diff --git a/src/clients/ioxide.file/ioxide.file.csproj b/src/clients/ioxide.file/ioxide.file.csproj index 5c245347..92e9ac96 100644 --- a/src/clients/ioxide.file/ioxide.file.csproj +++ b/src/clients/ioxide.file/ioxide.file.csproj @@ -8,7 +8,7 @@ ioxide.file ioxide.file - 0.6.208 + 0.7.209 MDA2AV File serving for the ioxide io_uring runtime: immutable asset snapshots with baked responses, pooled positional ring reads, atomic reloads. MIT diff --git a/src/clients/ioxide.httpclient/ioxide.httpclient.csproj b/src/clients/ioxide.httpclient/ioxide.httpclient.csproj index d75920d9..ac0cef5c 100644 --- a/src/clients/ioxide.httpclient/ioxide.httpclient.csproj +++ b/src/clients/ioxide.httpclient/ioxide.httpclient.csproj @@ -8,7 +8,7 @@ ioxide.httpclient ioxide.httpclient - 0.6.208 + 0.7.209 MDA2AV The ring-native HTTP/1.1 client for the ioxide io_uring runtime - the upstream leg between a proxy and an origin. Connections are opened on the reactor thread that will use them, so a request never crosses a thread on its way out or back, and every response resumes the awaiting handler inline on its own reactor. Includes client-side TLS (SNI, ALPN, certificate verification and client certificates for mutual TLS) for https:// origins. Depends on ioxide core alone: no protocol package, no native asset. MIT diff --git a/src/clients/ioxide.pg/ioxide.pg.csproj b/src/clients/ioxide.pg/ioxide.pg.csproj index 0a71af6b..724fc9a0 100644 --- a/src/clients/ioxide.pg/ioxide.pg.csproj +++ b/src/clients/ioxide.pg/ioxide.pg.csproj @@ -8,7 +8,7 @@ ioxide.pg ioxide.pg - 0.6.208 + 0.7.209 MDA2AV Postgres driver for the ioxide io_uring runtime: pooled ring-native connections per reactor, ring-native connect and handshake, inline completion resume. MIT diff --git a/src/clients/ioxide.redis/ioxide.redis.csproj b/src/clients/ioxide.redis/ioxide.redis.csproj index cff0bc51..dd95c79b 100644 --- a/src/clients/ioxide.redis/ioxide.redis.csproj +++ b/src/clients/ioxide.redis/ioxide.redis.csproj @@ -8,7 +8,7 @@ ioxide.redis ioxide.redis - 0.6.208 + 0.7.209 MDA2AV Redis client for the ioxide io_uring runtime: pooled ring-native connections per reactor, full RESP2 protocol, a generic command API plus typed helpers (strings, keys, hashes, lists, sets, sorted sets, pub/sub, transactions, scripting), and pipelining. Inline completion resume. MIT diff --git a/src/ioxide/Connection/Quic/QuicConnection.cs b/src/ioxide/Connection/Quic/QuicConnection.cs index b6f6002c..33460673 100644 --- a/src/ioxide/Connection/Quic/QuicConnection.cs +++ b/src/ioxide/Connection/Quic/QuicConnection.cs @@ -44,6 +44,26 @@ protected QuicConnection(int recvQueueEntries = 256) /// public abstract void OnDatagram(ReadOnlySpan payload, byte tos); + /// + /// The same delivery, plus the address it actually came FROM. An engine that supports + /// connection migration needs this: a peer that changed network sends on the same connection + /// id from a new address, and the transport is the only layer that can see the difference. + /// + /// + /// Virtual rather than abstract, forwarding to the two-argument form, so a binding that does + /// not care about paths - and every existing subclass - keeps working untouched. The address + /// points into the recv slot and is valid only for the duration of the call; anything keeping + /// it must copy. + /// + /// Being told an address is NOT permission to answer it freely. An engine that adopts a peer + /// address on the strength of a datagram claiming it becomes an amplification reflector for + /// whoever spoofed it, so an engine must bound what it sends on a path until that path has + /// been validated - which is what QUIC's PATH_CHALLENGE and the 3x anti-amplification limit + /// are for. Adoption and validation are not the same event and do not happen in that order. + /// + public virtual void OnDatagram(ReadOnlySpan payload, byte tos, nint peerAddr, int peerAddrLen) + => OnDatagram(payload, tos); + /// Next engine deadline in ms; long.MaxValue = none. public abstract long GetNextTimeout(long nowMs); @@ -126,6 +146,15 @@ protected void Send(ReadOnlySpan payload, int gsoSegmentSize = 0) /// Adopt a validated peer migration (copies the sockaddr out of the datagram). public unsafe void UpdatePeerAddress(nint addr, int addrLen) { + // Same guard Send() carries, and for the same reason: QuicRemoveConnection frees this + // block and zeroes the field, so a late call must not write through it. A null destination + // here would be an access violation rather than an exception, which no catch upstream + // could contain. + if (PeerAddr == 0 || addr == 0 || addrLen <= 0 || addrLen > Reactor.UdpNameCap) + { + return; + } + Buffer.MemoryCopy((void*)addr, (void*)PeerAddr, Reactor.UdpNameCap, addrLen); PeerAddrLen = addrLen; } diff --git a/src/ioxide/Reactor/Transport/Quic/Reactor.Quic.cs b/src/ioxide/Reactor/Transport/Quic/Reactor.Quic.cs index ddbf1f6d..21c6a11e 100644 --- a/src/ioxide/Reactor/Transport/Quic/Reactor.Quic.cs +++ b/src/ioxide/Reactor/Transport/Quic/Reactor.Quic.cs @@ -94,7 +94,7 @@ private void QuicDispatchDatagram(in UdpDatagram datagram) if (_quicConns.TryGetValue(dcid, out QuicConnection? conn)) { conn.LastSeenMs = Environment.TickCount64; - conn.OnDatagram(datagram.Payload, datagram.Tos); + conn.OnDatagram(datagram.Payload, datagram.Tos, datagram.PeerAddr, datagram.PeerAddrLen); QuicArmTimer(conn); // reads/handler sends (inline above) moved the engine deadline return; } @@ -141,7 +141,7 @@ private void QuicDispatchDatagram(in UdpDatagram datagram) freshQuicConnection.DecRef(); // no handler configured: the transport stays the only owner } - freshQuicConnection.OnDatagram(datagram.Payload, datagram.Tos); + freshQuicConnection.OnDatagram(datagram.Payload, datagram.Tos, datagram.PeerAddr, datagram.PeerAddrLen); QuicArmTimer(freshQuicConnection); } diff --git a/src/ioxide/ioxide.csproj b/src/ioxide/ioxide.csproj index bc9a22ac..d2970e4c 100644 --- a/src/ioxide/ioxide.csproj +++ b/src/ioxide/ioxide.csproj @@ -8,7 +8,7 @@ ioxide ioxide - 0.6.208 + 0.7.209 MDA2AV A shared-nothing io_uring runtime for .NET: one ring per reactor thread, inline completions, zero native dependencies. The engine - reactor, connection, and the IRingHost client seam. Includes TLS termination: the OpenSSL handshake driven over the ring, then kernel TLS (kTLS) transmit offload, so handlers keep writing plaintext. TLS needs OpenSSL 3 and the Linux tls module; nothing else does, and neither is loaded unless you use it. MIT diff --git a/src/protocols/ioxide.http2/ioxide.http2.csproj b/src/protocols/ioxide.http2/ioxide.http2.csproj index 7ec0f1fd..c7aada48 100644 --- a/src/protocols/ioxide.http2/ioxide.http2.csproj +++ b/src/protocols/ioxide.http2/ioxide.http2.csproj @@ -8,7 +8,7 @@ ioxide.http2 ioxide.http2 - 0.6.208 + 0.7.209 MDA2AV Pure-C# HTTP/2 for the ioxide io_uring runtime: framing, HPACK (static and dynamic tables, Huffman) and flow control, with zero native code. Serves h2c with prior knowledge and h2 over TLS by ALPN, buffered or streamed in either direction. MIT diff --git a/src/protocols/ioxide.http3/ioxide.http3.csproj b/src/protocols/ioxide.http3/ioxide.http3.csproj index fb354b19..e92cb86b 100644 --- a/src/protocols/ioxide.http3/ioxide.http3.csproj +++ b/src/protocols/ioxide.http3/ioxide.http3.csproj @@ -8,7 +8,7 @@ ioxide.http3 ioxide.http3 - 0.6.208 + 0.7.209 MDA2AV Pure C# HTTP/3 for the ioxide io_uring runtime: frame parsing, QPACK (static table + Huffman) and request dispatch with zero native dependencies. Rides any QuicConnection via its stream read surface - engine-agnostic, drop-in alternative to ioxide.nghttp3. MIT diff --git a/src/protocols/ioxide.nghttp2/ioxide.nghttp2.csproj b/src/protocols/ioxide.nghttp2/ioxide.nghttp2.csproj index c9b34348..5c59c5d3 100644 --- a/src/protocols/ioxide.nghttp2/ioxide.nghttp2.csproj +++ b/src/protocols/ioxide.nghttp2/ioxide.nghttp2.csproj @@ -8,7 +8,7 @@ ioxide.nghttp2 ioxide.nghttp2 - 0.6.208 + 0.7.209 MDA2AV HTTP/2 for the ioxide io_uring runtime: framing, HPACK and flow control from nghttp2, statically linked behind a small shim with no external dependencies beyond libc. Serves HTTP/2 over any TcpConnection - h2c with prior knowledge, or h2 over TLS via ALPN. nghttp2 is sans-I/O, so ioxide keeps the ring and the loop. MIT diff --git a/src/protocols/ioxide.nghttp3/ioxide.nghttp3.csproj b/src/protocols/ioxide.nghttp3/ioxide.nghttp3.csproj index ebca7231..e5820f53 100644 --- a/src/protocols/ioxide.nghttp3/ioxide.nghttp3.csproj +++ b/src/protocols/ioxide.nghttp3/ioxide.nghttp3.csproj @@ -8,7 +8,7 @@ ioxide.nghttp3 ioxide.nghttp3 - 0.6.208 + 0.7.209 MDA2AV HTTP/3 layer for the ioxide io_uring runtime: nghttp3 (H3 + QPACK) bundled as a single self-contained native library with no external dependencies. Rides any QuicConnection via its stream read surface - engine-agnostic, no ioxide.ngtcp2 dependency. MIT diff --git a/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.Callbacks.cs b/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.Callbacks.cs index 05e1f484..c7c50943 100644 --- a/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.Callbacks.cs +++ b/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.Callbacks.cs @@ -118,6 +118,38 @@ internal static void CbHandshakeCompleted(void* user) catch (Exception e) { c.OnCallbackFault(e, nameof(CbHandshakeCompleted)); } } + /// + /// ngtcp2 moved this connection to a new peer address, and the transport must follow - it owns + /// the socket, which is the half ngtcp2 cannot do for itself. + /// + /// It is worth being exact about what this does NOT say. The address is not validated when + /// this fires: ngtcp2 adopts the current path on the first non-probing 1-RTT packet from a new + /// address and validates afterwards. Safety comes from the packet having decrypted under 1-RTT + /// keys - which an off-path attacker cannot forge - and from ngtcp2's own anti-amplification + /// limit, which caps what it will send on a path that has not validated yet. + /// + [UnmanagedCallersOnly] + internal static void CbPathChange(void* user, void* remoteAddr, nuint len) + { + QuicEngineConnection? c = From(user); + if (c is null) + { + return; + } + + try + { + // Flush FIRST. Anything already coalesced in the GSO batch was built for the address + // in force when it was queued, and this call is what changes that address - sending + // the batch afterwards would deliver those datagrams to a different peer address than + // ngtcp2 addressed them to. Egress.cs states the batch is single-destination "by + // construction"; that holds only because this flush keeps it true. + c.FlushBatchBeforePathChange(); + c.UpdatePeerAddress((nint)remoteAddr, (int)len); + } + catch (Exception e) { c.OnCallbackFault(e, nameof(CbPathChange)); } + } + [UnmanagedCallersOnly] internal static void CbNewCid(void* user, byte* cid, nuint len) { diff --git a/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.Egress.cs b/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.Egress.cs index da0d180b..cf5b83c1 100644 --- a/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.Egress.cs +++ b/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.Egress.cs @@ -8,7 +8,11 @@ public unsafe partial class QuicEngineConnection // One sendmsg per engine cycle instead of one per datagram: ngtcp2 emits runs of equal-size // (MTU-full) datagrams under load, which is exactly the UDP_SEGMENT shape. A shorter datagram // may only END a batch (GSO semantics: equal segments, the last may be short). All datagrams - // in a batch are this connection's, so the destination is single by construction. + // in a batch are this connection's, and the batch is flushed whenever the peer address moves + // (FlushBatchBeforePathChange), so the destination is single for the life of a batch. That + // qualifier matters since migration landed: a connection can have more than one live path + // while one is being validated, and ngtcp2 addresses a PATH_RESPONSE to the path its + // challenge arrived on rather than to the current one. private readonly byte[] _gsoBuf = new byte[63 * 1024]; // < 65507, the UDP payload ceiling private int _gsoLen; @@ -89,6 +93,12 @@ private void QueueSend(ReadOnlySpan datagram) } } + /// + /// Send what is queued before the peer address moves. The batch is addressed at send time, not + /// at queue time, so a path change with datagrams still coalesced would readdress them. + /// + internal void FlushBatchBeforePathChange() => FlushGso(); + private void FlushGso() { if (_gsoLen == 0) diff --git a/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.OnDatagram.cs b/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.OnDatagram.cs index dafbabb1..c3e49cb5 100644 --- a/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.OnDatagram.cs +++ b/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.OnDatagram.cs @@ -3,7 +3,11 @@ namespace ioxide.ngtcp2; /// Ingress: datagrams routed by the transport are fed to ngtcp2 here. public unsafe partial class QuicEngineConnection { + /// Kept for callers that have no address to give - feeds ngtcp2 the path it already has. public override void OnDatagram(ReadOnlySpan payload, byte tos) + => OnDatagram(payload, tos, 0, 0); + + public override void OnDatagram(ReadOnlySpan payload, byte tos, nint peerAddr, int peerAddrLen) { if (_closed) { @@ -12,7 +16,7 @@ public override void OnDatagram(ReadOnlySpan payload, byte tos) _inEngineCycle = true; try { - OnDatagramCore(payload, tos); + OnDatagramCore(payload, tos, peerAddr, peerAddrLen); } finally { @@ -20,7 +24,7 @@ public override void OnDatagram(ReadOnlySpan payload, byte tos) } } - private void OnDatagramCore(ReadOnlySpan payload, byte tos) + private void OnDatagramCore(ReadOnlySpan payload, byte tos, nint peerAddr, int peerAddrLen) { // One call = one wire datagram: the transport pre-splits GRO trains before demux. int rv; @@ -28,8 +32,13 @@ private void OnDatagramCore(ReadOnlySpan payload, byte tos) // TODO: can we get the byte* without a fixed fixed (byte* p = payload) { - // milestone: no migration - the path is fixed at accept, so remote_sa is unused. - rv = Ngtcp2.iq_conn_read(_conn, null, 0, p, (nuint)payload.Length, tos, NowNs()); + // The address this datagram really came from. ngtcp2 compares it against the path in + // force and, when they differ, validates the new one with PATH_CHALLENGE before + // adopting it - so passing it is not "trust the sender", it is giving the library the + // input its own migration logic needs. Zero means the caller had none, and the path + // it already holds is used. + rv = Ngtcp2.iq_conn_read(_conn, (void*)peerAddr, (nuint)peerAddrLen, + p, (nuint)payload.Length, tos, NowNs()); } if (rv != 0) { diff --git a/src/protocols/ioxide.ngtcp2/Engine/QuicClientEngine.cs b/src/protocols/ioxide.ngtcp2/Engine/QuicClientEngine.cs index f81f4227..66fe052b 100644 --- a/src/protocols/ioxide.ngtcp2/Engine/QuicClientEngine.cs +++ b/src/protocols/ioxide.ngtcp2/Engine/QuicClientEngine.cs @@ -30,6 +30,7 @@ public QuicClientEngine(string alpn = "h3") // too (without them, send-retention would never be freed). var callbacks = new Ngtcp2.Callbacks { + StructSize = (nuint)sizeof(Ngtcp2.Callbacks), OnStreamData = &QuicEngineConnection.CbStreamData, OnStreamClose = &QuicEngineConnection.CbStreamClose, OnHandshakeCompleted = &QuicEngineConnection.CbHandshakeCompleted, diff --git a/src/protocols/ioxide.ngtcp2/Engine/QuicEngine.cs b/src/protocols/ioxide.ngtcp2/Engine/QuicEngine.cs index fdb581c5..b3c8b3d7 100644 --- a/src/protocols/ioxide.ngtcp2/Engine/QuicEngine.cs +++ b/src/protocols/ioxide.ngtcp2/Engine/QuicEngine.cs @@ -137,6 +137,7 @@ public QuicEngine(string certPemPath, string keyPemPath, uint cidLength = 8, str var callbacks = new Ngtcp2.Callbacks { + StructSize = (nuint)sizeof(Ngtcp2.Callbacks), OnStreamData = &QuicEngineConnection.CbStreamData, OnStreamClose = &QuicEngineConnection.CbStreamClose, OnHandshakeCompleted = &QuicEngineConnection.CbHandshakeCompleted, @@ -145,6 +146,7 @@ public QuicEngine(string certPemPath, string keyPemPath, uint cidLength = 8, str OnStreamReset = &QuicEngineConnection.CbStreamReset, OnStreamStopSending = &QuicEngineConnection.CbStreamStopSending, OnAckedStreamData = &QuicEngineConnection.CbAckedStreamData, + OnPathChange = &QuicEngineConnection.CbPathChange, }; byte[] alpnWire = AlpnWire(alpn); diff --git a/src/protocols/ioxide.ngtcp2/Interop/Ngtcp2.cs b/src/protocols/ioxide.ngtcp2/Interop/Ngtcp2.cs index cf292eee..36b671c1 100644 --- a/src/protocols/ioxide.ngtcp2/Interop/Ngtcp2.cs +++ b/src/protocols/ioxide.ngtcp2/Interop/Ngtcp2.cs @@ -19,6 +19,11 @@ internal static unsafe class Ngtcp2 [StructLayout(LayoutKind.Sequential)] internal struct Callbacks { + /// sizeof(iq_callbacks) as THIS side compiled it. The shim refuses a table it + /// does not recognise, because the struct is passed by value and mirrored by hand - a + /// member added on one side only is otherwise a silent read past the caller's buffer. + public nuint StructSize; + public delegate* unmanaged OnStreamData; public delegate* unmanaged OnStreamClose; public delegate* unmanaged OnHandshakeCompleted; @@ -27,6 +32,12 @@ internal struct Callbacks public delegate* unmanaged OnStreamReset; public delegate* unmanaged OnStreamStopSending; public delegate* unmanaged OnAckedStreamData; + + /// ngtcp2 moved this connection to a new peer address. NOT a statement that the + /// address was validated first - adoption happens on the first non-probing 1-RTT packet + /// from it, and validation follows. The protection is ngtcp2's 3x anti-amplification limit + /// on an unvalidated path, plus the packet having decrypted under 1-RTT keys. + public delegate* unmanaged OnPathChange; } diff --git a/src/protocols/ioxide.ngtcp2/ioxide.ngtcp2.csproj b/src/protocols/ioxide.ngtcp2/ioxide.ngtcp2.csproj index 2cf01efb..6f76761d 100644 --- a/src/protocols/ioxide.ngtcp2/ioxide.ngtcp2.csproj +++ b/src/protocols/ioxide.ngtcp2/ioxide.ngtcp2.csproj @@ -8,7 +8,7 @@ ioxide.ngtcp2 ioxide.ngtcp2 - 0.6.208 + 0.7.209 MDA2AV QUIC engine for the ioxide io_uring runtime: ngtcp2 + picotls bundled as a single self-contained native library (only system dependency: libcrypto.so.3 / OpenSSL 3.x). Plugs into the reactor's QUIC transport via QuicConnection. Server side; engine bindings in progress. MIT diff --git a/src/protocols/ioxide.ngtcp2/native/ioxide_ngtcp2_shim.c b/src/protocols/ioxide.ngtcp2/native/ioxide_ngtcp2_shim.c index 2339eeb8..92d007d8 100644 --- a/src/protocols/ioxide.ngtcp2/native/ioxide_ngtcp2_shim.c +++ b/src/protocols/ioxide.ngtcp2/native/ioxide_ngtcp2_shim.c @@ -40,6 +40,12 @@ /* ---- callback table into C# ------------------------------------------------------------- */ typedef struct iq_callbacks { + /* Bytes the CALLER compiled against. This struct is passed BY VALUE and is mirrored by hand in + * several managed declarations, so a member added on one side and not the others makes the + * callee read past what the caller wrote - silently, because nothing on either side can see the + * mismatch. Checked at engine creation instead of being discovered as a garbage pointer. */ + size_t struct_size; + void (*on_stream_data)(void *user, int64_t stream_id, const uint8_t *data, size_t datalen, int fin); void (*on_stream_close)(void *user, int64_t stream_id, uint64_t app_error_code); void (*on_handshake_completed)(void *user); @@ -53,6 +59,14 @@ typedef struct iq_callbacks { * ngtcp2 retains POINTERS into the app's buffers for retransmission until this fires - the * caller of iq_conn_write must keep stream bytes alive until then. May be NULL. */ void (*on_acked_stream_data)(void *user, int64_t stream_id, uint64_t offset, uint64_t datalen); + /* ngtcp2 moved this connection to a new peer address - the connection's path moved. What this does NOT mean is that the new + * address was validated first: ngtcp2 adopts the current path on the first non-probing 1-RTT + * packet from a new address (conn_recv_non_probing_pkt_on_new_path) and starts validating + * afterwards. What makes that safe is not ordering but ngtcp2's own anti-amplification limit - + * it will not send more than 3x what it has received on a path until that path validates - and + * the fact that the packet had to decrypt under 1-RTT keys, which an off-path attacker cannot + * forge. May be NULL. */ + void (*on_path_change)(void *user, const void *remote_sa, size_t remote_salen); } iq_callbacks; /* ---- objects ---------------------------------------------------------------------------- */ @@ -132,6 +146,11 @@ typedef struct iq_conn { ngtcp2_crypto_picotls_ctx cptls; ngtcp2_sockaddr_union local_addr; ngtcp2_sockaddr_union remote_addr; + /* The address last REPORTED to the caller. c->remote_addr is rewritten by ngtcp2 on every + * write (the path argument to writev_stream is an OUT parameter), so it cannot double as the + * record of what the caller believes - the two must be compared, not conflated. */ + ngtcp2_sockaddr_union reported_addr; + ngtcp2_socklen reported_addrlen; ngtcp2_path path; ngtcp2_ccerr last_error; void *user; @@ -1106,6 +1125,13 @@ iq_engine *iq_engine_new_mtls(const char *cert_pem_path, const char *key_pem_pat const char *client_ca_pem_path, const char *client_ca_pem, int require_client_cert, iq_callbacks cbs) { + if (cbs.struct_size != sizeof(iq_callbacks)) { + fprintf(stderr, "[ioxide.ngtcp2] callback table is %zu bytes, this build expects %zu - " + "a managed mirror of iq_callbacks is out of date\n", + cbs.struct_size, sizeof(iq_callbacks)); + return NULL; + } + /* Bounded here and not merely where the CID is minted: ngtcp2_cid.data is a fixed * NGTCP2_MAX_CIDLEN array, so accepting a longer length would store an overflow in the engine * and hand every accept a stack smash. A zero length leaves the caller nothing to route on. */ @@ -1296,6 +1322,11 @@ iq_conn *iq_accept(iq_engine *e, c->path.remote.addr = &c->remote_addr.sa; c->path.remote.addrlen = (ngtcp2_socklen)remote_salen; + /* What the caller already believes, so the first sync is a no-op. Left zeroed, every new + * connection would report a path change it had not had. */ + memcpy(&c->reported_addr, remote_sa, remote_salen); + c->reported_addrlen = (ngtcp2_socklen)remote_salen; + ngtcp2_callbacks callbacks = {0}; callbacks.recv_client_initial = ngtcp2_crypto_recv_client_initial_cb; callbacks.recv_crypto_data = ngtcp2_crypto_recv_crypto_data_cb; @@ -1332,6 +1363,13 @@ iq_conn *iq_accept(iq_engine *e, params.initial_max_data = 1024 * 1024; params.initial_max_streams_bidi = 1024; params.initial_max_streams_uni = 100; + /* The budget for connection ids the CLIENT issues us, and a migration spends one: a client + * that moves is required to use a fresh CID, and ngtcp2 pops one from this pool to do it. + * ngtcp2's default is RFC 9000's minimum of 2, which during a validation window is current + + * fallback + nothing spare - so a second migration inside that window finds the pool empty, + * ngtcp2 swallows it ("DCID is not available. Just continue."), the path never moves and the + * connection blackholes with no error anywhere. */ + params.active_connection_id_limit = 8; params.original_dcid = hd.dcid; params.original_dcid_present = 1; @@ -1390,15 +1428,83 @@ void iq_conn_free(iq_conn *c) free(c); } + +/* Tell the caller if the path in force has moved since we last said so. + * + * ngtcp2 changes conn->dcid.current in four places, and only three are inside read_pkt. The fourth + * is conn_on_path_validation_failed, reached from conn_write_path_challenge - i.e. from INSIDE a + * write - and it restores the previous, validated path when a probe times out. Watching only the + * read path meant that recovery was invisible: the caller stayed pinned to an address that had + * just failed validation, permanently, because ngtcp2 also rewrites our remote_addr on every write + * so the next read compared equal and never fired again. A connection that would have survived + * died at the idle sweep instead. + * + * ngtcp2 fills the path we hand to writev_stream with the destination it chose for THAT datagram, + * which for a PATH_RESPONSE or a PATH_CHALLENGE probe is not the current path at all. Calling this + * before the datagram is handed back is what lets the caller send it where ngtcp2 meant it to go. */ +static void iq_sync_path(iq_conn *c) +{ + if (c->path.remote.addrlen == 0) { + return; + } + + if (c->path.remote.addrlen == c->reported_addrlen && + ngtcp2_sockaddr_eq(&c->reported_addr.sa, c->path.remote.addr)) { + return; + } + + if (c->path.remote.addrlen > sizeof(c->reported_addr)) { + return; + } + + memcpy(&c->reported_addr, c->path.remote.addr, c->path.remote.addrlen); + c->reported_addrlen = c->path.remote.addrlen; + + if (c->cbs.on_path_change) { + c->cbs.on_path_change(c->user, &c->reported_addr, c->reported_addrlen); + } +} + /* Feed one UDP datagram (the transport already split GRO trains). Returns 0, or a negative * ngtcp2 error - NGTCP2_ERR_DRAINING / NGTCP2_ERR_DROP_CONN mean "stop using this conn". */ int iq_conn_read(iq_conn *c, const void *remote_sa, size_t remote_salen, const uint8_t *pkt, size_t pktlen, uint8_t ecn, uint64_t ts) { - (void)remote_sa; (void)remote_salen; /* milestone: no migration; path is fixed at accept */ - ngtcp2_pkt_info pi = { .ecn = (uint8_t)(ecn & 0x3) }; - return ngtcp2_conn_read_pkt(c->conn, &c->path, &pi, pkt, pktlen, ts); + + /* The path this datagram actually arrived on, not the one it arrived on at accept. Handing + * ngtcp2 a stale path is what made migration impossible: it cannot validate a change it is + * never told about, and it answers whatever address it was given. Everything below this line + * is ngtcp2's decision, not ours - it issues PATH_CHALLENGE, waits for the PATH_RESPONSE, and + * only then adopts. We are removing a lie, not implementing migration. */ + ngtcp2_path path = c->path; + /* A lower bound as well as an upper one: below sizeof(ngtcp2_sockaddr) the family field is not + * even fully present, and ngtcp2_sockaddr_eq would read past what the caller vouched for - + * an unexpected family reaches ngtcp2_unreachable() and aborts the process. Unreachable + * through a Linux recvmsg on an AF_INET/AF_INET6 socket, but this value is now on the wire + * path rather than discarded. */ + if (remote_sa != NULL && remote_salen >= sizeof(ngtcp2_sockaddr) && + remote_salen <= sizeof(ngtcp2_sockaddr_union)) { + path.remote.addr = (ngtcp2_sockaddr *)remote_sa; + path.remote.addrlen = (ngtcp2_socklen)remote_salen; + } + + int rv = ngtcp2_conn_read_pkt(c->conn, &path, &pi, pkt, pktlen, ts); + + /* Before the error check on purpose: read_pkt can adopt a new path and then fail on a later + * coalesced packet in the same datagram. Returning early there left the caller sending the + * CONNECTION_CLOSE to the address the peer had just left, which is the silent death this whole + * change exists to remove. */ + const ngtcp2_path *now = ngtcp2_conn_get_path2(c->conn); + if (now != NULL && now->remote.addrlen > 0 && + now->remote.addrlen <= sizeof(c->remote_addr)) { + memcpy(&c->remote_addr, now->remote.addr, now->remote.addrlen); + c->path.remote.addr = &c->remote_addr.sa; + c->path.remote.addrlen = now->remote.addrlen; + } + iq_sync_path(c); + + return rv; } /* Produce at most one UDP datagram into dest. With stream_id >= 0, tries to include data from @@ -1422,6 +1528,18 @@ ngtcp2_ssize iq_conn_write(iq_conn *c, uint8_t *dest, size_t destlen, data != NULL ? &vec : NULL, data != NULL ? 1 : 0, ts); *pconsumed = consumed; + + /* writev_stream's path argument is an OUT parameter: ngtcp2 has just written the destination + * it chose for THIS datagram into c->path. For ordinary traffic that is the current path; for + * a PATH_RESPONSE it is the address the challenge arrived from, and for a probe it is the + * address being validated. Reporting it here is what stops those going to the wrong peer - + * RFC 9000 8.2.2 requires a PATH_RESPONSE on the path its challenge came in on. */ + /* writev_stream's path argument is an OUT parameter: ngtcp2 has just written the destination + * it chose for THIS datagram into c->path. For ordinary traffic that is the current path; for + * a PATH_RESPONSE it is the address the challenge arrived from, and for a probe it is the + * address being validated. Reporting it here is what stops those going to the wrong peer - + * RFC 9000 8.2.2 requires a PATH_RESPONSE on the path its challenge came in on. */ + iq_sync_path(c); return n; } @@ -1455,7 +1573,9 @@ uint64_t iq_conn_expiry(iq_conn *c) int iq_conn_handle_expiry(iq_conn *c, uint64_t ts) { - return ngtcp2_conn_handle_expiry(c->conn, ts); + int rv = ngtcp2_conn_handle_expiry(c->conn, ts); + iq_sync_path(c); + return rv; } int iq_conn_is_established(iq_conn *c) @@ -1590,6 +1710,14 @@ void iq_client_engine_record_server_certificate(iq_client_engine *e) iq_client_engine *iq_client_engine_new_mtls(const char *alpn, const char *cert_pem_path, const char *key_pem_path, iq_callbacks cbs) { + if (cbs.struct_size != sizeof(iq_callbacks)) { + fprintf(stderr, "[ioxide.ngtcp2] callback table is %zu bytes, this build expects %zu - " + "a managed mirror of iq_callbacks is out of date\n", + cbs.struct_size, sizeof(iq_callbacks)); + return NULL; + } + + iq_client_engine *e = calloc(1, sizeof(*e)); if (e == NULL) { return NULL; @@ -1678,6 +1806,11 @@ iq_conn *iq_client_connect(iq_client_engine *e, c->path.remote.addr = &c->remote_addr.sa; c->path.remote.addrlen = (ngtcp2_socklen)remote_salen; + /* What the caller already believes, so the first sync is a no-op. Left zeroed, every new + * connection would report a path change it had not had. */ + memcpy(&c->reported_addr, remote_sa, remote_salen); + c->reported_addrlen = (ngtcp2_socklen)remote_salen; + ngtcp2_callbacks callbacks = {0}; callbacks.client_initial = ngtcp2_crypto_client_initial_cb; callbacks.recv_crypto_data = ngtcp2_crypto_recv_crypto_data_cb; diff --git a/src/protocols/ioxide.ngtcp2/runtimes/linux-x64/native/libioxide_ngtcp2.so b/src/protocols/ioxide.ngtcp2/runtimes/linux-x64/native/libioxide_ngtcp2.so index 9a8e9b6f..d506d1a0 100755 Binary files a/src/protocols/ioxide.ngtcp2/runtimes/linux-x64/native/libioxide_ngtcp2.so and b/src/protocols/ioxide.ngtcp2/runtimes/linux-x64/native/libioxide_ngtcp2.so differ diff --git a/src/serving/ioxide.Kestrel/ioxide.Kestrel.csproj b/src/serving/ioxide.Kestrel/ioxide.Kestrel.csproj index 30a39d6e..43cb9c0e 100644 --- a/src/serving/ioxide.Kestrel/ioxide.Kestrel.csproj +++ b/src/serving/ioxide.Kestrel/ioxide.Kestrel.csproj @@ -8,7 +8,7 @@ ioxide.Kestrel ioxide.Kestrel - 0.6.208 + 0.7.209 MDA2AV ASP.NET Core Kestrel transport backed by the ioxide io_uring runtime: one reactor (ring) per core, SO_REUSEPORT load-balanced, with Kestrel's HTTP request loop pinned to the reactor thread. Drop-in via UseIoxide(). MIT diff --git a/tests/Ioxide.Tests.E2E/Program.cs b/tests/Ioxide.Tests.E2E/Program.cs index f656e348..c7d51dab 100644 --- a/tests/Ioxide.Tests.E2E/Program.cs +++ b/tests/Ioxide.Tests.E2E/Program.cs @@ -31,6 +31,7 @@ private static int Main() QuicTimerTests.Register(runner); QuicSniHostileTests.Register(runner); QuicDemuxRoutingTests.Register(runner); + QuicMigrationTests.Register(runner); QuicClientCertTimingTests.Register(runner); H3BodyTruncationTests.Register(runner); H3ErrorCodeTests.Register(runner); diff --git a/tests/Ioxide.Tests.E2E/Protocols/H3BodyTruncationTests.cs b/tests/Ioxide.Tests.E2E/Protocols/H3BodyTruncationTests.cs index df3f65f7..cfb9fb4b 100644 --- a/tests/Ioxide.Tests.E2E/Protocols/H3BodyTruncationTests.cs +++ b/tests/Ioxide.Tests.E2E/Protocols/H3BodyTruncationTests.cs @@ -246,7 +246,7 @@ public RawH3Client(string host, int port) public void Connect() { _self = GCHandle.Alloc(this); - var callbacks = new IqCallbacks { OnStreamData = &OnStreamData }; + var callbacks = new IqCallbacks { StructSize = (nuint)sizeof(IqCallbacks), OnStreamData = &OnStreamData }; _engine = iq_client_engine_new_mtls("h3", null, null, callbacks); Assert.True(_engine != 0, "client engine init failed"); @@ -477,6 +477,7 @@ public void Dispose() [StructLayout(LayoutKind.Sequential)] private struct IqCallbacks { + public nuint StructSize; public delegate* unmanaged OnStreamData; public delegate* unmanaged OnStreamClose; public delegate* unmanaged OnHandshakeCompleted; @@ -485,6 +486,7 @@ private struct IqCallbacks public delegate* unmanaged OnStreamReset; public delegate* unmanaged OnStreamStopSending; public delegate* unmanaged OnAckedStreamData; + public delegate* unmanaged OnPathChange; } private const string Lib = "ioxide_ngtcp2"; diff --git a/tests/Ioxide.Tests.E2E/Protocols/H3ErrorCodeTests.cs b/tests/Ioxide.Tests.E2E/Protocols/H3ErrorCodeTests.cs index e69e4cca..d3e13d6b 100644 --- a/tests/Ioxide.Tests.E2E/Protocols/H3ErrorCodeTests.cs +++ b/tests/Ioxide.Tests.E2E/Protocols/H3ErrorCodeTests.cs @@ -406,7 +406,7 @@ public RawH3Peer(string host, int port) public void Connect() { _self = GCHandle.Alloc(this); - var cbs = new IqCallbacks { OnStreamData = &OnStreamData }; + var cbs = new IqCallbacks { StructSize = (nuint)sizeof(IqCallbacks), OnStreamData = &OnStreamData }; _engine = iq_client_engine_new_mtls("h3", null, null, cbs); Assert.True(_engine != 0, "client engine init failed"); @@ -610,6 +610,7 @@ public void Dispose() [StructLayout(LayoutKind.Sequential)] private struct IqCallbacks { + public nuint StructSize; public delegate* unmanaged OnStreamData; public delegate* unmanaged OnStreamClose; public delegate* unmanaged OnHandshakeCompleted; @@ -618,6 +619,7 @@ private struct IqCallbacks public delegate* unmanaged OnStreamReset; public delegate* unmanaged OnStreamStopSending; public delegate* unmanaged OnAckedStreamData; + public delegate* unmanaged OnPathChange; } private const string QuicLib = "ioxide_ngtcp2"; diff --git a/tests/Ioxide.Tests.E2E/Protocols/QuicDeferredFaultTests.cs b/tests/Ioxide.Tests.E2E/Protocols/QuicDeferredFaultTests.cs index b6e9d4d3..0acb9643 100644 --- a/tests/Ioxide.Tests.E2E/Protocols/QuicDeferredFaultTests.cs +++ b/tests/Ioxide.Tests.E2E/Protocols/QuicDeferredFaultTests.cs @@ -284,7 +284,7 @@ public QuicFaultClient(string host, int port) public void Connect() { - var cbs = new IqCallbacks { OnStreamData = &OnClientStreamData }; + var cbs = new IqCallbacks { StructSize = (nuint)sizeof(IqCallbacks), OnStreamData = &OnClientStreamData }; _clientEngine = iq_client_engine_new_mtls("echo", null, null, cbs); Assert.True(_clientEngine != 0, "client engine init failed"); @@ -538,6 +538,7 @@ public void Dispose() [StructLayout(LayoutKind.Sequential)] private struct IqCallbacks { + public nuint StructSize; public delegate* unmanaged OnStreamData; public delegate* unmanaged OnStreamClose; public delegate* unmanaged OnHandshakeCompleted; @@ -546,6 +547,7 @@ private struct IqCallbacks public delegate* unmanaged OnStreamReset; public delegate* unmanaged OnStreamStopSending; public delegate* unmanaged OnAckedStreamData; + public delegate* unmanaged OnPathChange; } private const string Lib = "ioxide_ngtcp2"; diff --git a/tests/Ioxide.Tests.E2E/Protocols/QuicEngineTests.cs b/tests/Ioxide.Tests.E2E/Protocols/QuicEngineTests.cs index 61abf2cf..7d10495c 100644 --- a/tests/Ioxide.Tests.E2E/Protocols/QuicEngineTests.cs +++ b/tests/Ioxide.Tests.E2E/Protocols/QuicEngineTests.cs @@ -344,6 +344,7 @@ public void Connect() { var cbs = new IqCallbacks { + StructSize = (nuint)sizeof(IqCallbacks), OnStreamData = &OnClientStreamData, }; _clientEngine = iq_client_engine_new_mtls("echo", null, null, cbs); @@ -511,6 +512,7 @@ public void Dispose() [StructLayout(LayoutKind.Sequential)] private struct IqCallbacks { + public nuint StructSize; public delegate* unmanaged OnStreamData; public delegate* unmanaged OnStreamClose; public delegate* unmanaged OnHandshakeCompleted; @@ -519,6 +521,7 @@ private struct IqCallbacks public delegate* unmanaged OnStreamReset; public delegate* unmanaged OnStreamStopSending; public delegate* unmanaged OnAckedStreamData; + public delegate* unmanaged OnPathChange; } private const string Lib = "ioxide_ngtcp2"; diff --git a/tests/Ioxide.Tests.E2E/Protocols/QuicMigrationTests.cs b/tests/Ioxide.Tests.E2E/Protocols/QuicMigrationTests.cs new file mode 100644 index 00000000..7d622e1b --- /dev/null +++ b/tests/Ioxide.Tests.E2E/Protocols/QuicMigrationTests.cs @@ -0,0 +1,223 @@ +using System.Net; +using System.Net.Sockets; +using ioxide.nghttp3; +using ioxide.ngtcp2; + +namespace Ioxide.Tests; + +/// +/// A client whose address changes mid-connection - the thing QUIC's connection id exists for. +/// +/// +/// The shape is h2o's 40http3-migration.t and nginx's quic_migration.t: put a UDP +/// forwarder between client and server, then swap the forwarder's UPSTREAM socket part way +/// through. The server sees the same connection id arriving from a new source address, exactly as +/// it would when a NAT rebinds a mapping or a phone moves from Wi-Fi to cellular. +/// +/// That trigger is more ordinary than "the user changed network": home and mobile NATs recycle UDP +/// mappings after fairly short idle periods, so a connection that goes quiet and speaks again can +/// come back from a different port without the client moving at all. +/// +/// What must happen is ngtcp2's decision, not ours - it challenges the new path, waits for the +/// response, and only then adopts it. What ioxide has to get right is feeding it the address the +/// datagram actually arrived on, and then sending to the address it settled on. +/// +internal static class QuicMigrationTests +{ + public static void Register(Runner runner) + { + runner.Test("quic/migration: a client whose address changes keeps being served", () => + { + (string certPath, string keyPath) = TestCert.Ensure(); + using var engine = new QuicEngine(certPath, keyPath, cidLength: 8, alpn: ["h3"]); + + (_, int serverPort) = TestServer.StartDatagram( + onDatagram: null, + quicFactory: engine.CreateFactory(), + quicHandle: static (_, conn) => new Nghttp3Connection(conn).RunBufferedAsync( + static _ => Nghttp3Response.Text("migrated-ok"))); + + using var forwarder = new UdpForwarder(serverPort); + using var client = new H3TestClient("127.0.0.1", forwarder.Port); + + client.Connect(); + Assert.True(client.CompleteHandshake(10_000), "the handshake through the forwarder did not complete"); + + // Before: proves the path under test is a working one, so a failure after the swap is + // the swap and not the forwarder. + (int firstStatus, string firstBody) = client.Request("GET", "/before", null, 10_000); + Assert.Equal(200, firstStatus); + Assert.Equal("migrated-ok", firstBody); + + // The client's address changes. Same connection id, new source port. + forwarder.SwapUpstream(); + + (int secondStatus, string secondBody) = client.Request("GET", "/after", null, 15_000); + + Assert.Equal(200, secondStatus); + Assert.Equal("migrated-ok", secondBody); + // The discriminating assertion, and the reason the two above are not enough. A server + // that ignores the address change still answers requests it received BEFORE the swap, + // so "the request succeeded" proves nothing on its own. What only a migrated server can + // do is send to the new address - measured here as datagrams arriving back on the + // socket the swap created. Without the path being passed through to ngtcp2 this is 0. + Assert.True(forwarder.RelayedAfterSwap > 0, + $"nothing was relayed after the swap, so the exchange never crossed the new path"); + Assert.True(forwarder.FromServerAfterSwap > 0, + "the server never sent anything to the client's NEW address: it kept answering the " + + "address the connection was accepted on, which is the connection blackholing"); + }); + + runner.Test("control: the same exchange through a forwarder that never swaps", () => + { + // Without this, the test above is satisfied by a forwarder that works and a migration + // that never happened - and it would also hide a forwarder too slow to relay in time. + (string certPath, string keyPath) = TestCert.Ensure(); + using var engine = new QuicEngine(certPath, keyPath, cidLength: 8, alpn: ["h3"]); + + (_, int serverPort) = TestServer.StartDatagram( + onDatagram: null, + quicFactory: engine.CreateFactory(), + quicHandle: static (_, conn) => new Nghttp3Connection(conn).RunBufferedAsync( + static _ => Nghttp3Response.Text("migrated-ok"))); + + using var forwarder = new UdpForwarder(serverPort); + using var client = new H3TestClient("127.0.0.1", forwarder.Port); + + client.Connect(); + Assert.True(client.CompleteHandshake(10_000), "the handshake through the forwarder did not complete"); + + Assert.Equal(200, client.Request("GET", "/one", null, 10_000).Status); + Assert.Equal(200, client.Request("GET", "/two", null, 10_000).Status); + }); + } + + /// + /// Relays UDP between one client and one server, and can change the socket it uses towards the + /// server - which is what the server sees as its peer moving. Deliberately a single thread: + /// the interesting behaviour is on the server, and a forwarder with its own concurrency bugs + /// would be indistinguishable from the defect under test. + /// + private sealed class UdpForwarder : IDisposable + { + private readonly Socket _front; // faces the client + private readonly IPEndPoint _server; + private readonly Thread _pump; + private volatile bool _running = true; + private volatile Socket _upstream; // faces the server; swapped mid-connection + private readonly object _swapGate = new(); + private EndPoint? _client; + private int _relayed; + private int _fromServerAfterSwap; + + public int Port { get; } + public int SwappedAt { get; private set; } + public int RelayedAfterSwap { get; private set; } + public int FromServerAfterSwap => _fromServerAfterSwap; + + public UdpForwarder(int serverPort) + { + _server = new IPEndPoint(IPAddress.Loopback, serverPort); + + _front = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + _front.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + Port = ((IPEndPoint)_front.LocalEndPoint!).Port; + + _upstream = NewUpstream(); + + _pump = new Thread(Pump) { IsBackground = true, Name = "udp-forwarder" }; + _pump.Start(); + } + + /// + /// Change the source address the server sees, NOW. Deliberately synchronous: doing it + /// lazily on the next relayed datagram let the request under test complete before the swap + /// ever happened, and the test then passed against a server with no migration support at + /// all - it was measuring nothing. + /// + public void SwapUpstream() + { + lock (_swapGate) + { + Socket replacement = NewUpstream(); + Socket old = _upstream; + SwappedAt = _relayed; + _upstream = replacement; + old.Dispose(); // the pump's in-flight receive throws and retries on the new one + } + } + + private static Socket NewUpstream() + { + var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + socket.Bind(new IPEndPoint(IPAddress.Loopback, 0)); // a fresh source port + socket.ReceiveTimeout = 50; // never inherit "block forever" + return socket; + } + + private void Pump() + { + byte[] buffer = new byte[2048]; + _front.ReceiveTimeout = 50; + + while (_running) + { + // Read the field ONCE per pass and set the timeout on the socket actually used. + // Setting it on a stale capture while receiving through the volatile field left a + // freshly swapped socket at its default of 0 - block forever - so the pump wedged + // and only Dispose woke it. A background thread outliving its test is how one + // suite starts perturbing another. + Socket upstream = _upstream; + upstream.ReceiveTimeout = 50; + + // client -> server + try + { + EndPoint from = new IPEndPoint(IPAddress.Any, 0); + int n = _front.ReceiveFrom(buffer, ref from); + _client = from; + + upstream.SendTo(buffer, 0, n, SocketFlags.None, _server); + _relayed++; + if (SwappedAt > 0) + { + RelayedAfterSwap++; + } + } + catch (SocketException) + { + // timeout, or the socket was swapped from under the receive - both ordinary + } + catch (ObjectDisposedException) + { + } + + // server -> client + try + { + EndPoint from = new IPEndPoint(IPAddress.Any, 0); + int n = upstream.ReceiveFrom(buffer, ref from); + if (_client is not null) + { + _front.SendTo(buffer, 0, n, SocketFlags.None, _client); + if (SwappedAt > 0) { _fromServerAfterSwap++; } + } + } + catch (SocketException) + { + } + catch (ObjectDisposedException) + { + } + } + } + + public void Dispose() + { + _running = false; + _pump.Join(2_000); + _front.Dispose(); + _upstream.Dispose(); + } + } +} diff --git a/tests/Ioxide.Tests.E2E/Protocols/QuicStreamAllowanceTests.cs b/tests/Ioxide.Tests.E2E/Protocols/QuicStreamAllowanceTests.cs index deab6fe2..059f5ea7 100644 --- a/tests/Ioxide.Tests.E2E/Protocols/QuicStreamAllowanceTests.cs +++ b/tests/Ioxide.Tests.E2E/Protocols/QuicStreamAllowanceTests.cs @@ -396,6 +396,7 @@ public void Connect() { var cbs = new IqCallbacks { + StructSize = (nuint)sizeof(IqCallbacks), OnStreamData = &OnStreamData, OnStreamClose = &OnStreamClose, }; @@ -609,6 +610,7 @@ public void Dispose() [StructLayout(LayoutKind.Sequential)] private struct IqCallbacks { + public nuint StructSize; public delegate* unmanaged OnStreamData; public delegate* unmanaged OnStreamClose; public delegate* unmanaged OnHandshakeCompleted; @@ -617,6 +619,7 @@ private struct IqCallbacks public delegate* unmanaged OnStreamReset; public delegate* unmanaged OnStreamStopSending; public delegate* unmanaged OnAckedStreamData; + public delegate* unmanaged OnPathChange; } private const string Lib = "ioxide_ngtcp2"; diff --git a/tests/Ioxide.Tests.E2E/Protocols/QuicTeardownWireTests.cs b/tests/Ioxide.Tests.E2E/Protocols/QuicTeardownWireTests.cs index 9542c6b8..b217893e 100644 --- a/tests/Ioxide.Tests.E2E/Protocols/QuicTeardownWireTests.cs +++ b/tests/Ioxide.Tests.E2E/Protocols/QuicTeardownWireTests.cs @@ -263,7 +263,7 @@ public TeardownWireClient(int serverPort) _udp.Client.ReceiveTimeout = 100; _udp.Connect(new IPEndPoint(IPAddress.Loopback, serverPort)); // fixes the local port - var cbs = new IqCallbacks { OnStreamData = &OnClientStreamData }; + var cbs = new IqCallbacks { StructSize = (nuint)sizeof(IqCallbacks), OnStreamData = &OnClientStreamData }; _engine = iq_client_engine_new_mtls("echo", null, null, cbs); Assert.True(_engine != 0, "client engine init failed"); @@ -459,6 +459,7 @@ public void Dispose() [StructLayout(LayoutKind.Sequential)] private struct IqCallbacks { + public nuint StructSize; public delegate* unmanaged OnStreamData; public delegate* unmanaged OnStreamClose; public delegate* unmanaged OnHandshakeCompleted; @@ -467,6 +468,7 @@ private struct IqCallbacks public delegate* unmanaged OnStreamReset; public delegate* unmanaged OnStreamStopSending; public delegate* unmanaged OnAckedStreamData; + public delegate* unmanaged OnPathChange; } private const string Lib = "ioxide_ngtcp2"; diff --git a/tests/Ioxide.Tests.E2E/Protocols/QuicTimerTests.cs b/tests/Ioxide.Tests.E2E/Protocols/QuicTimerTests.cs index f76a7ede..1c883174 100644 --- a/tests/Ioxide.Tests.E2E/Protocols/QuicTimerTests.cs +++ b/tests/Ioxide.Tests.E2E/Protocols/QuicTimerTests.cs @@ -563,7 +563,7 @@ public LossyQuicClient(int port) public void Connect() { - var cbs = new IqCallbacks { OnStreamData = &OnClientStreamData }; + var cbs = new IqCallbacks { StructSize = (nuint)sizeof(IqCallbacks), OnStreamData = &OnClientStreamData }; _engine = iq_client_engine_new_mtls("echo", null, null, cbs); Assert.True(_engine != 0, "client engine init failed"); @@ -780,6 +780,7 @@ public void Dispose() [StructLayout(LayoutKind.Sequential)] private struct IqCallbacks { + public nuint StructSize; public delegate* unmanaged OnStreamData; public delegate* unmanaged OnStreamClose; public delegate* unmanaged OnHandshakeCompleted; @@ -788,6 +789,7 @@ private struct IqCallbacks public delegate* unmanaged OnStreamReset; public delegate* unmanaged OnStreamStopSending; public delegate* unmanaged OnAckedStreamData; + public delegate* unmanaged OnPathChange; } private const string Lib = "ioxide_ngtcp2"; diff --git a/tests/Ioxide.Tests.Harness/H3TestClient.cs b/tests/Ioxide.Tests.Harness/H3TestClient.cs index c4163bad..d0bd1a42 100644 --- a/tests/Ioxide.Tests.Harness/H3TestClient.cs +++ b/tests/Ioxide.Tests.Harness/H3TestClient.cs @@ -72,6 +72,7 @@ public void Connect() var quicCbs = new IqCallbacks { + StructSize = (nuint)sizeof(IqCallbacks), OnStreamData = &OnQuicStreamData, }; _clientEngine = _certPath is null @@ -446,6 +447,7 @@ public void Dispose() [StructLayout(LayoutKind.Sequential)] private struct IqCallbacks { + public nuint StructSize; public delegate* unmanaged OnStreamData; public delegate* unmanaged OnStreamClose; public delegate* unmanaged OnHandshakeCompleted; @@ -454,6 +456,7 @@ private struct IqCallbacks public delegate* unmanaged OnStreamReset; public delegate* unmanaged OnStreamStopSending; public delegate* unmanaged OnAckedStreamData; + public delegate* unmanaged OnPathChange; } [StructLayout(LayoutKind.Sequential)]