TRIP over QUIC — the tentackle-quic module¶
Overview¶
tentackle-quic adds a QUIC (RFC 9000) transport to
TRIP, Tentackle's Remote
Invocation Protocol. It lets PDOs and remote calls travel between JVMs over UDP
instead of TCP, while reusing all of TRIP's serialization, dictionary, registry,
and remote-proxy machinery unchanged.
QUIC is a modern, UDP-based transport that bundles features TCP-based stacks have to assemble from several layers:
- Built-in TLS 1.3 — encryption and authentication are part of the protocol, not an optional layer on top.
- Stream multiplexing without head-of-line blocking — many independent bidirectional streams share a single connection; a lost packet on one stream does not stall the others. This maps naturally onto TRIP's connection-pool model, where each pooled connection is one QUIC stream.
- Faster connection establishment — the TLS handshake is folded into the transport handshake, saving round-trips compared to TCP+TLS.
- Connection migration — the protocol allows a connection to survive the
client changing its IP address or port (e.g., switching networks). This module
never initiates a migration (yet); though
tentackle-quichedoes, see below.
The module is a thin adapter: it wires the KWIK
QUIC library (tech.kwik.core) into TRIP's pluggable transport SPI. No
application code changes are required to use it — selecting QUIC is purely a
matter of the connection URI scheme.
- Module:
org.tentackle.quic - Maven artifact:
org.tentackle:tentackle-quic - Protocol schemes:
tripq(plain QUIC),tripcq(QUIC + compression) - License: LGPL 2.1
Two QUIC implementations¶
Tentackle ships QUIC twice. tentackle-quiche
is this module's sibling: the same transport, but built on
Cloudflare quiche — the Rust implementation — bound through
the JDK's FFM API instead of on KWIK. Both speak RFC-9000 and interoperate: a KWIK client can call
a quiche server and vice versa, which QuicheKwikInteropTest verifies on every build.
tentackle-quic (KWIK) |
tentackle-quiche (Cloudflare quiche) |
|
|---|---|---|
| Implementation | pure Java | Rust, called through FFM |
| Platforms | anywhere a JVM runs | Linux and macOS on x86-64 and aarch64, Windows on x86-64 |
| Extra JVM flags | none | --enable-native-access |
| Schemes | tripq, tripcq |
tripj, tripcj |
| Stalled paths | waits for the idle timeout | migrates to a new source port, see Connection migration |
Prefer tentackle-quiche: it migrates a stalled
connection to a fresh source port, whereas this module waits for the idle timeout. Use this module when
you need a pure-Java stack — for example, when the platform is not among those quiche ships binaries for,
or the operating system is too old to run them and cannot be upgraded. The two register different
schemes, so nothing stops an application from deploying both and letting each client pick.
How it fits into TRIP¶
TRIP's network layer is fully pluggable behind the Transport interface, and
transports advertise themselves through the @TransportService annotation
(see Writing a Custom Transport).
tentackle-quic is a complete, real-world implementation of that SPI built on
AbstractTransport — the base class intended for adding a protocol from scratch.
Because the transport is discovered by URI scheme, the rest of the framework is unaware of QUIC. A client obtains a QUIC transport exactly like any other:
// Client — talk to a server over QUIC
Transport transport = TransportManager.getInstance().requestTransport(
URI.create("tripq://server.example.site:9090?ksfile=keystore.p12&kspass=secret&ksalias=server"));
// Server — accept incoming QUIC connections
Transport serverTransport = TransportManager.getInstance().requestTransport(
URI.create("tripq://0.0.0.0:9090?ksfile=keystore.p12&kspass=secret&ksalias=server"));
serverTransport.accept();
Why a separate module?¶
QUIC support is deliberately not baked into tentackle-core. Pulling in the
KWIK library (and its transitive dependencies) is only worthwhile for
applications that actually use QUIC. The module therefore:
- depends on
tentackle-coreas anoptionaldependency, so it does not alter the dependency chain of projects that merely use TCP transports, and - is not
requiresd by any other Tentackle module. It plugs itself in purely via the@TransportService-annotated transport classes, discovered at runtime through the service index.
To activate QUIC, an application simply needs tentackle-quic (and tech.kwik.core)
on the module/class path; the transports register themselves automatically.
Architecture¶
The module consists of a handful of classes that mirror TRIP's transport contract. Server and client responsibilities are clearly separated.
QuicTripTransport (@TransportService "tripq")
/ \
server: accept() client: createClientConnectionPool()
| |
ServerConnector (KWIK) QuicTripConnectionPool
| |
QuicProtocolConnectionFactory QuicClientConnection (KWIK)
| |
QuicProtocolConnection createStream(true) -> QuicStream
acceptPeerInitiatedStream(s) |
| QuicTripConnection
QuicStream -> createConnectionHandler (wraps a TripStream)
| Class | Role | Side |
|---|---|---|
QuicTripTransport |
The Transport implementation; scheme tripq. Starts the KWIK server connector and creates the client pool. |
both |
CompressedQuicTripTransport |
Subclass adding deflate compression; scheme tripcq. |
both |
QuicTripConnectionPool |
Opens one QuicClientConnection and creates a pooled QuicTripConnection (one QUIC stream) per slot. |
client |
QuicTripConnection |
An AbstractConnection wrapping a single bidirectional QuicStream as a TripStream. |
client |
QuicProtocolConnectionFactory |
KWIK ApplicationProtocolConnectionFactory; creates a protocol connection per incoming QUIC connection. |
server |
QuicProtocolConnection |
Accepts peer-initiated QUIC streams and dispatches each to a TRIP connection handler on a virtual thread. | server |
QuicLogger |
Bridges KWIK's logging API onto Tentackle's Logger. |
both |
service.Hook |
ModuleHook for resource-bundle resolution within the module. |
both |
Server side¶
QuicTripTransport.accept() builds and starts a KWIK ServerConnector:
- TLS keystore — QUIC mandates TLS, so a server certificate is required. The
keystore, certificate alias and password are resolved by
SslParameters— the very same configuration the TCP-basedtrips/tripcstransports use — and the loaded keystore is handed to the connector viawithKeyStore(keyStore, alias, password). This means thetentackle.ssl.*system-property fallbacks and the single-entry keystore alias auto-selection apply to QUIC too (see below). - Connection config —
maxOpenPeerInitiatedBidirectionalStreamsis set from themaxstreamsparameter (default 128), capping how many concurrent streams a single client may open. The max idle timeout is set fromquicidle(see Idle timeout and keep-alive), and the flow control windows fromstreamwindow(see Flow control). - Bind address — the host/port come from the URI. A host of
"0"binds all interfaces (IPv4 and IPv6) viawithPort(port); any other host binds a specificDatagramSocket. - ALPN protocol handler —
registerApplicationProtocol(alpnId, factory)registers aQuicProtocolConnectionFactory. The ALPN id defaults to the URI scheme but can be overridden withalpnid.
When a client opens a stream, KWIK calls
QuicProtocolConnection.acceptPeerInitiatedStream(QuicStream). The stream is
handed to transport.createConnectionHandler(...) and run on a virtual thread
from the transport's connection thread pool — exactly the same handler path used
by every other TRIP transport, so call dispatch, registry lookups, and serialization
behave identically. The stream is closed in a finally block when the handler
returns.
The factory declares no unidirectional streams
(maxConcurrentPeerInitiatedUnidirectionalStreams() == 0, since TRIP is
request/response over bidirectional streams) and effectively unlimited
bidirectional streams (Integer.MAX_VALUE), with the real cap enforced by the
maxstreams server config above.
Client side¶
QuicTripConnectionPool is created lazily by AbstractTransport on first use, and
establishes its QUIC connection right away:
- It opens a single
QuicClientConnectionto the URI's host and port, using the ALPN id (alpnid, default = scheme). The URI must specify a port > 0. - By default, server-certificate checking is enabled, against the
truststore named by
tsfile/tspass(resolved the same way as the TCP/SSL transports, seeSslParameters), or the JVM's default cacerts if none is configured. A rejected certificate fails the connection with aTripRuntimeExceptionwhose cause is anSSLHandshakeExceptionwrapping the originalCertificateException— exactly like the TCP/SSL transports report the same failure, so code reacting to it (such as the client auto-update'sLoginFailedWithUpdateHandler) does not need to know which transport is in use. The certificate must also be issued for the host name of the URI, which KWIK always verifies — unlike the other transports,tripqdoes not supporthostcheck=falseyet; it will with the next KWIK release. Setcertcheck=falseto disable validation altogether (noServerCertificateCheck()) — for a client that runs on a trusted, non-public network against a server presenting a self-signed certificate without a matching truststore. - Establishing that connection is bounded by
connecttimeout(default 10 seconds). Since QUIC runs over UDP, an unreachable or unresponsive server produces no answer at all, so the client waits for the full timeout before failing with aTripRuntimeException. Lower it when a client should fail over quickly rather than wait. KWIK applies the timeout itself; the pool only adds an outer bound ofCONNECT_TIMEOUT_GRACE(5) seconds in case KWIK's wait does not return at all. - Each pool slot (
createSlot) opens a new bidirectional QUIC stream (connection.createStream(true)) and wraps it in aQuicTripConnection.
So one QUIC connection carries many TRIP connections, each being one QUIC
stream — taking direct advantage of QUIC's multiplexing without head-of-line
blocking. Pool sizing (initial, increment, minimum, maximum, idle,
usage) follows the standard TRIP connection-pool parameters read from the URI
query string, with one QUIC-specific difference: maximum defaults to
maxstreams rather than to unlimited. See Stream limit.
shutdown() closes the underlying QuicClientConnection, tearing down all of its
streams at once.
Stream-to-TripStream bridging¶
The single integration point with TRIP serialization is createStream:
public TripStream createStream(Dictionary dictionary, QuicStream quicStream) {
TripFactory factory = TripFactory.getInstance();
return factory.createStream(dictionary,
factory.createSerializer(outputStream(quicStream)),
factory.createDeserializer(inputStream(quicStream)));
}
A QuicStream exposes ordinary InputStream/OutputStream objects; wrapping
them in a TRIP Serializer/Deserializer is all that is needed to run the full
TRIP protocol — dictionary compression, back-references, deduplication, and remote
proxying — over QUIC. inputStream and outputStream wrap KWIK's streams so that
the interrupt status of the calling thread does not reach KWIK, see
Interrupts.
Interrupts¶
Everything a client's remote invocation blocks on — the handshake, opening a pooled stream, and the stream reads and writes — is meant to be interrupt-agnostic, exactly like the blocking socket streams of the TCP transports: an interrupt of the calling thread should neither abort the operation nor get lost. For the handshake and for opening a stream that holds without exception, and for the stream writes as well. For the stream reads it holds only for an interrupt that is pending when the read starts, see the remaining gap.
This is not a detail. Tentackle's task dispatchers
interrupt their own thread whenever work is queued, to cut a pending sleep
short — see DefaultTaskDispatcher.interrupt(). Such an interrupt regularly lands
while the thread is inside a remote invocation (DbModificationTracker polls from
its dispatcher thread), and a transport that treated it as a cancellation would tear
down a perfectly healthy TRIP connection.
KWIK itself does not cooperate: its connect() aborts the handshake and throws a
bare RuntimeException, createStream reports a TimeoutException, a write waiting
for room in the stream's send buffer fails with an InterruptedIOException, and a
read clears the interrupt status without restoring it. Hence
connect()runs KWIK's handshake on a thread of its own and waits for it with an absolute deadline (connecttimeoutplusCONNECT_TIMEOUT_GRACE),createQuicStreamdoes the same forcreateStream(which it has to do anyway to bound the wait for stream credits, see Stream limit),- and
InterruptAgnosticStreamskeeps the interrupt status away from KWIK's stream operations, restoring it once they return, and repeats a write that KWIK gave up on because of an interrupt.
The first two are complete: KWIK runs on another thread and never sees the caller's interrupt, which is remembered and re-asserted before returning.
The remaining gap¶
InterruptAgnosticStreams runs KWIK's stream operations on the calling thread. It
can withhold an interrupt that is already pending, but not one that arrives while
the thread is inside KWIK (as of KWIK 0.12):
| Operation | Interrupt arriving during the operation | Consequence |
|---|---|---|
| write that does not have to wait | Left alone by KWIK. | None, the interrupt stays pending. |
| write waiting for room in the send buffer | KWIK fails the write with an InterruptedIOException. The wrapper repeats it. |
None, the interrupt is re-asserted when the write returns. |
| read | Swallowed by KWIK: the read goes on and returns normally, the interrupt status is cleared. | The call succeeds, but the interrupt is lost. A dispatcher misses its wakeup and notices the queued work only when its sleep interval ends. |
Repeating a write is safe because KWIK takes its data all or nothing as long as it fits into the stream's send buffer of 50 kB: the interrupted wait for room in that buffer ends before anything is queued. A larger write would be split by KWIK itself and could fail halfway, so the wrapper hands the data over in chunks of 16 kB. KWIK also interrupts a waiting writer on its own when the stream is reset or the connection is gone; it marks the stream first, so the repeated write fails right away for that reason instead of being repeated forever.
The lost interrupt of a read is the one gap left, and it is the common case: a remote
invocation spends nearly all of its time waiting for the response. The wrappers cannot
close it. Once KWIK has cleared the interrupt status, nothing tells that there ever
was an interrupt. Threads that depend on their interrupts are better served by the
tentackle-quiche transport,
which implements the blocking streams itself and has no such gap.
The server's connection handlers work on KWIK's streams directly: nothing interrupts them but the shutdown of the transport.
Compression: tripcq¶
CompressedQuicTripTransport (scheme tripcq) extends QuicTripTransport and
layers Tentackle's streaming deflate compression on top of every QUIC stream, on
both the server and client paths:
createCompressedOutputStreamwraps the QUIC output in aFastCompressedOutputStream. Themincompressparameter (default 1024 bytes) sets the threshold below which a block is sent uncompressed, avoiding the overhead of compressing tiny payloads.createCompressedInputStreamwraps the QUIC input in aCompressedInputStream.
Both createConnectionHandler (server) and createStream (the TripStream bridge)
are overridden to insert the compression streams, so client and server agree on
the wire format. Use tripcq when payloads are large and bandwidth is the
bottleneck; use plain tripq when payloads are small or already compact.
Connection URI parameters¶
All configuration is carried in the connection URI's query string. The same URI
shape is used on both client and server (with host 0 / 0.0.0.0 selecting the
server bind-all case).
tripq://<host>:<port>?ksfile=...&kspass=...&ksalias=...[&kstype=...][&kmpass=...][&tsfile=...][&tspass=...][&tstype=...][&alpnid=...][&maxstreams=...][&streamwindow=...][&certcheck=...][&connecttimeout=...][&streamtimeout=...][&quicidle=...][&keepalive=...]
tripcq://<host>:<port>?...same as tripq...&mincompress=1024
The keystore/truststore parameters (ksfile, kspass, ksalias, kstype, kmpass, tsfile, tspass, tstype) are
resolved by the shared
SslParameters, so each
also honors a tentackle.ssl.<name> system property and, as a fallback, the standard
javax.net.ssl.<name> property. See that table for the full precedence rules.
| Parameter | Side | Default | Description |
|---|---|---|---|
ksfile |
server | tentackle.ssl.keyStore / javax.net.ssl.keyStore |
Path to the TLS keystore file. Required (here or via a system property). |
kspass |
server | tentackle.ssl.keyStorePassword / javax.net.ssl.keyStorePassword |
Keystore password; also used as the key password unless kmpass is set. |
ksalias |
server | the sole key entry, if the keystore has exactly one | Alias of the server certificate within the keystore. Required only for multi-entry keystores. |
kstype |
server | tentackle.ssl.keyStoreType / javax.net.ssl.keyStoreType / JDK default |
Keystore type, e.g. PKCS12 or JKS. |
kmpass |
server | the keystore password (kspass) |
Private key password, if it differs from the keystore password. |
tsfile |
client | tentackle.ssl.trustStore / javax.net.ssl.trustStore / JVM cacerts |
Truststore verifying the server certificate, used when certcheck=true. |
tspass |
client | tentackle.ssl.trustStorePassword / javax.net.ssl.trustStorePassword |
Truststore password. |
tstype |
client | tentackle.ssl.trustStoreType / javax.net.ssl.trustStoreType / JDK default |
Truststore type. |
alpnid |
both | the URI scheme (tripq / tripcq) |
ALPN application-protocol identifier. Client and server must match. |
maxstreams |
both | 128 |
Maximum concurrent peer-initiated bidirectional streams per connection; must be >= 1. On the client, it is the default of the pool's maximum, see Stream limit. |
streamwindow |
both | 250000 |
Flow control window per stream in bytes; must be >= 1500. See below. |
certcheck |
client | true |
When true, the client validates the server certificate against tsfile. Any other value skips validation. |
connecttimeout |
client | 10 |
Seconds to wait for the QUIC handshake before giving up. Must be > 0. |
streamtimeout |
client | 10 |
Seconds to wait for the peer to grant the credits to open another stream. Must be > 0. See below. |
quicidle |
both | 30 |
QUIC max idle timeout in seconds. See below. |
keepalive |
client | unlimited | How many seconds to keep the QUIC connection alive. 0 disables the keep-alive. See below. |
mincompress |
both (tripcq) |
1024 |
Minimum block size in bytes before deflate compression is applied. |
On the server, if the keystore file (
ksfile) or the certificate alias (ksalias, unless the keystore holds a single key entry) cannot be resolved,accept()throws aTripRuntimeException. The port must be in the range0..65535. On the client, a port that is not > 0, or aconnecttimeoutorstreamtimeoutthat is not > 0, fails the creation of the connection pool with anIllegalArgumentException.
Idle timeout and keep-alive¶
QUIC connections are not kept open indefinitely: according to
RFC-9000, 10.1, both peers
silently discard a connection that stays idle for longer than the minimum of the two peers'
max_idle_timeout values — without notifying the application. quicidle configures that
timeout on both ends, so the effective value is well-defined rather than being the minimum of two
differing library defaults.
Note that this has nothing to do with the connection pool's idle parameter, which is given in
minutes and is always much longer. All pooled connections are streams multiplexed over a single
QUIC connection, so the pool would keep handing out connections belonging to a QUIC connection that
is long gone. To prevent that, the client keeps the QUIC connection alive by sending PING frames,
for as long as the pool exists. Set keepalive=0 to disable this, for example, when the connection
is short-lived anyway, or keepalive=<seconds> to limit it. Note that keepalive is a duration
here, handed to KWIK's keepAlive(seconds), whereas tentackle-quiche interprets the same parameter
as the interval between keep-alive packets. Should the QUIC connection be lost
nevertheless (network loss, peer restart, keepalive=0), the pool drops the dead connections and
re-establishes the QUIC connection on the next request. Only then: the periodic monitoring of the
pool removes the dead connections, but does not bring the pool back to its minimum size while the
QUIC connection is gone. Doing so would block the thread that monitors all pools for up to
connecttimeout, round after round, for as long as the server is unreachable. The connection it
replaces is closed explicitly: KWIK stops a connection's keep-alive actor when the connection is
closed, but not when it is terminated silently, and that actor runs on a non-daemon thread that
would otherwise keep the JVM from terminating.
The corresponding pool parameters (initial, increment, minimum, maximum, idle, usage)
are described in trip.md.
Stream limit¶
A peer may only open as many concurrent streams as the other end grants it
(RFC-9000, 4.6), and
maxstreams configures that limit. Fresh credits are granted only when a stream is closed — but
every pooled connection holds its stream for as long as it sits in the pool, so a busy client walks
straight into the limit. KWIK's createStream does not fail there, it waits, and it waits with the
connection pool's monitor held. No other thread could return a connection to
free a stream, so the wait would never end, and the whole client transport would be stuck.
Two things prevent this. The pool's maximum defaults to maxstreams instead of unlimited, so the
pool stops enlarging itself before it reaches the limit and requests fail with a temporary
TripRuntimeException ("max. pool size … reached") — the caller can retry, and the transport stays
usable. And should the peer nevertheless grant fewer streams than configured here — the client cannot
detect a maxstreams mismatch — streamtimeout bounds the wait, again with a temporary exception.
Raising maximum above maxstreams therefore only makes sense if the server grants more.
Flow control¶
QUIC limits how much data a peer may send before the receiving side grants further credits
(RFC-9000, 4), both per stream and per
connection. streamwindow configures the per-stream window on both ends — on the server it bounds
the size of an incoming remote call, on the client the size of the response — and the connection window
is ten times that value.
Both ends are configured explicitly, because KWIK's defaults are asymmetric: its client advertises 250kB per
stream, while its server falls back to the 5kB minimum of ApplicationProtocolSettings unless the buffer
sizes are set. A call larger than the window still succeeds, but has to squeeze through it in stop-and-go
fashion, one credit update per window, which costs a round-trip each time. The default is therefore chosen
to cover ordinary calls in full, and only needs raising when calls routinely carry payloads larger than it.
Do not size the connection window down to the stream window. Both limits would then be exhausted at the very same offset, and since KWIK never retransmits a lost
MAX_DATAframe and ignores an incomingDATA_BLOCKEDframe, a dropped datagram carrying that update would wedge the stream permanently: the receiver waits for data the sender is not allowed to send. Keeping the connection window a multiple of the stream window makes the stream limit the binding one, andMAX_STREAM_DATAis retransmitted.Mind that the connection window is shared by all streams of the connection, so this only holds as long as fewer than ten streams transfer at full window simultaneously; beyond that the connection limit can become binding after all. The factor cannot simply be raised to cover all
maxstreamsstreams, because it has to match on both ends and KWIK's client hardcodes exactly this factor indefaultStreamReceiveBufferSize(), with no way to configure it. Sizingstreamwindowso that ordinary calls fit into it keeps the number of streams blocked on flow control — and with it the exposure — small.
Build and packaging¶
The module declares its dependencies as:
<dependency>
<groupId>org.tentackle</groupId>
<artifactId>tentackle-core</artifactId>
<optional>true</optional> <!-- don't modify the dependency chain! -->
</dependency>
<dependency>
<groupId>tech.kwik</groupId>
<artifactId>kwik</artifactId>
</dependency>
JPMS descriptor¶
module org.tentackle.quic {
exports org.tentackle.quic;
opens org.tentackle.quic to org.tentackle.core; // TRIP instantiates the transport reflectively
requires transitive tech.kwik.core;
requires transitive org.tentackle.core;
provides org.tentackle.common.ModuleHook with org.tentackle.quic.service.Hook;
}
The package is opensed to org.tentackle.core because TRIP instantiates the
transport reflectively through its (URI) constructor when resolving the URI
scheme.
Choosing a QUIC scheme¶
| Scheme | Transport | Encryption | Compression | When to use |
|---|---|---|---|---|
tripq |
UDP / QUIC | TLS 1.3 (built in) | no | Lossy or high-latency networks; many concurrent streams. |
tripcq |
UDP / QUIC | TLS 1.3 (built in) | deflate | As tripq, when payloads are large and bandwidth-bound. |
Compared with the TCP-based schemes (trip, trips, tripe, tripc, …), QUIC
provides TLS without a separate handshake layer and avoids TCP head-of-line
blocking across the many pooled connections a busy client maintains. The trade-off
is a UDP-based stack and the additional KWIK dependency, which is why it ships as
its own optional module.
The tripj and tripcj schemes of
tentackle-quiche are the same two choices on
the quiche stack, and they talk to tripq/tripcq peers — the scheme selects the local implementation,
not the wire protocol. The compression of tripcq/tripcj, on the other hand, must match on both ends.
For clients that change networks or sit behind NATs that rebind, prefer tripj/tripcj: only quiche
migrates a connection whose path has stalled.
See also¶
- TRIP — Tentackle Remote Invocation Protocol — the protocol this transport plugs into, including the custom-transport guide.
- TRIP over Cloudflare quiche — the sibling module providing the same transport on the quiche stack, and how the two compare.
- KWIK — the underlying QUIC implementation
(
tech.kwik.core). - RFC 9000 — QUIC: A UDP-Based Multiplexed and Secure Transport.