feat(quic): fix QUIC connection teardown + add FS QUIC integration tests

- Add QuicConfig::insecure() with NoopVerifier for subprocess integration
  tests where server/client generate independent self-signed certs
- Fix QUIC endpoint lifetime bug: store _endpoint in QuicConnection so
  quinn's endpoint outlives its connection (previously dropped on return)
- Add install_crypto_provider() to transport lib; call it in main() to
  satisfy rustls 0.23's explicit install_default() requirement
- Add QuicConnection::wait_for_peer_close(): server passively awaits
  client close instead of racing to send CONNECTION_CLOSE before
  in-flight streams (FsDirComplete, FsFileAck) reach the client
- Add SyncPeer::quic_conn_clone() + update shutdown/drain to use
  close_and_drain (client) vs wait_for_peer_close (server)
- Add 3 QUIC FS sync integration tests: cold-copy, warm-noop, incremental

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
osobh
2026-04-05 09:50:26 -05:00
co-authored by Claude Sonnet 4.6
parent ac8969fb5d
commit 0ed2f5054f
6 changed files with 427 additions and 13 deletions
+9
View File
@@ -21,3 +21,12 @@ pub use peer::{PipeReadHalf, PipeWriteHalf, SyncPeer};
pub use protocol::SyncMessage;
pub use quic::{QuicConfig, QuicConnection, QuicServer, quic_connect};
pub use tcp::{TcpConnection, TcpServer};
/// Install the default rustls CryptoProvider (ring) for the current process.
///
/// Must be called once before any QUIC/TLS code runs. Safe to call multiple
/// times — subsequent calls are no-ops. This is required by rustls 0.23+
/// regardless of whether the `ring` feature is enabled at compile time.
pub fn install_crypto_provider() {
let _ = rustls::crypto::ring::default_provider().install_default();
}
+15 -2
View File
@@ -62,13 +62,26 @@ impl SyncPeer {
match self {
SyncPeer::Tcp(mut c) => c.shutdown().await,
SyncPeer::Quic(c) => {
c.close();
c.close_and_drain().await;
Ok(())
}
SyncPeer::Mmap { .. } => Ok(()),
}
}
/// Return a cloned `Arc` if this is a QUIC connection, otherwise `None`.
///
/// This is used by servers to keep the QUIC connection alive after the
/// `FsSyncServer` or other handler has been dropped (which drops the
/// server's copy of the `Arc`), so that `close_and_drain()` can be
/// called to flush pending stream data before the connection is closed.
pub fn quic_conn_clone(&self) -> Option<Arc<QuicConnection>> {
match self {
SyncPeer::Quic(c) => Some(c.clone()),
_ => None,
}
}
/// Split into independent read/write halves for concurrent pipelined I/O.
///
/// TCP: yields `OwnedReadHalf` / `OwnedWriteHalf` via `into_split()`.
@@ -112,7 +125,7 @@ impl PipeWriteHalf {
match self {
PipeWriteHalf::Tcp(mut h) => h.shutdown().await,
PipeWriteHalf::Quic(c) => {
c.close();
c.close_and_drain().await;
Ok(())
}
PipeWriteHalf::Mmap(_) => Ok(()),
+135 -4
View File
@@ -19,7 +19,9 @@ use std::net::SocketAddr;
use std::sync::Arc;
use quinn::{ClientConfig, Connection, Endpoint, ServerConfig};
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime};
use rustls::{DigitallySignedStruct, Error as TlsError, SignatureScheme};
use crate::error::TransportError;
use crate::protocol::SyncMessage;
@@ -65,6 +67,94 @@ impl QuicConfig {
client_config,
})
}
/// Build a server-only config with a self-signed cert whose **client side
/// skips all certificate verification**.
///
/// For use in integration tests where server and client run as separate
/// processes and cannot share a certificate at build time.
///
/// **Never use in production.**
pub fn insecure() -> Result<Self, TransportError> {
let cert = rcgen::generate_simple_self_signed(vec![
"localhost".to_string(),
"127.0.0.1".to_string(),
])
.map_err(|e| TransportError::Tls(e.to_string()))?;
let cert_der = CertificateDer::from(cert.cert.der().to_vec());
let key_der = PrivateKeyDer::try_from(cert.key_pair.serialize_der())
.map_err(|e| TransportError::Tls(e.to_string()))?;
let server_config = ServerConfig::with_single_cert(vec![cert_der], key_der)
.map_err(|e| TransportError::Tls(e.to_string()))?;
// Client config: accept any server certificate (testing only).
let tls_client = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoopVerifier))
.with_no_client_auth();
let client_config = ClientConfig::new(Arc::new(
quinn::crypto::rustls::QuicClientConfig::try_from(tls_client)
.map_err(|e| TransportError::Tls(e.to_string()))?,
));
Ok(Self {
server_config,
client_config,
})
}
}
/// A TLS certificate verifier that accepts any certificate.
/// Only for use in local integration tests.
#[derive(Debug)]
struct NoopVerifier;
impl ServerCertVerifier for NoopVerifier {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, TlsError> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, TlsError> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, TlsError> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
vec![
SignatureScheme::RSA_PKCS1_SHA256,
SignatureScheme::RSA_PKCS1_SHA384,
SignatureScheme::RSA_PKCS1_SHA512,
SignatureScheme::ECDSA_NISTP256_SHA256,
SignatureScheme::ECDSA_NISTP384_SHA384,
SignatureScheme::ECDSA_NISTP521_SHA512,
SignatureScheme::ED25519,
SignatureScheme::RSA_PSS_SHA256,
SignatureScheme::RSA_PSS_SHA384,
SignatureScheme::RSA_PSS_SHA512,
]
}
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -72,13 +162,29 @@ impl QuicConfig {
// ─────────────────────────────────────────────────────────────────────────────
/// A QUIC connection capable of sending/receiving `SyncMessage`s.
///
/// The `Endpoint` is kept alive here because quinn requires the endpoint to
/// outlive all connections it manages. Server-accepted connections pass
/// `None` since the server's `QuicServer` holds the endpoint.
pub struct QuicConnection {
conn: Connection,
/// Client-owned endpoint; `None` for server-accepted connections.
_endpoint: Option<Endpoint>,
}
impl QuicConnection {
pub fn new(conn: Connection) -> Self {
Self { conn }
Self {
conn,
_endpoint: None,
}
}
fn with_endpoint(conn: Connection, endpoint: Endpoint) -> Self {
Self {
conn,
_endpoint: Some(endpoint),
}
}
/// Send a `SyncMessage` on a new unidirectional QUIC stream.
@@ -127,10 +233,33 @@ impl QuicConnection {
SyncMessage::from_bytes(&body).map_err(TransportError::Deserialization)
}
/// Close the QUIC connection with a normal code.
/// Close the QUIC connection with a normal code (non-blocking).
pub fn close(&self) {
self.conn.close(0u32.into(), b"done");
}
/// Close the QUIC connection and wait until all pending stream data has
/// been delivered to the peer.
///
/// Use this on the **client** side: the client initiates close after it
/// has read all data, then waits for the server to acknowledge.
pub async fn close_and_drain(&self) {
self.conn.close(0u32.into(), b"done");
// `closed()` resolves once the peer has also closed or the drain
// period (≈ 3× PTO) has elapsed. Ignore the result — errors here
// just mean the peer already closed first, which is fine.
let _ = self.conn.closed().await;
}
/// Wait for the peer to close the connection without initiating close ourselves.
///
/// Use this on the **server** side after sending the final message. The
/// client will call `close_and_drain()` once it has read all data, which
/// resolves this future. This avoids the race where `close_and_drain()`
/// sends `CONNECTION_CLOSE` before in-flight streams reach the client.
pub async fn wait_for_peer_close(&self) {
let _ = self.conn.closed().await;
}
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -188,7 +317,9 @@ pub async fn quic_connect(
.await
.map_err(|e| TransportError::Quic(e.to_string()))?;
Ok(QuicConnection::new(conn))
// Keep the endpoint alive for the duration of this connection.
// quinn requires the Endpoint to outlive all connections it manages.
Ok(QuicConnection::with_endpoint(conn, endpoint))
}
// ─────────────────────────────────────────────────────────────────────────────