This is an automated email from the ASF dual-hosted git repository.

maskit pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficserver.git


The following commit(s) were added to refs/heads/master by this push:
     new bac05dadfa Add QMux support (HTTP/3 over TLS/TCP) (#13465)
bac05dadfa is described below

commit bac05dadfa53305e68279a7b7d6931aafbae1fd1
Author: Masakazu Kitajo <[email protected]>
AuthorDate: Mon Aug 3 10:16:03 2026 -0600

    Add QMux support (HTTP/3 over TLS/TCP) (#13465)
    
    * Add QMux support (HTTP/3 over TLS/TCP)
    
    HTTP/3 requires UDP, which is blocked or degraded on many networks.
    QMux carries QUIC stream multiplexing over a TLS/TCP connection so
    HTTP/3 can be served where UDP is unavailable. Server side only.
    
    The transport is abstracted behind the existing QUICConnection and
    QUICStreamIO interfaces, so HTTP/3 session and application handling is
    reused unchanged. QMux is offered via ALPN "h3qx-01" on TLS ports.
    
    The two transports are now selected independently of the QUIC backend.
    ENABLE_QUIC carries QUIC over UDP and defaults to on whenever a backend
    is available; ENABLE_QMUX carries it over TLS/TCP and defaults to off.
    Either can be enabled without the other, and QMux requires quiche built
    with qmux support.
    
    * Report QMux build support
    
    AuTests need a stable feature flag to skip QMux coverage when ATS is
    built without the optional transport.
    
    This exposes TS_USE_QMUX through traffic_layout alongside the existing
    QUIC and TLS feature flags.
    
    * Add QMux Go client AuTest
    
    QMux needs an interoperable client test to prove that HTTP/3 can run
    over TLS/TCP and proxy multiple transactions with request and response
    bodies.
    
    This adds a class-based AuTest with a qmux-go client and Proxy Verifier
    origin. The client sends three transactions on one session, verifies
    forwarded headers and bodies, and checks a 300-kilobyte response byte
    for byte. Compatibility shims cover qmux-go v0.2.0 wire gaps.
    
    * Resume QMux reads across buffer blocks
    
    A partial QMux record at the end of the 32 KB input buffer prevents
    TLS from reading the rest of the record, stalling larger request bodies.
    
    Set the input watermark to the maximum QMux record size so the buffer can
    append a block and complete records that span block boundaries.
    
    * Address copilot comments
    
    * Reclaim QMux connection VIOs after Http3App construction
    
    Http3App's constructor runs the generic ProxySession start-up
    (HQSession::start()), which claims the netvc's read/write VIOs for
    itself. Moving qmux_con->start() before that construction, to
    address an earlier review comment about the app racing the
    transport bridge, let that claim win instead of QMuxConnection's,
    silently disabling QMux's connection-level I/O and crashing on the
    first subsequent write.
    
    Construct the app, reclaim the VIOs for QMuxConnection right after,
    then start the app. This keeps the app from generating stream I/O
    before the transport is wired up while ensuring QMuxConnection ends
    up owning the VIOs it depends on.
    
    * Flush qmux transport params before checking established streams
    
    is_established() for qmux mode is qmux_transport_params_sent &&
    qmux_transport_params_received. _handle_write() checked it before
    _flush_quiche_output(), which is what can flip sent to true. On the
    call where establishment completes this way, a stream already queued
    (e.g. the HTTP/3 control stream) missed its flush window, and nothing
    else was guaranteed to trigger another one -- if the peer waits on
    that stream before sending anything further, both sides stall until
    idle timeout.
    
    Flush once before the streams check when not yet established, so a
    transition to established within this call is visible to it.
    
    * Default ENABLE_QMUX to AUTO when quiche has qmux support
    
    ENABLE_QUICHE is a plain ON/OFF option with no AUTO state, so building
    with quiche never turned QMux on by itself -- ENABLE_QMUX had its own
    hardcoded OFF default regardless of whether the linked quiche was built
    with qmux support. This was the one auto_option() in the QUIC/QMux
    chain that didn't actually auto-detect anything, unlike
    ENABLE_OPENSSL_QUIC's AUTO default.
    
    quiche.h always declares quiche_config_enable_qmux() regardless of
    whether the library was actually built with the qmux feature, so
    detecting support requires a real compile-and-link check against
    quiche::quiche, not a header-only one -- CheckQuicheHasQmux.cmake
    mirrors CheckOpenSSLHasNativeQuic.cmake's shape for this reason.
    
    * Remove ENABLE_OPENSSL_QUIC; fix premature QUIC backend status message
    
    ENABLE_OPENSSL_QUIC gated a capability of the mandatory OpenSSL
    dependency behind its own ON/OFF/AUTO option, unlike every other OpenSSL
    capability check in this file (SSLLIB_IS_BORINGSSL, SSLLIB_HAS_QUIC_TLS_CBS,
    etc.), which are plain detected variables with no option of their own.
    Since OpenSSL is always linked regardless, and OpenSSL-native QUIC and
    quiche are mutually exclusive by TLS-library requirement (quiche needs
    BoringSSL or the TLS callback compat shim, neither of which implements
    the upstream-OpenSSL-3.5+ native QUIC API), the flag never actually
    selected between two live backends -- disabling it had the same effect
    as disabling the QUIC transport outright via ENABLE_QUIC, just through
    a separate, asymmetric path that left a misleading "Using OpenSSL
    native QUIC" status line and no warning when the backend was flagged
    available but nothing was configured to serve it.
    
    TS_HAS_OPENSSL_QUIC is now set directly from the same detection logic,
    folded into the other capability checks already living in this file.
    The "Using ... QUIC transport" status message moves to after
    auto_option(QUIC ...) decides TS_USE_QUIC, so it reflects what's
    actually enabled rather than what's merely detected.
    
    * Close QMux connections immediately on a fatal quiche_conn_recv() error
    
    quiche_conn_recv() returning anything other than QUICHE_ERR_DONE means
    quiche has already classified the received bytes as an unrecoverable
    per-connection protocol violation and started its own internal
    close/drain sequence internally (every non-Done error path in
    recv_qmux() calls self.close() before returning) -- it is never used
    to mean "incomplete record, wait for more bytes" in this quiche fork
    (both incomplete-header and incomplete-record cases are mapped to
    QUICHE_ERR_DONE explicitly).
    
    _handle_read() previously only logged this case and left the
    connection to be caught by the next scheduled quiche_conn_on_timeout()
    tick, which notices via quiche_conn_is_closed(). That works, but
    lingers for up to the connection's drain timeout doing nothing useful,
    and leaves the now-unparseable bytes sitting in the read buffer for
    that whole window. Calling close_quic_connection() immediately reaches
    the same end state without the wait: quiche_conn_close() is a safe
    no-op here since quiche already set its own close reason internally,
    and the pending CLOSE frame gets flushed to the peer right away instead
    of on the next natural write event.
    
    ---------
    
    Co-authored-by: bneradt <[email protected]>
---
 CMakeLists.txt                                     |  98 ++--
 cmake/CheckQuicheHasQmux.cmake                     |  41 ++
 include/iocore/net/qmux/QMuxConnection.h           | 132 +++++
 include/iocore/net/quic/QUICStream.h               |   2 +-
 include/ts/apidefs.h.in                            |   3 +
 include/tscore/ink_config.h.cmake.in               |   1 +
 include/tscore/ink_inet.h                          |   1 +
 src/iocore/net/CMakeLists.txt                      |  21 +-
 src/iocore/net/P_SSLNetVConnection.h               |  22 +
 src/iocore/net/SSLNetVConnection.cc                |  23 +
 src/iocore/net/qmux/CMakeLists.txt                 |  23 +
 src/iocore/net/qmux/QMuxConnection.cc              | 543 +++++++++++++++++++++
 src/proxy/CMakeLists.txt                           |   2 +-
 src/proxy/http/CMakeLists.txt                      |   2 +-
 src/proxy/http/HttpProxyServerMain.cc              |   5 +
 src/proxy/http3/CMakeLists.txt                     |   4 +
 src/proxy/http3/Http3SessionAccept.cc              |  22 +-
 src/records/RecHttp.cc                             |   9 +
 src/traffic_layout/info.cc                         |   1 +
 src/traffic_server/CMakeLists.txt                  |   6 +-
 src/traffic_server/traffic_server.cc               |   6 +-
 src/tscore/ink_inet.cc                             |   1 +
 tests/gold_tests/qmux/go_qmux_client/go.mod        |  15 +
 tests/gold_tests/qmux/go_qmux_client/go.sum        |  26 +
 tests/gold_tests/qmux/go_qmux_client/main.go       | 340 +++++++++++++
 .../gold_tests/qmux/go_qmux_client/qmux_compat.go  | 223 +++++++++
 tests/gold_tests/qmux/qmux.replay.yaml             | 126 +++++
 tests/gold_tests/qmux/qmux_go_client.test.py       | 128 +++++
 28 files changed, 1773 insertions(+), 53 deletions(-)

diff --git a/CMakeLists.txt b/CMakeLists.txt
index 32a9bc6df8..2805612543 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -285,6 +285,7 @@ include(CheckOpenSSLIsQuictls)
 include(CheckOpenSSLIsAwsLc)
 include(CheckOpenSSLHasQuicTlsCbs)
 include(CheckOpenSSLHasNativeQuic)
+include(CheckQuicheHasQmux)
 find_package(OpenSSL REQUIRED)
 check_openssl_is_boringssl(SSLLIB_IS_BORINGSSL BORINGSSL_VERSION 
"${OPENSSL_INCLUDE_DIR}")
 check_openssl_is_awslc(SSLLIB_IS_AWSLC AWSLC_VERSION "${OPENSSL_INCLUDE_DIR}")
@@ -329,41 +330,19 @@ endif()
 
 check_openssl_has_native_quic(SSLLIB_HAS_NATIVE_QUIC "${OPENSSL_INCLUDE_DIR}")
 
-if(DEFINED ENABLE_OPENSSL_QUIC
-   AND NOT ENABLE_OPENSSL_QUIC STREQUAL "AUTO"
-   AND ENABLE_OPENSSL_QUIC
-)
-  if(ENABLE_QUICHE)
-    message(FATAL_ERROR "ENABLE_OPENSSL_QUIC and ENABLE_QUICHE are mutually 
exclusive QUIC backends")
-  endif()
-  if(NOT SSLLIB_HAS_NATIVE_QUIC)
-    message(FATAL_ERROR "OpenSSL native QUIC support requires OpenSSL 3.5 or 
newer with OSSL_QUIC_server_method")
-  endif()
-  if(SSLLIB_IS_BORINGSSL
-     OR SSLLIB_IS_AWSLC
-     OR SSLLIB_IS_QUICTLS
-  )
-    message(FATAL_ERROR "OpenSSL native QUIC support requires upstream OpenSSL 
3.5 or newer")
-  endif()
-endif()
-
-set(OPENSSL_QUIC_AVAILABLE ${SSLLIB_HAS_NATIVE_QUIC})
-if(SSLLIB_IS_BORINGSSL
-   OR SSLLIB_IS_AWSLC
-   OR SSLLIB_IS_QUICTLS
-   OR ENABLE_QUICHE
+# OpenSSL's native QUIC is a capability of the mandatory OpenSSL dependency, 
not an optional
+# component to opt into -- same footing as SSLLIB_IS_BORINGSSL or 
SSLLIB_HAS_QUIC_TLS_CBS above.
+# It's mutually exclusive with quiche by construction: quiche requires 
BoringSSL or the TLS
+# callback compat shim, neither of which implements this upstream-OpenSSL-3.5+ 
API.
+set(TS_HAS_OPENSSL_QUIC FALSE)
+if(SSLLIB_HAS_NATIVE_QUIC
+   AND NOT SSLLIB_IS_BORINGSSL
+   AND NOT SSLLIB_IS_AWSLC
+   AND NOT SSLLIB_IS_QUICTLS
+   AND NOT ENABLE_QUICHE
 )
-  set(OPENSSL_QUIC_AVAILABLE FALSE)
+  set(TS_HAS_OPENSSL_QUIC TRUE)
 endif()
-auto_option(
-  OPENSSL_QUIC
-  FEATURE_VAR
-  TS_HAS_OPENSSL_QUIC
-  DESCRIPTION
-  "Use OpenSSL native QUIC"
-  VAR_DEPENDS
-  OPENSSL_QUIC_AVAILABLE
-)
 
 if(ENABLE_PROFILER)
   find_package(profiler REQUIRED)
@@ -380,11 +359,6 @@ elseif(TS_HAS_MIMALLOC)
   link_libraries(mimalloc)
 endif()
 
-if(TS_HAS_OPENSSL_QUIC)
-  set(TS_USE_QUIC TRUE)
-  message(STATUS "Using OpenSSL native QUIC")
-endif()
-
 if(ENABLE_QUICHE)
   if(TS_OPENSSL_QUIC_TLS_CBS_COMPAT)
     set(quiche_USE_STATIC TRUE)
@@ -392,7 +366,6 @@ if(ENABLE_QUICHE)
   find_package(quiche REQUIRED)
 
   set(TS_HAS_QUICHE ${quiche_FOUND})
-  set(TS_USE_QUIC ${TS_HAS_QUICHE})
   if(NOT SSLLIB_IS_BORINGSSL
      AND NOT SSLLIB_IS_QUICTLS
      AND NOT TS_OPENSSL_QUIC_TLS_CBS_COMPAT
@@ -411,6 +384,53 @@ if(ENABLE_QUICHE)
   elseif(TS_OPENSSL_QUIC_TLS_CBS_COMPAT)
     message(STATUS "Using OpenSSL QUIC TLS callbacks compatibility for quiche")
   endif()
+
+  check_quiche_has_qmux(TS_QUICHE_HAS_QMUX)
+endif()
+
+# A QUIC backend supplies the protocol implementation; the transport options
+# below decide how QUIC streams are actually carried on the wire. At least one
+# transport must be enabled for QUIC to be reachable.
+if(TS_HAS_OPENSSL_QUIC OR TS_HAS_QUICHE)
+  set(TS_HAS_QUIC_BACKEND TRUE)
+else()
+  set(TS_HAS_QUIC_BACKEND FALSE)
+endif()
+
+auto_option(
+  QUIC
+  DESCRIPTION
+  "Carry QUIC over UDP (default AUTO: on when a QUIC backend is available)"
+  FEATURE_VAR
+  TS_USE_QUIC
+  VAR_DEPENDS
+  TS_HAS_QUIC_BACKEND
+)
+
+if(TS_USE_QUIC)
+  if(TS_HAS_OPENSSL_QUIC)
+    message(STATUS "Using OpenSSL native QUIC")
+  elseif(TS_HAS_QUICHE)
+    message(STATUS "Using quiche for QUIC transport")
+  endif()
+endif()
+
+auto_option(
+  QMUX
+  DESCRIPTION
+  "Carry QUIC over TLS/TCP as QMux (default AUTO: on when quiche has qmux 
support)"
+  FEATURE_VAR
+  TS_USE_QMUX
+  VAR_DEPENDS
+  TS_HAS_QUICHE
+  TS_QUICHE_HAS_QMUX
+)
+
+if(TS_HAS_QUIC_BACKEND
+   AND NOT TS_USE_QUIC
+   AND NOT TS_USE_QMUX
+)
+  message(WARNING "A QUIC backend is enabled but neither UDP QUIC nor QMux is, 
so QUIC will not be served.")
 endif()
 
 find_package(maxminddb) # Header_rewrite experimental/maxmind_acl
diff --git a/cmake/CheckQuicheHasQmux.cmake b/cmake/CheckQuicheHasQmux.cmake
new file mode 100644
index 0000000000..cef8744c3c
--- /dev/null
+++ b/cmake/CheckQuicheHasQmux.cmake
@@ -0,0 +1,41 @@
+#######################
+#
+#  Licensed to the Apache Software Foundation (ASF) under one or more 
contributor license
+#  agreements.  See the NOTICE file distributed with this work for additional 
information regarding
+#  copyright ownership.  The ASF licenses this file to you under the Apache 
License, Version 2.0
+#  (the "License"); you may not use this file except in compliance with the 
License.  You may obtain
+#  a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software 
distributed under the License
+#  is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 
KIND, either express
+#  or implied. See the License for the specific language governing permissions 
and limitations under
+#  the License.
+#
+#######################
+
+# quiche.h always declares quiche_config_enable_qmux(), regardless of whether 
quiche was built
+# with its `qmux` Rust feature -- only the compiled library conditionally 
exports the symbol. So
+# this must be a full compile-and-link check against the actual quiche 
library, not a header-only
+# check, or it would report qmux support as available even when the linked 
quiche lacks it.
+function(CHECK_QUICHE_HAS_QMUX OUT_VAR)
+  set(CHECK_PROGRAM
+      "
+        #include <quiche.h>
+
+        int main() {
+            quiche_config *config = quiche_config_new(QUICHE_PROTOCOL_VERSION);
+            quiche_config_enable_qmux(config, true);
+            return 0;
+        }
+        "
+  )
+  set(CMAKE_REQUIRED_LIBRARIES quiche::quiche)
+  include(CheckCXXSourceCompiles)
+  check_cxx_source_compiles("${CHECK_PROGRAM}" ${OUT_VAR})
+  set(${OUT_VAR}
+      ${${OUT_VAR}}
+      PARENT_SCOPE
+  )
+endfunction()
diff --git a/include/iocore/net/qmux/QMuxConnection.h 
b/include/iocore/net/qmux/QMuxConnection.h
new file mode 100644
index 0000000000..d194025372
--- /dev/null
+++ b/include/iocore/net/qmux/QMuxConnection.h
@@ -0,0 +1,132 @@
+/** @file
+
+  QMux connection wrapping quiche_conn (draft-opik-quic-qmux-01)
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+ */
+
+#pragma once
+
+#include "iocore/net/quic/QUICConnection.h"
+#include "iocore/net/quic/QUICStream.h"
+#include "iocore/eventsystem/Continuation.h"
+#include "tscore/ink_inet.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <memory>
+#include <string>
+
+struct quiche_conn;
+struct quiche_config;
+
+class Event;
+class NetVConnection;
+class VIO;
+class MIOBuffer;
+class IOBufferReader;
+class QUICContext;
+class QUICApplicationMap;
+class QUICStreamManager;
+
+/**
+ * QMux connection implementing QUICConnection interface.
+ * Wraps a quiche_conn* created with QMux-enabled config.
+ * Also acts as the I/O event handler (Continuation) that bridges
+ * the SSLNetVConnection byte stream to quiche framing, and as the
+ * QUICStreamIO backend that QUICStream uses to move stream data.
+ */
+class QMuxConnection : public QUICConnection, public Continuation, public 
QUICStreamIO
+{
+public:
+  explicit QMuxConnection(NetVConnection *netvc);
+  ~QMuxConnection() override;
+
+  // QUICConnectionInfoProvider
+  QUICConnectionId        peer_connection_id() const override;
+  QUICConnectionId        original_connection_id() const override;
+  QUICConnectionId        first_connection_id() const override;
+  QUICConnectionId        retry_source_connection_id() const override;
+  QUICConnectionId        initial_source_connection_id() const override;
+  QUICConnectionId        connection_id() const override;
+  std::string_view        cids() const override;
+  const QUICFiveTuple     five_tuple() const override;
+  uint32_t                pmtu() const override;
+  NetVConnectionContext_t direction() const override;
+  bool                    is_closed() const override;
+  bool                    is_at_anti_amplification_limit() const override;
+  bool                    is_address_validation_completed() const override;
+  bool                    is_handshake_completed() const override;
+  QUICVersion             negotiated_version() const override;
+  std::string_view        negotiated_application_name() const override;
+  void                    on_stream_updated() override;
+
+  // QUICStreamIO
+  int64_t read_stream(QUICStreamId stream_id, uint8_t *buf, size_t len, bool 
&fin, QUICStreamIO::ErrorCode &error_code) override;
+  bool    stream_read_finished(QUICStreamId stream_id) override;
+  int64_t stream_write_capacity(QUICStreamId stream_id) override;
+  int64_t write_stream(QUICStreamId stream_id, uint8_t const *buf, size_t len, 
bool fin,
+                       QUICStreamIO::ErrorCode &error_code) override;
+
+  // QUICConnection
+  QUICStreamManager *stream_manager() override;
+  void               close_quic_connection(QUICConnectionErrorUPtr error) 
override;
+  void               reset_quic_connection() override;
+  void               handle_received_packet(UDPPacket *packet) override;
+  void               ping() override;
+
+  void start(NetVConnection *netvc);
+  void signal_write_ready();
+
+private:
+  int  main_event(int event, void *data);
+  void _handle_read();
+  void _handle_write();
+  void _flush_quiche_output();
+  void _handle_read_streams();
+  void _handle_write_streams();
+  void _schedule_quiche_timeout();
+  void _unschedule_quiche_timeout();
+
+  static quiche_config *_shared_config;
+  static void           _init_shared_config();
+
+  quiche_conn *_quiche_con = nullptr;
+
+  sockaddr_storage _local_addr     = {};
+  socklen_t        _local_addr_len = 0;
+  sockaddr_storage _peer_addr      = {};
+  socklen_t        _peer_addr_len  = 0;
+
+  std::unique_ptr<QUICApplicationMap> _app_map;
+  std::unique_ptr<QUICContext>        _context;
+  std::unique_ptr<QUICStreamManager>  _stream_manager;
+
+  QUICConnectionId _synthetic_cid;
+  std::string      _cids_str;
+
+  bool _closed   = false;
+  bool _in_write = false;
+
+  MIOBuffer      *_read_buf       = nullptr;
+  IOBufferReader *_read_reader    = nullptr;
+  MIOBuffer      *_write_buf      = nullptr;
+  VIO            *_write_vio      = nullptr;
+  Event          *_quiche_timeout = nullptr;
+};
diff --git a/include/iocore/net/quic/QUICStream.h 
b/include/iocore/net/quic/QUICStream.h
index e57c49b6dc..e287daa5c9 100644
--- a/include/iocore/net/quic/QUICStream.h
+++ b/include/iocore/net/quic/QUICStream.h
@@ -61,7 +61,7 @@ public:
 
   QUICStream() {}
   QUICStream(QUICConnectionInfoProvider *cinfo, QUICStreamId sid);
-  ~QUICStream();
+  virtual ~QUICStream();
 
   QUICStreamId                      id() const;
   const QUICConnectionInfoProvider *connection_info();
diff --git a/include/ts/apidefs.h.in b/include/ts/apidefs.h.in
index fc2403892b..f34ffdb1a3 100644
--- a/include/ts/apidefs.h.in
+++ b/include/ts/apidefs.h.in
@@ -1512,6 +1512,7 @@ extern const char *const TS_ALPN_PROTOCOL_HTTP_3;
 extern const char *const TS_ALPN_PROTOCOL_HTTP_3_D29;
 extern const char *const TS_ALPN_PROTOCOL_HTTP_QUIC;
 extern const char *const TS_ALPN_PROTOCOL_HTTP_QUIC_D29;
+extern const char *const TS_ALPN_PROTOCOL_H3QX;
 
 extern int TS_ALPN_PROTOCOL_INDEX_HTTP_0_9;
 extern int TS_ALPN_PROTOCOL_INDEX_HTTP_1_0;
@@ -1519,6 +1520,7 @@ extern int TS_ALPN_PROTOCOL_INDEX_HTTP_1_1;
 extern int TS_ALPN_PROTOCOL_INDEX_HTTP_2_0;
 extern int TS_ALPN_PROTOCOL_INDEX_HTTP_3;
 extern int TS_ALPN_PROTOCOL_INDEX_HTTP_QUIC;
+extern int TS_ALPN_PROTOCOL_INDEX_H3QX;
 
 extern const char *const TS_ALPN_PROTOCOL_GROUP_HTTP;
 extern const char *const TS_ALPN_PROTOCOL_GROUP_HTTP2;
@@ -1528,6 +1530,7 @@ extern const char *const TS_PROTO_TAG_HTTP_1_1;
 extern const char *const TS_PROTO_TAG_HTTP_2_0;
 extern const char *const TS_PROTO_TAG_HTTP_3;
 extern const char *const TS_PROTO_TAG_HTTP_QUIC;
+extern const char *const TS_PROTO_TAG_H3QX;
 extern const char *const TS_PROTO_TAG_TLS_1_3;
 extern const char *const TS_PROTO_TAG_TLS_1_2;
 extern const char *const TS_PROTO_TAG_TLS_1_1;
diff --git a/include/tscore/ink_config.h.cmake.in 
b/include/tscore/ink_config.h.cmake.in
index 3898e3e7dc..c914eae89d 100644
--- a/include/tscore/ink_config.h.cmake.in
+++ b/include/tscore/ink_config.h.cmake.in
@@ -163,6 +163,7 @@ const int DEFAULT_STACKSIZE = @DEFAULT_STACK_SIZE@;
 #cmakedefine01 TS_USE_ALLOCATOR_METRICS
 #cmakedefine01 TS_USE_POSIX_CAP
 #cmakedefine01 TS_USE_QUIC
+#cmakedefine01 TS_USE_QMUX
 #cmakedefine01 TS_USE_REMOTE_UNWINDING
 #cmakedefine01 TS_USE_TLS13
 #cmakedefine01 TS_USE_TLS_ASYNC
diff --git a/include/tscore/ink_inet.h b/include/tscore/ink_inet.h
index d0cc243364..efdad4b566 100644
--- a/include/tscore/ink_inet.h
+++ b/include/tscore/ink_inet.h
@@ -78,6 +78,7 @@ extern const std::string_view IP_PROTO_TAG_HTTP_QUIC;
 extern const std::string_view IP_PROTO_TAG_HTTP_3;
 extern const std::string_view IP_PROTO_TAG_HTTP_QUIC_D29;
 extern const std::string_view IP_PROTO_TAG_HTTP_3_D29;
+extern const std::string_view IP_PROTO_TAG_H3QX;
 
 struct IpAddr; // forward declare.
 struct UnAddr; // forward declare.
diff --git a/src/iocore/net/CMakeLists.txt b/src/iocore/net/CMakeLists.txt
index c76322b77c..bd1abce1b6 100644
--- a/src/iocore/net/CMakeLists.txt
+++ b/src/iocore/net/CMakeLists.txt
@@ -77,12 +77,15 @@ add_library(
 )
 add_library(ts::inknet ALIAS inknet)
 
-if(TS_USE_QUIC)
+if(TS_USE_QUIC OR TS_USE_QMUX)
   add_subdirectory(quic)
 
-  target_sources(
-    inknet PRIVATE QUICClosedConCollector.cc QUICMultiCertConfigLoader.cc 
QUICNextProtocolAccept.cc QUICSupport.cc
-  )
+  target_sources(inknet PRIVATE QUICSupport.cc)
+  target_link_libraries(inknet PUBLIC ts::quic)
+endif()
+
+if(TS_USE_QUIC)
+  target_sources(inknet PRIVATE QUICClosedConCollector.cc 
QUICMultiCertConfigLoader.cc QUICNextProtocolAccept.cc)
 
   if(TS_HAS_OPENSSL_QUIC)
     target_sources(inknet PRIVATE OpenSSLQUICNetProcessor.cc 
OpenSSLQUICNetVConnection.cc OpenSSLQUICPacketHandler.cc)
@@ -90,8 +93,11 @@ if(TS_USE_QUIC)
     target_sources(inknet PRIVATE QUICNet.cc QUICNetProcessor.cc 
QUICNetVConnection.cc QUICPacketHandler.cc)
     target_link_libraries(inknet PUBLIC quiche::quiche)
   endif()
+endif()
 
-  target_link_libraries(inknet PUBLIC ts::quic)
+if(TS_USE_QMUX)
+  add_subdirectory(qmux)
+  target_link_libraries(inknet PUBLIC quiche::quiche ts::qmux)
 endif()
 
 if(BUILD_REGRESSION_TESTING OR BUILD_TESTING)
@@ -165,12 +171,15 @@ if(BUILD_TESTING)
       ts::http
       ts::http_remap
   )
-  if(TS_USE_QUIC)
+  if(TS_USE_QUIC OR TS_USE_QMUX)
     list(APPEND LINK_GROUP_LIBS quic http3)
     if(TS_HAS_QUICHE)
       list(APPEND LINK_GROUP_LIBS quiche::quiche)
     endif()
   endif()
+  if(TS_USE_QMUX)
+    list(APPEND LINK_GROUP_LIBS qmux)
+  endif()
   if(CMAKE_LINK_GROUP_USING_RESCAN_SUPPORTED OR 
CMAKE_CXX_LINK_GROUP_USING_RESCAN_SUPPORTED)
     string(JOIN "," LINK_GROUP_LIBS_CSV ${LINK_GROUP_LIBS})
     target_link_libraries(
diff --git a/src/iocore/net/P_SSLNetVConnection.h 
b/src/iocore/net/P_SSLNetVConnection.h
index 161748796d..41fe9a5cda 100644
--- a/src/iocore/net/P_SSLNetVConnection.h
+++ b/src/iocore/net/P_SSLNetVConnection.h
@@ -45,6 +45,13 @@
 #include "P_SSLUtils.h"
 #include "P_SSLConfig.h"
 
+#include "tscore/ink_config.h"
+
+#if TS_USE_QMUX
+#include "iocore/net/QUICSupport.h"
+#include "iocore/net/qmux/QMuxConnection.h"
+#endif
+
 #include <netinet/in.h>
 #include <openssl/ssl.h>
 #include <openssl/err.h>
@@ -103,6 +110,10 @@ class SSLNetVConnection : public UnixNetVConnection,
                           public TLSCertSwitchSupport,
                           public TLSEventSupport,
                           public TLSBasicSupport
+#if TS_USE_QMUX
+  ,
+                          public QUICSupport
+#endif
 {
   using super = UnixNetVConnection; ///< Parent type.
 
@@ -417,6 +428,17 @@ private:
   IOBufferReader *_early_data_reader = nullptr;
 #endif
 
+#if TS_USE_QMUX
+  // QUICSupport
+  QUICConnection *
+  get_quic_connection() override
+  {
+    return _qmux_connection.get();
+  }
+
+  std::unique_ptr<QMuxConnection> _qmux_connection;
+#endif
+
 private:
   void                _make_ssl_connection(SSL_CTX *ctx);
   void                _bindSSLObject();
diff --git a/src/iocore/net/SSLNetVConnection.cc 
b/src/iocore/net/SSLNetVConnection.cc
index a1b5ea43bd..bb7ccb5dde 100644
--- a/src/iocore/net/SSLNetVConnection.cc
+++ b/src/iocore/net/SSLNetVConnection.cc
@@ -1030,6 +1030,15 @@ SSLNetVConnection::clear()
     ssl = nullptr;
   }
 
+#if TS_USE_QMUX
+  // The destructor never runs (ClassAllocator<SSLNetVConnection, false>), so 
the
+  // QMux connection has to be released here or it leaks on every VC recycle.
+  // Clear the QUICSupport service slot too, or a recycled non-QMux VC would
+  // still report a (now null) QUIC connection to get_service<QUICSupport>().
+  _qmux_connection.reset();
+  this->_set_service(static_cast<QUICSupport *>(nullptr));
+#endif
+
   ALPNSupport::clear();
   TLSBasicSupport::clear();
   TLSEventSupport::clear();
@@ -1482,6 +1491,20 @@ SSLNetVConnection::sslServerHandShakeEvent(int &err)
         this->set_negotiated_protocol_id({reinterpret_cast<const char 
*>(proto), static_cast<size_t>(len)});
 
         Dbg(dbg_ctl_ssl, "Origin selected next protocol '%.*s'", len, proto);
+
+#if TS_USE_QMUX
+        if (this->get_negotiated_protocol_id() == TS_ALPN_PROTOCOL_INDEX_H3QX) 
{
+          Dbg(dbg_ctl_ssl, "ALPN h3qx-01: creating QMuxConnection");
+          _qmux_connection = std::make_unique<QMuxConnection>(this);
+          if (_qmux_connection->is_closed()) {
+            // Config or quiche_accept() failed. Don't advertise a QUIC 
connection that can
+            // never make progress -- fail the connection instead of 
completing the handshake.
+            _qmux_connection.reset();
+            return EVENT_ERROR;
+          }
+          this->_set_service(static_cast<QUICSupport *>(this));
+        }
+#endif
       } else {
         Dbg(dbg_ctl_ssl, "Origin did not select a next protocol");
       }
diff --git a/src/iocore/net/qmux/CMakeLists.txt 
b/src/iocore/net/qmux/CMakeLists.txt
new file mode 100644
index 0000000000..4ec8c64812
--- /dev/null
+++ b/src/iocore/net/qmux/CMakeLists.txt
@@ -0,0 +1,23 @@
+#######################
+#
+#  Licensed to the Apache Software Foundation (ASF) under one or more 
contributor license
+#  agreements.  See the NOTICE file distributed with this work for additional 
information regarding
+#  copyright ownership.  The ASF licenses this file to you under the Apache 
License, Version 2.0
+#  (the "License"); you may not use this file except in compliance with the 
License.  You may obtain
+#  a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software 
distributed under the License
+#  is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 
KIND, either express
+#  or implied. See the License for the specific language governing permissions 
and limitations under
+#  the License.
+#
+#######################
+
+add_library(qmux STATIC QMuxConnection.cc)
+add_library(ts::qmux ALIAS qmux)
+
+target_link_libraries(qmux PUBLIC quiche::quiche ts::quic ts::inkevent 
ts::tscore)
+
+clang_tidy_check(qmux)
diff --git a/src/iocore/net/qmux/QMuxConnection.cc 
b/src/iocore/net/qmux/QMuxConnection.cc
new file mode 100644
index 0000000000..3b73b16c5b
--- /dev/null
+++ b/src/iocore/net/qmux/QMuxConnection.cc
@@ -0,0 +1,543 @@
+/** @file
+
+  QMux connection implementation wrapping quiche_conn (draft-opik-quic-qmux-01)
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+ */
+
+#include "iocore/net/qmux/QMuxConnection.h"
+#include "iocore/net/NetVConnection.h"
+#include "iocore/net/quic/QUICContext.h"
+#include "iocore/net/quic/QUICApplicationMap.h"
+#include "iocore/net/quic/QUICStreamManager.h"
+#include "iocore/eventsystem/EThread.h"
+#include "iocore/eventsystem/IOBuffer.h"
+#include "iocore/eventsystem/VIO.h"
+#include "tscore/Diags.h"
+#include "tscore/ink_hrtime.h"
+#include "tsutil/DbgCtl.h"
+
+#include <quiche.h>
+#include <algorithm>
+#include <cinttypes>
+#include <cstdint>
+#include <cstring>
+#include <mutex>
+
+namespace
+{
+DbgCtl        dbg_ctl_qmux{"qmux"};
+constexpr int QMUX_IO_BUFFER_SIZE_INDEX = BUFFER_SIZE_INDEX_32K;
+
+// Largest Frames field we advertise via qmux_max_record_size. quiche rejects
+// anything smaller than this, and enforces the limit on records the peer 
sends.
+constexpr uint64_t QMUX_MAX_RECORD_SIZE = 16382;
+
+// A record is Size (varint, at most 8 bytes) followed by Frames, so this 
bounds
+// the bytes that must be contiguous for quiche to parse one record.
+constexpr int64_t QMUX_MAX_RECORD_BYTES = QMUX_MAX_RECORD_SIZE + 8;
+
+// Staging size for records handed to the transport on each send.
+constexpr int64_t QMUX_SEND_BUFFER_SIZE = 65535;
+
+constexpr QUICVersion QMUX_QUIC_VERSION = 0x00000001;
+
+std::once_flag qmux_shared_config_once;
+} // end anonymous namespace
+
+quiche_config *QMuxConnection::_shared_config = nullptr;
+
+void
+QMuxConnection::_init_shared_config()
+{
+  std::call_once(qmux_shared_config_once, []() {
+    quiche_config *config = quiche_config_new(QUICHE_PROTOCOL_VERSION);
+    if (config == nullptr) {
+      Error("failed to create a QMux config");
+      return;
+    }
+
+    std::string alpn("\x07h3qx-01");
+    quiche_config_set_application_protos(config, reinterpret_cast<const 
uint8_t *>(alpn.c_str()), alpn.size());
+
+    quiche_config_set_max_idle_timeout(config, 30000);
+    quiche_config_set_initial_max_data(config, 10000000);
+    quiche_config_set_initial_max_stream_data_bidi_local(config, 1000000);
+    quiche_config_set_initial_max_stream_data_bidi_remote(config, 1000000);
+    quiche_config_set_initial_max_stream_data_uni(config, 1000000);
+    quiche_config_set_initial_max_streams_bidi(config, 100);
+    quiche_config_set_initial_max_streams_uni(config, 100);
+    quiche_config_set_disable_active_migration(config, true);
+
+    quiche_config_enable_qmux(config, true);
+    quiche_config_set_qmux_max_record_size(config, QMUX_MAX_RECORD_SIZE);
+
+    _shared_config = config;
+  });
+}
+
+QMuxConnection::QMuxConnection(NetVConnection *netvc) : 
Continuation(netvc->mutex)
+{
+  _init_shared_config();
+  SET_HANDLER(&QMuxConnection::main_event);
+
+  _synthetic_cid.randomize();
+  _cids_str = _synthetic_cid.hex();
+
+  auto *local_ep = netvc->get_local_addr();
+  auto *peer_ep  = netvc->get_remote_addr();
+
+  _local_addr_len = ats_ip_size(local_ep);
+  _peer_addr_len  = ats_ip_size(peer_ep);
+  memcpy(&_local_addr, local_ep, _local_addr_len);
+  memcpy(&_peer_addr, peer_ep, _peer_addr_len);
+
+  if (_shared_config != nullptr) {
+    _quiche_con =
+      quiche_accept(_synthetic_cid, _synthetic_cid.length(), nullptr, 0, 
reinterpret_cast<const sockaddr *>(&_local_addr),
+                    _local_addr_len, reinterpret_cast<const sockaddr 
*>(&_peer_addr), _peer_addr_len, _shared_config);
+  }
+  if (_quiche_con == nullptr) {
+    Error("failed to create a QMux connection");
+    _closed = true;
+  }
+
+  _context        = std::make_unique<QUICContext>(this);
+  _app_map        = std::make_unique<QUICApplicationMap>();
+  _stream_manager = std::make_unique<QUICStreamManager>(_context.get(), 
_app_map.get());
+}
+
+QMuxConnection::~QMuxConnection()
+{
+  _unschedule_quiche_timeout();
+  if (_read_reader) {
+    _read_reader->dealloc();
+  }
+  if (_read_buf) {
+    free_MIOBuffer(_read_buf);
+  }
+  if (_write_buf) {
+    free_MIOBuffer(_write_buf);
+  }
+  if (_quiche_con != nullptr) {
+    quiche_conn_free(_quiche_con);
+    _quiche_con = nullptr;
+  }
+}
+
+void
+QMuxConnection::start(NetVConnection *netvc)
+{
+  _read_buf             = new_MIOBuffer(QMUX_IO_BUFFER_SIZE_INDEX);
+  _read_buf->water_mark = QMUX_MAX_RECORD_BYTES;
+  _read_reader          = _read_buf->alloc_reader();
+  _write_buf            = new_MIOBuffer(QMUX_IO_BUFFER_SIZE_INDEX);
+
+  netvc->do_io_read(this, INT64_MAX, _read_buf);
+  _write_vio = netvc->do_io_write(this, INT64_MAX, _write_buf->alloc_reader());
+
+  _schedule_quiche_timeout();
+}
+
+void
+QMuxConnection::_schedule_quiche_timeout()
+{
+  if (!_quiche_timeout && _quiche_con != nullptr) {
+    _quiche_timeout = this_ethread()->schedule_in(this, 
HRTIME_MSECONDS(quiche_conn_timeout_as_millis(_quiche_con)));
+  }
+}
+
+void
+QMuxConnection::_unschedule_quiche_timeout()
+{
+  if (_quiche_timeout) {
+    _quiche_timeout->cancel();
+    _quiche_timeout = nullptr;
+  }
+}
+
+void
+QMuxConnection::signal_write_ready()
+{
+  if (_in_write) {
+    return;
+  }
+  if (_write_vio) {
+    SCOPED_MUTEX_LOCK(lock, this->mutex, this_ethread());
+    _write_vio->reenable();
+  }
+}
+
+int
+QMuxConnection::main_event(int event, void *data)
+{
+  if (_quiche_con == nullptr) {
+    return EVENT_DONE;
+  }
+
+  switch (event) {
+  case VC_EVENT_READ_READY:
+  case VC_EVENT_READ_COMPLETE:
+    _handle_read();
+    break;
+  case VC_EVENT_WRITE_READY:
+  case VC_EVENT_WRITE_COMPLETE:
+    _handle_write();
+    break;
+  case EVENT_INTERVAL:
+    ink_assert(_quiche_timeout == data);
+    _quiche_timeout = nullptr;
+    quiche_conn_on_timeout(_quiche_con);
+    _flush_quiche_output();
+    if (quiche_conn_is_closed(_quiche_con)) {
+      close_quic_connection(nullptr);
+    } else {
+      _schedule_quiche_timeout();
+    }
+    break;
+  case VC_EVENT_EOS:
+  case VC_EVENT_ERROR:
+  case VC_EVENT_INACTIVITY_TIMEOUT:
+  case VC_EVENT_ACTIVE_TIMEOUT:
+    Dbg(dbg_ctl_qmux, "connection event %d, closing", event);
+    close_quic_connection(nullptr);
+    break;
+  default:
+    break;
+  }
+
+  return EVENT_CONT;
+}
+
+void
+QMuxConnection::_handle_read()
+{
+  if (_read_reader->read_avail() <= 0) {
+    return;
+  }
+
+  // quiche parses at most one record per call and needs it in contiguous
+  // memory. A record can straddle IOBufferBlock boundaries, so the spanning
+  // case is staged through this buffer; the common case reads in place.
+  uint8_t staging[QMUX_MAX_RECORD_BYTES];
+
+  while (_read_reader->read_avail() > 0) {
+    int64_t avail   = _read_reader->read_avail();
+    int64_t blk_len = _read_reader->block_read_avail();
+
+    if (blk_len <= 0) {
+      _read_reader->skip_empty_blocks();
+      continue;
+    }
+
+    uint8_t *buf = nullptr;
+    int64_t  len = 0;
+
+    if (blk_len == avail) {
+      buf = reinterpret_cast<uint8_t *>(_read_reader->start());
+      len = blk_len;
+    } else {
+      len = std::min(avail, QMUX_MAX_RECORD_BYTES);
+      _read_reader->memcpy(staging, len, 0);
+      buf = staging;
+    }
+
+    quiche_recv_info recv_info = {};
+    recv_info.from             = const_cast<sockaddr *>(reinterpret_cast<const 
sockaddr *>(&_peer_addr));
+    recv_info.from_len         = _peer_addr_len;
+    recv_info.to               = const_cast<sockaddr *>(reinterpret_cast<const 
sockaddr *>(&_local_addr));
+    recv_info.to_len           = _local_addr_len;
+
+    ssize_t done = quiche_conn_recv(_quiche_con, buf, len, &recv_info);
+    if (done < 0) {
+      if (done == QUICHE_ERR_DONE) {
+        // No complete record in what's buffered yet. Leave the bytes for the 
next read event.
+      } else {
+        // quiche has already classified this as an unrecoverable 
per-connection error and
+        // started its own internal close/drain sequence -- these bytes will 
never parse
+        // successfully, so close now instead of leaving them to linger until 
the next
+        // scheduled quiche_conn_on_timeout() notices the connection is closed.
+        Dbg(dbg_ctl_qmux, "quiche_conn_recv error: %zd", done);
+        close_quic_connection(nullptr);
+      }
+      break;
+    }
+    _read_reader->consume(done);
+  }
+
+  _handle_read_streams();
+  _handle_write();
+}
+
+void
+QMuxConnection::_handle_read_streams()
+{
+  quiche_stream_iter *readable = quiche_conn_readable(_quiche_con);
+  uint64_t            stream_id;
+
+  while (quiche_stream_iter_next(readable, &stream_id)) {
+    QUICStream *stream = _stream_manager->find_stream(stream_id);
+    if (stream == nullptr) {
+      QUICConnectionError err;
+      stream = _stream_manager->create_stream(stream_id, err);
+      if (stream == nullptr) {
+        Dbg(dbg_ctl_qmux, "failed to create stream %" PRIu64, stream_id);
+        continue;
+      }
+    }
+    stream->receive_data(*this);
+  }
+  quiche_stream_iter_free(readable);
+}
+
+void
+QMuxConnection::_handle_write()
+{
+  _in_write = true;
+  if (!quiche_conn_is_established(_quiche_con)) {
+    // Our own QX_TRANSPORT_PARAMETERS haven't been sent yet. Flush now so 
that,
+    // if the peer's have already been received, the connection is established
+    // before the _handle_write_streams() check below -- otherwise a stream
+    // queued before this call (e.g. the HTTP/3 control stream) misses this
+    // cycle, and nothing else is guaranteed to trigger another one.
+    _flush_quiche_output();
+  }
+  _handle_write_streams();
+  _flush_quiche_output();
+  _in_write = false;
+}
+
+void
+QMuxConnection::_flush_quiche_output()
+{
+  bool             wrote = false;
+  uint8_t          out[QMUX_SEND_BUFFER_SIZE];
+  quiche_send_info send_info;
+
+  for (;;) {
+    ssize_t written = quiche_conn_send(_quiche_con, out, sizeof(out), 
&send_info);
+    if (written == QUICHE_ERR_DONE) {
+      break;
+    }
+    if (written < 0) {
+      Dbg(dbg_ctl_qmux, "quiche_conn_send error: %zd", written);
+      break;
+    }
+    _write_buf->write(out, written);
+    wrote = true;
+  }
+
+  if (wrote && _write_vio) {
+    _write_vio->reenable();
+  }
+}
+
+void
+QMuxConnection::_handle_write_streams()
+{
+  if (!quiche_conn_is_established(_quiche_con)) {
+    return;
+  }
+
+  quiche_stream_iter *writable = quiche_conn_writable(_quiche_con);
+  uint64_t            stream_id;
+
+  while (quiche_stream_iter_next(writable, &stream_id)) {
+    QUICStream *stream = _stream_manager->find_stream(stream_id);
+    if (stream != nullptr) {
+      stream->send_data(*this);
+    }
+  }
+  quiche_stream_iter_free(writable);
+}
+
+// --- QUICConnectionInfoProvider ---
+
+QUICConnectionId
+QMuxConnection::peer_connection_id() const
+{
+  return QUICConnectionId::ZERO();
+}
+
+QUICConnectionId
+QMuxConnection::original_connection_id() const
+{
+  return QUICConnectionId::ZERO();
+}
+
+QUICConnectionId
+QMuxConnection::first_connection_id() const
+{
+  return _synthetic_cid;
+}
+
+QUICConnectionId
+QMuxConnection::retry_source_connection_id() const
+{
+  return QUICConnectionId::ZERO();
+}
+
+QUICConnectionId
+QMuxConnection::initial_source_connection_id() const
+{
+  return _synthetic_cid;
+}
+
+QUICConnectionId
+QMuxConnection::connection_id() const
+{
+  return _synthetic_cid;
+}
+
+std::string_view
+QMuxConnection::cids() const
+{
+  return _cids_str;
+}
+
+const QUICFiveTuple
+QMuxConnection::five_tuple() const
+{
+  return QUICFiveTuple();
+}
+
+uint32_t
+QMuxConnection::pmtu() const
+{
+  // Not meaningful over TCP.
+  return QMUX_SEND_BUFFER_SIZE;
+}
+
+NetVConnectionContext_t
+QMuxConnection::direction() const
+{
+  return NET_VCONNECTION_IN;
+}
+
+bool
+QMuxConnection::is_closed() const
+{
+  return _closed;
+}
+
+bool
+QMuxConnection::is_at_anti_amplification_limit() const
+{
+  return false;
+}
+
+bool
+QMuxConnection::is_address_validation_completed() const
+{
+  return true;
+}
+
+bool
+QMuxConnection::is_handshake_completed() const
+{
+  return true;
+}
+
+QUICVersion
+QMuxConnection::negotiated_version() const
+{
+  return QMUX_QUIC_VERSION;
+}
+
+std::string_view
+QMuxConnection::negotiated_application_name() const
+{
+  return "h3qx-01";
+}
+
+void
+QMuxConnection::on_stream_updated()
+{
+  this->signal_write_ready();
+}
+
+// --- QUICStreamIO ---
+
+int64_t
+QMuxConnection::read_stream(QUICStreamId stream_id, uint8_t *buf, size_t len, 
bool &fin, QUICStreamIO::ErrorCode &error_code)
+{
+  return quiche_conn_stream_recv(_quiche_con, stream_id, buf, len, &fin, 
&error_code);
+}
+
+bool
+QMuxConnection::stream_read_finished(QUICStreamId stream_id)
+{
+  return quiche_conn_stream_finished(_quiche_con, stream_id);
+}
+
+int64_t
+QMuxConnection::stream_write_capacity(QUICStreamId stream_id)
+{
+  return quiche_conn_stream_capacity(_quiche_con, stream_id);
+}
+
+int64_t
+QMuxConnection::write_stream(QUICStreamId stream_id, uint8_t const *buf, 
size_t len, bool fin, QUICStreamIO::ErrorCode &error_code)
+{
+  return quiche_conn_stream_send(_quiche_con, stream_id, const_cast<uint8_t 
*>(buf), len, fin, &error_code);
+}
+
+// --- QUICConnection ---
+
+QUICStreamManager *
+QMuxConnection::stream_manager()
+{
+  return _stream_manager.get();
+}
+
+void
+QMuxConnection::close_quic_connection(QUICConnectionErrorUPtr error)
+{
+  if (_closed) {
+    return;
+  }
+  _closed = true;
+
+  const bool     is_app_error = error != nullptr && error->cls == 
QUICErrorClass::APPLICATION;
+  const uint64_t err_code     = error == nullptr ? 
static_cast<uint64_t>(QUICTransErrorCode::NO_ERROR) : error->code;
+
+  if (int rv = quiche_conn_close(_quiche_con, is_app_error, err_code, nullptr, 
0); rv < 0) {
+    Dbg(dbg_ctl_qmux, "[%s] quiche_conn_close error: %d", _cids_str.c_str(), 
rv);
+  }
+  // quiche_conn_close() only queues the CLOSE frame; it has to be flushed 
like any other
+  // outgoing data or the peer never sees it.
+  _flush_quiche_output();
+  Dbg(dbg_ctl_qmux, "[%s] connection closed with error %" PRIu64, 
_cids_str.c_str(), err_code);
+}
+
+void
+QMuxConnection::reset_quic_connection()
+{
+  _closed = true;
+}
+
+void
+QMuxConnection::handle_received_packet(UDPPacket * /* packet ATS_UNUSED */)
+{
+}
+
+void
+QMuxConnection::ping()
+{
+}
diff --git a/src/proxy/CMakeLists.txt b/src/proxy/CMakeLists.txt
index c0814f6009..f4c74c8c8d 100644
--- a/src/proxy/CMakeLists.txt
+++ b/src/proxy/CMakeLists.txt
@@ -52,7 +52,7 @@ add_subdirectory(http)
 add_subdirectory(http2)
 add_subdirectory(logging)
 
-if(TS_USE_QUIC)
+if(TS_USE_QUIC OR TS_USE_QMUX)
   add_subdirectory(http3)
 endif()
 
diff --git a/src/proxy/http/CMakeLists.txt b/src/proxy/http/CMakeLists.txt
index 55cf7ebd36..bf36cd4a78 100644
--- a/src/proxy/http/CMakeLists.txt
+++ b/src/proxy/http/CMakeLists.txt
@@ -50,7 +50,7 @@ target_link_libraries(
   PRIVATE ts::http2 ts::http_remap ts::inkcache ts::inkutils ts::logging
 )
 
-if(TS_USE_QUIC)
+if(TS_USE_QUIC OR TS_USE_QMUX)
   target_link_libraries(http PRIVATE ts::http3)
 endif()
 
diff --git a/src/proxy/http/HttpProxyServerMain.cc 
b/src/proxy/http/HttpProxyServerMain.cc
index 133f70116e..467fcad36a 100644
--- a/src/proxy/http/HttpProxyServerMain.cc
+++ b/src/proxy/http/HttpProxyServerMain.cc
@@ -40,6 +40,8 @@
 #include "../../iocore/net/P_QUICNetProcessor.h"
 #include "../../iocore/net/P_QUICNextProtocolAccept.h"
 #include "proxy/http3/Http3SessionAccept.h"
+#elif TS_USE_QMUX == 1
+#include "proxy/http3/Http3SessionAccept.h"
 #endif
 
 #include <vector>
@@ -223,6 +225,9 @@ MakeHttpProxyAcceptor(HttpProxyAcceptor &acceptor, 
HttpProxyPort &port, unsigned
     ssl->registerEndpoint(TS_ALPN_PROTOCOL_HTTP_1_0, http);
     ssl->registerEndpoint(TS_ALPN_PROTOCOL_HTTP_1_1, http);
     ssl->registerEndpoint(TS_ALPN_PROTOCOL_HTTP_2_0, new 
Http2SessionAccept(accept_opt));
+#if TS_USE_QMUX
+    ssl->registerEndpoint(TS_ALPN_PROTOCOL_H3QX, new 
Http3SessionAccept(accept_opt));
+#endif
 
     SCOPED_MUTEX_LOCK(lock, ssl_plugin_mutex, this_ethread());
     ssl_plugin_acceptors.push(ssl);
diff --git a/src/proxy/http3/CMakeLists.txt b/src/proxy/http3/CMakeLists.txt
index c4c71dba53..42beb52202 100644
--- a/src/proxy/http3/CMakeLists.txt
+++ b/src/proxy/http3/CMakeLists.txt
@@ -47,6 +47,10 @@ target_link_libraries(
   PRIVATE ts::proxy
 )
 
+if(TS_USE_QMUX)
+  target_link_libraries(http3 PUBLIC ts::qmux)
+endif()
+
 if(BUILD_TESTING)
   add_executable(
     test_http3
diff --git a/src/proxy/http3/Http3SessionAccept.cc 
b/src/proxy/http3/Http3SessionAccept.cc
index d82546b606..8a7e9da036 100644
--- a/src/proxy/http3/Http3SessionAccept.cc
+++ b/src/proxy/http3/Http3SessionAccept.cc
@@ -32,6 +32,10 @@
 #include "proxy/http3/Http09App.h"
 #include "proxy/http3/Http3App.h"
 
+#if TS_USE_QMUX
+#include "iocore/net/qmux/QMuxConnection.h"
+#endif
+
 namespace
 {
 DbgCtl dbg_ctl_http3{"http3"};
@@ -77,10 +81,26 @@ Http3SessionAccept::accept(NetVConnection *netvc, MIOBuffer 
* /* iobuf ATS_UNUSE
   if (IP_PROTO_TAG_HTTP_QUIC.compare(alpn) == 0 || 
IP_PROTO_TAG_HTTP_QUIC_D29.compare(alpn) == 0) {
     Dbg(dbg_ctl_http3, "[%s] start HTTP/0.9 app (ALPN=%.*s)", 
qc->cids().data(), static_cast<int>(alpn.length()), alpn.data());
     new Http09App(netvc, qc, std::move(session_acl), this->options);
-  } else if (IP_PROTO_TAG_HTTP_3.compare(alpn) == 0 || 
IP_PROTO_TAG_HTTP_3_D29.compare(alpn) == 0) {
+  } else if (IP_PROTO_TAG_HTTP_3.compare(alpn) == 0 || 
IP_PROTO_TAG_HTTP_3_D29.compare(alpn) == 0 ||
+             IP_PROTO_TAG_H3QX.compare(alpn) == 0) {
     Dbg(dbg_ctl_http3, "[%s] start HTTP/3 app (ALPN=%.*s)", qc->cids().data(), 
static_cast<int>(alpn.length()), alpn.data());
 
     Http3App *app = new Http3App(netvc, qc, std::move(session_acl), 
this->options);
+
+#if TS_USE_QMUX
+    if (IP_PROTO_TAG_H3QX.compare(alpn) == 0) {
+      // Http3App's constructor runs the generic ProxySession start-up 
(HQSession::start()),
+      // which claims the netvc's read/write VIOs for itself. Reclaim them for 
QMuxConnection
+      // here, after that clobber and before app->start() can generate any 
stream I/O that
+      // would need them.
+      auto *qmux_con = dynamic_cast<QMuxConnection *>(qc);
+      if (!qmux_con) {
+        ink_abort("negotiated h3qx-01 but QUICConnection is not a 
QMuxConnection");
+      }
+      qmux_con->start(netvc);
+    }
+#endif
+
     app->start();
   } else {
     ink_abort("Negotiated App Name is unknown");
diff --git a/src/records/RecHttp.cc b/src/records/RecHttp.cc
index 7f1dbd68f9..5b689a3d0b 100644
--- a/src/records/RecHttp.cc
+++ b/src/records/RecHttp.cc
@@ -51,6 +51,7 @@ const char *const TS_ALPN_PROTOCOL_HTTP_3        = 
IP_PROTO_TAG_HTTP_3.data();
 const char *const TS_ALPN_PROTOCOL_HTTP_QUIC     = 
IP_PROTO_TAG_HTTP_QUIC.data();
 const char *const TS_ALPN_PROTOCOL_HTTP_3_D29    = 
IP_PROTO_TAG_HTTP_3_D29.data();
 const char *const TS_ALPN_PROTOCOL_HTTP_QUIC_D29 = 
IP_PROTO_TAG_HTTP_QUIC_D29.data();
+const char *const TS_ALPN_PROTOCOL_H3QX          = IP_PROTO_TAG_H3QX.data();
 
 const char *const TS_ALPN_PROTOCOL_GROUP_HTTP  = "http";
 const char *const TS_ALPN_PROTOCOL_GROUP_HTTP2 = "http2";
@@ -62,6 +63,7 @@ const char *const TS_PROTO_TAG_HTTP_3        = 
TS_ALPN_PROTOCOL_HTTP_3;
 const char *const TS_PROTO_TAG_HTTP_QUIC     = TS_ALPN_PROTOCOL_HTTP_QUIC;
 const char *const TS_PROTO_TAG_HTTP_3_D29    = TS_ALPN_PROTOCOL_HTTP_3_D29;
 const char *const TS_PROTO_TAG_HTTP_QUIC_D29 = TS_ALPN_PROTOCOL_HTTP_QUIC_D29;
+const char *const TS_PROTO_TAG_H3QX          = TS_ALPN_PROTOCOL_H3QX;
 const char *const TS_PROTO_TAG_TLS_1_3       = IP_PROTO_TAG_TLS_1_3.data();
 const char *const TS_PROTO_TAG_TLS_1_2       = IP_PROTO_TAG_TLS_1_2.data();
 const char *const TS_PROTO_TAG_TLS_1_1       = IP_PROTO_TAG_TLS_1_1.data();
@@ -82,6 +84,7 @@ int TS_ALPN_PROTOCOL_INDEX_HTTP_3        = 
SessionProtocolNameRegistry::INVALID;
 int TS_ALPN_PROTOCOL_INDEX_HTTP_QUIC     = 
SessionProtocolNameRegistry::INVALID;
 int TS_ALPN_PROTOCOL_INDEX_HTTP_3_D29    = 
SessionProtocolNameRegistry::INVALID;
 int TS_ALPN_PROTOCOL_INDEX_HTTP_QUIC_D29 = 
SessionProtocolNameRegistry::INVALID;
+int TS_ALPN_PROTOCOL_INDEX_H3QX          = 
SessionProtocolNameRegistry::INVALID;
 
 // Predefined protocol sets for ease of use.
 SessionProtocolSet HTTP_PROTOCOL_SET;
@@ -221,6 +224,7 @@ constexpr std::string_view 
TS_ALPN_PROTO_ID_OPENSSL_HTTP_1_0("\x8http/1.0");
 constexpr std::string_view TS_ALPN_PROTO_ID_OPENSSL_HTTP_1_1("\x8http/1.1");
 constexpr std::string_view TS_ALPN_PROTO_ID_OPENSSL_HTTP_2("\x2h2");
 constexpr std::string_view TS_ALPN_PROTO_ID_OPENSSL_HTTP_3("\x2h3");
+constexpr std::string_view TS_ALPN_PROTO_ID_OPENSSL_H3QX("\x7h3qx-01");
 
 bool
 parse_octal_mode(const char *s, mode_t &out)
@@ -836,6 +840,7 @@ ts_session_protocol_well_known_name_indices_init()
   TS_ALPN_PROTOCOL_INDEX_HTTP_QUIC  = 
globalSessionProtocolNameRegistry.toIndexConst(std::string_view{TS_ALPN_PROTOCOL_HTTP_QUIC});
   TS_ALPN_PROTOCOL_INDEX_HTTP_QUIC_D29 =
     
globalSessionProtocolNameRegistry.toIndexConst(std::string_view{TS_ALPN_PROTOCOL_HTTP_QUIC_D29});
+  TS_ALPN_PROTOCOL_INDEX_H3QX = 
globalSessionProtocolNameRegistry.toIndexConst(std::string_view{TS_ALPN_PROTOCOL_H3QX});
 
   // Now do the predefined protocol sets.
   HTTP_PROTOCOL_SET.markIn(TS_ALPN_PROTOCOL_INDEX_HTTP_0_9);
@@ -846,6 +851,7 @@ ts_session_protocol_well_known_name_indices_init()
   DEFAULT_TLS_SESSION_PROTOCOL_SET.markAllIn();
   DEFAULT_TLS_SESSION_PROTOCOL_SET.markOut(TS_ALPN_PROTOCOL_INDEX_HTTP_3);
   DEFAULT_TLS_SESSION_PROTOCOL_SET.markOut(TS_ALPN_PROTOCOL_INDEX_HTTP_QUIC);
+  DEFAULT_TLS_SESSION_PROTOCOL_SET.markOut(TS_ALPN_PROTOCOL_INDEX_H3QX);
 
   DEFAULT_QUIC_SESSION_PROTOCOL_SET.markIn(TS_ALPN_PROTOCOL_INDEX_HTTP_3);
   DEFAULT_QUIC_SESSION_PROTOCOL_SET.markIn(TS_ALPN_PROTOCOL_INDEX_HTTP_QUIC);
@@ -861,6 +867,7 @@ ts_session_protocol_well_known_name_indices_init()
   TSProtoTags.insert(TS_PROTO_TAG_HTTP_QUIC);
   TSProtoTags.insert(TS_PROTO_TAG_HTTP_3_D29);
   TSProtoTags.insert(TS_PROTO_TAG_HTTP_QUIC_D29);
+  TSProtoTags.insert(TS_PROTO_TAG_H3QX);
   TSProtoTags.insert(TS_PROTO_TAG_TLS_1_3);
   TSProtoTags.insert(TS_PROTO_TAG_TLS_1_2);
   TSProtoTags.insert(TS_PROTO_TAG_TLS_1_1);
@@ -898,6 +905,8 @@ 
SessionProtocolNameRegistry::convert_openssl_alpn_wire_format(int index)
     return TS_ALPN_PROTO_ID_OPENSSL_HTTP_2;
   } else if (index == TS_ALPN_PROTOCOL_INDEX_HTTP_3) {
     return TS_ALPN_PROTO_ID_OPENSSL_HTTP_3;
+  } else if (index == TS_ALPN_PROTOCOL_INDEX_H3QX) {
+    return TS_ALPN_PROTO_ID_OPENSSL_H3QX;
   }
 
   return {};
diff --git a/src/traffic_layout/info.cc b/src/traffic_layout/info.cc
index 9b1e6c331c..746eeca499 100644
--- a/src/traffic_layout/info.cc
+++ b/src/traffic_layout/info.cc
@@ -160,6 +160,7 @@ produce_features(bool json)
   print_feature("TS_USE_HWLOC", TS_USE_HWLOC, json);
   print_feature("TS_USE_TLS13", TS_USE_TLS13, json);
   print_feature("TS_USE_QUIC", TS_USE_QUIC, json);
+  print_feature("TS_USE_QMUX", TS_USE_QMUX, json);
   print_feature("TS_HAS_OPENSSL_QUIC", TS_HAS_OPENSSL_QUIC, json);
   print_feature("TS_HAS_QUICHE", TS_HAS_QUICHE, json);
   print_feature("TS_HAS_SO_PEERCRED", TS_HAS_SO_PEERCRED, json);
diff --git a/src/traffic_server/CMakeLists.txt 
b/src/traffic_server/CMakeLists.txt
index f50ff2ef82..9f1e1fc20f 100644
--- a/src/traffic_server/CMakeLists.txt
+++ b/src/traffic_server/CMakeLists.txt
@@ -50,10 +50,14 @@ if(NOT APPLE)
   target_link_options(traffic_server PRIVATE 
-Wl,--no-undefined,--no-allow-shlib-undefined)
 endif()
 
-if(TS_USE_QUIC)
+if(TS_USE_QUIC OR TS_USE_QMUX)
   target_link_libraries(traffic_server PRIVATE ts::http3 ts::quic)
 endif()
 
+if(TS_USE_QMUX)
+  target_link_libraries(traffic_server PRIVATE ts::qmux)
+endif()
+
 if(TS_HAS_PROFILER)
   target_link_libraries(traffic_server PRIVATE gperftools::profiler)
 endif()
diff --git a/src/traffic_server/traffic_server.cc 
b/src/traffic_server/traffic_server.cc
index 57158fd66b..f253c0cdea 100644
--- a/src/traffic_server/traffic_server.cc
+++ b/src/traffic_server/traffic_server.cc
@@ -127,7 +127,7 @@ extern "C" int plock(int);
 
 #include "mgmt/config/FileManager.h"
 
-#if TS_USE_QUIC == 1
+#if TS_USE_QUIC == 1 || TS_USE_QMUX == 1
 #include "proxy/http3/Http3.h"
 #include "proxy/http3/Http3Config.h"
 #endif
@@ -2159,7 +2159,7 @@ main(int /* argc ATS_UNUSED */, const char **argv)
   // We want to initialize Machine as early as possible because it
   // has other dependencies. Hopefully not in prep_HttpProxyServer().
   HttpConfig::startup();
-#if TS_USE_QUIC == 1
+#if TS_USE_QUIC == 1 || TS_USE_QMUX == 1
   ts::Http3Config::startup();
 #endif
 
@@ -2353,7 +2353,7 @@ main(int /* argc ATS_UNUSED */, const char **argv)
 
     // Initialize HTTP/2
     Http2::init();
-#if TS_USE_QUIC == 1
+#if TS_USE_QUIC == 1 || TS_USE_QMUX == 1
     // Initialize HTTP/QUIC
     Http3::init();
 #endif
diff --git a/src/tscore/ink_inet.cc b/src/tscore/ink_inet.cc
index 0194dc9d94..6d8f9eda5e 100644
--- a/src/tscore/ink_inet.cc
+++ b/src/tscore/ink_inet.cc
@@ -56,6 +56,7 @@ const std::string_view IP_PROTO_TAG_HTTP_QUIC("hq"sv);        
// HTTP/0.9 over Q
 const std::string_view IP_PROTO_TAG_HTTP_3("h3"sv);           // HTTP/3 over 
QUIC
 const std::string_view IP_PROTO_TAG_HTTP_QUIC_D29("hq-29"sv); // HTTP/0.9 over 
QUIC (draft-29)
 const std::string_view IP_PROTO_TAG_HTTP_3_D29("h3-29"sv);    // HTTP/3 over 
QUIC (draft-29)
+const std::string_view IP_PROTO_TAG_H3QX("h3qx-01"sv);        // HTTP/3 over 
QMux (TLS/TCP)
 
 const std::string_view UNIX_PROTO_TAG{"unix"sv};
 
diff --git a/tests/gold_tests/qmux/go_qmux_client/go.mod 
b/tests/gold_tests/qmux/go_qmux_client/go.mod
new file mode 100644
index 0000000000..531eb56c06
--- /dev/null
+++ b/tests/gold_tests/qmux/go_qmux_client/go.mod
@@ -0,0 +1,15 @@
+module qmux_client
+
+go 1.26.1
+
+require (
+       github.com/okdaichi/qmux-go v0.2.0
+       github.com/quic-go/qpack v0.6.0
+       github.com/quic-go/quic-go v0.59.0
+)
+
+require (
+       golang.org/x/crypto v0.50.0 // indirect
+       golang.org/x/net v0.53.0 // indirect
+       golang.org/x/sys v0.43.0 // indirect
+)
diff --git a/tests/gold_tests/qmux/go_qmux_client/go.sum 
b/tests/gold_tests/qmux/go_qmux_client/go.sum
new file mode 100644
index 0000000000..b10735cd87
--- /dev/null
+++ b/tests/gold_tests/qmux/go_qmux_client/go.sum
@@ -0,0 +1,26 @@
+github.com/coder/websocket v1.8.14 
h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
+github.com/coder/websocket v1.8.14/go.mod 
h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
+github.com/davecgh/go-spew v1.1.1 
h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod 
h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/gorilla/websocket v1.5.3 
h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod 
h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/okdaichi/qmux-go v0.2.0 
h1:FiAJN99zhe9CcEHbJCBonOhjVhNRNLS1GPCQbE+Etx4=
+github.com/okdaichi/qmux-go v0.2.0/go.mod 
h1:M3k3+VbBl98QagraePQqavelrSM15FQSHJgnS+RjKOU=
+github.com/pmezard/go-difflib v1.0.0 
h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod 
h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
+github.com/quic-go/qpack v0.6.0/go.mod 
h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
+github.com/quic-go/quic-go v0.59.0 
h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
+github.com/quic-go/quic-go v0.59.0/go.mod 
h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
+github.com/stretchr/testify v1.11.1 
h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod 
h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
+go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
+golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
+golang.org/x/crypto v0.50.0/go.mod 
h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
+golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
+golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
+golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
+golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/tests/gold_tests/qmux/go_qmux_client/main.go 
b/tests/gold_tests/qmux/go_qmux_client/main.go
new file mode 100644
index 0000000000..2fccebe476
--- /dev/null
+++ b/tests/gold_tests/qmux/go_qmux_client/main.go
@@ -0,0 +1,340 @@
+//  Licensed to the Apache Software Foundation (ASF) under one
+//  or more contributor license agreements.  See the NOTICE file
+//  distributed with this work for additional information
+//  regarding copyright ownership.  The ASF licenses this file
+//  to you under the Apache License, Version 2.0 (the
+//  "License"); you may not use this file except in compliance
+//  with the License.  You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package main
+
+import (
+       "bytes"
+       "context"
+       "crypto/tls"
+       "errors"
+       "flag"
+       "fmt"
+       "io"
+       "net"
+       "os"
+       "strconv"
+       "time"
+
+       "github.com/okdaichi/qmux-go/qmux"
+       "github.com/quic-go/qpack"
+       "github.com/quic-go/quic-go/quicvarint"
+)
+
+const (
+       qmuxALPN      = "h3qx-01"
+       bodyChunkSize = 8 * 1024
+       largeBodySize = 300000
+
+       h3FrameData     = 0x00
+       h3FrameHeaders  = 0x01
+       h3FrameSettings = 0x04
+
+       h3ControlStream      = 0x00
+       h3QPACKEncoderStream = 0x02
+       h3QPACKDecoderStream = 0x03
+)
+
+type requestCase struct {
+       name         string
+       method       string
+       path         string
+       requestSize  int
+       responseSize int
+}
+
+func generatedBody(size int) []byte {
+       var body bytes.Buffer
+       for i := 0; body.Len() < size; i++ {
+               fmt.Fprintf(&body, "%07x ", i)
+       }
+       return body.Bytes()[:size]
+}
+
+func writeVarInt(w io.Writer, value uint64) error {
+       encoded := quicvarint.Append(nil, value)
+       _, err := w.Write(encoded)
+       return err
+}
+
+func writeFrame(w io.Writer, frameType uint64, payload []byte) error {
+       header := quicvarint.Append(nil, frameType)
+       header = quicvarint.Append(header, uint64(len(payload)))
+       if _, err := w.Write(header); err != nil {
+               return err
+       }
+       _, err := w.Write(payload)
+       return err
+}
+
+func writeRequestBody(w io.Writer, body []byte) error {
+       for len(body) > 0 {
+               chunkSize := min(len(body), bodyChunkSize)
+               if err := writeFrame(w, h3FrameData, body[:chunkSize]); err != 
nil {
+                       return err
+               }
+               body = body[chunkSize:]
+       }
+       return nil
+}
+
+func openUniStream(ctx context.Context, conn *qmux.Conn, streamType uint64) 
error {
+       stream, err := conn.OpenUniStreamSync(ctx)
+       if err != nil {
+               return err
+       }
+       return writeVarInt(stream, streamType)
+}
+
+func initializeHTTP3(ctx context.Context, conn *qmux.Conn) error {
+       control, err := conn.OpenUniStreamSync(ctx)
+       if err != nil {
+               return fmt.Errorf("open control stream: %w", err)
+       }
+       if err := writeVarInt(control, h3ControlStream); err != nil {
+               return fmt.Errorf("write control stream type: %w", err)
+       }
+       if err := writeFrame(control, h3FrameSettings, nil); err != nil {
+               return fmt.Errorf("write SETTINGS frame: %w", err)
+       }
+
+       if err := openUniStream(ctx, conn, h3QPACKEncoderStream); err != nil {
+               return fmt.Errorf("open QPACK encoder stream: %w", err)
+       }
+       if err := openUniStream(ctx, conn, h3QPACKDecoderStream); err != nil {
+               return fmt.Errorf("open QPACK decoder stream: %w", err)
+       }
+       return nil
+}
+
+func encodeRequestHeaders(authority string, tc requestCase) ([]byte, error) {
+       var block bytes.Buffer
+
+       encoder := qpack.NewEncoder(&block)
+       fields := []qpack.HeaderField{
+               {Name: ":method", Value: tc.method},
+               {Name: ":scheme", Value: "https"},
+               {Name: ":authority", Value: authority},
+               {Name: ":path", Value: tc.path},
+               {Name: "user-agent", Value: "ats-qmux-go-autest"},
+               {Name: "x-qmux-client", Value: "qmux-go"},
+               {Name: "x-qmux-test-case", Value: tc.name},
+               {Name: "uuid", Value: tc.name},
+       }
+       if tc.requestSize > 0 {
+               fields = append(
+                       fields,
+                       qpack.HeaderField{Name: "content-type", Value: 
"application/octet-stream"},
+                       qpack.HeaderField{Name: "content-length", Value: 
strconv.Itoa(tc.requestSize)},
+               )
+       }
+       for _, field := range fields {
+               if err := encoder.WriteField(field); err != nil {
+                       return nil, err
+               }
+       }
+       return block.Bytes(), nil
+}
+
+func decodeResponseHeaders(block []byte) (string, string, string, error) {
+       var status string
+       var marker string
+       var contentLength string
+
+       decode := qpack.NewDecoder().Decode(block)
+       for {
+               field, err := decode()
+               if errors.Is(err, io.EOF) {
+                       break
+               }
+               if err != nil {
+                       return "", "", "", err
+               }
+               switch field.Name {
+               case ":status":
+                       status = field.Value
+               case "x-qmux-response":
+                       marker = field.Value
+               case "content-length":
+                       contentLength = field.Value
+               }
+       }
+       return status, marker, contentLength, nil
+}
+
+func readResponse(stream *qmux.Stream) (string, string, string, []byte, error) 
{
+       reader := quicvarint.NewReader(stream)
+       var status string
+       var marker string
+       var contentLength string
+       var body bytes.Buffer
+
+       for {
+               frameType, err := quicvarint.Read(reader)
+               if errors.Is(err, io.EOF) {
+                       break
+               }
+               if err != nil {
+                       return "", "", "", nil, err
+               }
+               length, err := quicvarint.Read(reader)
+               if err != nil {
+                       return "", "", "", nil, err
+               }
+               payload := make([]byte, length)
+               if _, err := io.ReadFull(reader, payload); err != nil {
+                       return "", "", "", nil, err
+               }
+
+               switch frameType {
+               case h3FrameHeaders:
+                       decodedStatus, decodedMarker, decodedContentLength, err 
:= decodeResponseHeaders(payload)
+                       if err != nil {
+                               return "", "", "", nil, fmt.Errorf("decode 
response headers: %w", err)
+                       }
+                       if decodedStatus != "" {
+                               status = decodedStatus
+                       }
+                       if decodedMarker != "" {
+                               marker = decodedMarker
+                       }
+                       if decodedContentLength != "" {
+                               contentLength = decodedContentLength
+                       }
+               case h3FrameData:
+                       body.Write(payload)
+               }
+       }
+       return status, marker, contentLength, body.Bytes(), nil
+}
+
+func request(ctx context.Context, conn *qmux.Conn, authority string, tc 
requestCase) error {
+       stream, err := conn.OpenStreamSync(ctx)
+       if err != nil {
+               return fmt.Errorf("%s: open request stream: %w", tc.name, err)
+       }
+       stream.SetDeadline(time.Now().Add(20 * time.Second))
+
+       headerBlock, err := encodeRequestHeaders(authority, tc)
+       if err != nil {
+               return fmt.Errorf("%s: encode request headers: %w", tc.name, 
err)
+       }
+       if err := writeFrame(stream, h3FrameHeaders, headerBlock); err != nil {
+               return fmt.Errorf("%s: write request headers: %w", tc.name, err)
+       }
+       if tc.requestSize > 0 {
+               if err := writeRequestBody(stream, 
generatedBody(tc.requestSize)); err != nil {
+                       return fmt.Errorf("%s: write request body: %w", 
tc.name, err)
+               }
+       }
+       if err := stream.Close(); err != nil {
+               return fmt.Errorf("%s: finish request stream: %w", tc.name, err)
+       }
+
+       status, marker, contentLength, body, err := readResponse(stream)
+       if err != nil {
+               return fmt.Errorf("%s: read response: %w", tc.name, err)
+       }
+       if status != "200" {
+               return fmt.Errorf("%s: expected status 200, got %q", tc.name, 
status)
+       }
+       if marker != "success" {
+               return fmt.Errorf("%s: expected X-QMux-Response success, got 
%q", tc.name, marker)
+       }
+       if contentLength != strconv.Itoa(tc.responseSize) {
+               return fmt.Errorf("%s: expected Content-Length %d, got %q", 
tc.name, tc.responseSize, contentLength)
+       }
+       expectedBody := generatedBody(tc.responseSize)
+       if !bytes.Equal(body, expectedBody) {
+               return fmt.Errorf("%s: response body mismatch: got %d bytes, 
expected %d", tc.name, len(body), len(expectedBody))
+       }
+
+       fmt.Printf("ok %s request=%d response=%d\n", tc.name, tc.requestSize, 
tc.responseSize)
+       return nil
+}
+
+func run(addr string, authority string, serverName string) error {
+       ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+       defer cancel()
+
+       tcpConn, err := (&net.Dialer{}).DialContext(ctx, "tcp", addr)
+       if err != nil {
+               return fmt.Errorf("dial TCP: %w", err)
+       }
+       tlsConn := tls.Client(tcpConn, &tls.Config{
+               InsecureSkipVerify: true,
+               MinVersion:         tls.VersionTLS13,
+               NextProtos:         []string{qmuxALPN},
+               ServerName:         serverName,
+       })
+       if err := tlsConn.HandshakeContext(ctx); err != nil {
+               return fmt.Errorf("TLS handshake: %w", err)
+       }
+       if negotiated := tlsConn.ConnectionState().NegotiatedProtocol; 
negotiated != qmuxALPN {
+               return fmt.Errorf("expected ALPN %q, got %q", qmuxALPN, 
negotiated)
+       }
+
+       config := qmux.DefaultConfig()
+       // qmux-go v0.2.0 uses a nonstandard code point for this optional 
parameter.
+       // Omitting it selects the interoperable protocol default of 16,382 
bytes.
+       config.MaxRecordSize = 0
+       config.InitialConnectionReceiveWindow = 10000000
+       config.InitialStreamReceiveWindow = 1000000
+       conn, err := qmux.Dial(newQMuxCompatConn(tlsConn), config)
+       if err != nil {
+               return fmt.Errorf("start QMux: %w", err)
+       }
+       defer conn.Close()
+
+       if err := initializeHTTP3(ctx, conn); err != nil {
+               return err
+       }
+       cases := []requestCase{
+               {name: "qmux-get-empty", method: "GET", path: 
"/qmux-get-empty"},
+               {name: "qmux-post-small", method: "POST", path: 
"/qmux-post-small", requestSize: 100, responseSize: 100},
+               {
+                       name:         "qmux-post-large",
+                       method:       "POST",
+                       path:         "/qmux-post-large",
+                       requestSize:  largeBodySize,
+                       responseSize: largeBodySize,
+               },
+       }
+       for _, tc := range cases {
+               if err := request(ctx, conn, authority, tc); err != nil {
+                       return err
+               }
+       }
+
+       fmt.Printf("completed %d QMux HTTP/3 requests: alpn=%s\n", len(cases), 
qmuxALPN)
+       return nil
+}
+
+func main() {
+       addr := flag.String("addr", "", "ATS QMux address in host:port form")
+       authority := flag.String("authority", "", "HTTP/3 request authority")
+       serverName := flag.String("server-name", "", "TLS SNI server name")
+       flag.Parse()
+
+       if *addr == "" || *authority == "" || *serverName == "" {
+               flag.Usage()
+               os.Exit(2)
+       }
+       if err := run(*addr, *authority, *serverName); err != nil {
+               fmt.Fprintln(os.Stderr, err)
+               os.Exit(1)
+       }
+}
diff --git a/tests/gold_tests/qmux/go_qmux_client/qmux_compat.go 
b/tests/gold_tests/qmux/go_qmux_client/qmux_compat.go
new file mode 100644
index 0000000000..e7f164389b
--- /dev/null
+++ b/tests/gold_tests/qmux/go_qmux_client/qmux_compat.go
@@ -0,0 +1,223 @@
+//  Licensed to the Apache Software Foundation (ASF) under one
+//  or more contributor license agreements.  See the NOTICE file
+//  distributed with this work for additional information
+//  regarding copyright ownership.  The ASF licenses this file
+//  to you under the Apache License, Version 2.0 (the
+//  "License"); you may not use this file except in compliance
+//  with the License.  You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package main
+
+import (
+       "bytes"
+       "fmt"
+       "io"
+       "net"
+       "sync"
+
+       "github.com/quic-go/quic-go/quicvarint"
+)
+
+const qmuxTransportParametersFrameType = 0x3f5153300d0a0d0a
+
+const (
+       qmuxStreamFrameType      = 0x08
+       qmuxStreamFrameTypeMask  = 0xf8
+       qmuxStreamFrameOffsetBit = 0x04
+       qmuxStreamFrameLengthBit = 0x02
+)
+
+// qmuxCompatConn adapts qmux-go v0.2.0's initial transport-parameter frame to
+// draft-ietf-quic-qmux-01. The release omits the transport-parameter frame's
+// payload length and cannot parse STREAM frames without a LEN field, so the
+// adapter normalizes both differences before qmux-go sees them.
+type qmuxCompatConn struct {
+       net.Conn
+       readMutex  sync.Mutex
+       readBuffer bytes.Buffer
+       readReady  bool
+       writeMutex sync.Mutex
+       writeDone  bool
+}
+
+func newQMuxCompatConn(conn net.Conn) net.Conn {
+       return &qmuxCompatConn{Conn: conn}
+}
+
+func (conn *qmuxCompatConn) Read(data []byte) (int, error) {
+       conn.readMutex.Lock()
+       defer conn.readMutex.Unlock()
+
+       if conn.readBuffer.Len() == 0 {
+               var adapted []byte
+               var err error
+               if conn.readReady {
+                       adapted, err = conn.readRecord()
+               } else {
+                       adapted, err = conn.readInitialRecord()
+                       conn.readReady = true
+               }
+               if err != nil {
+                       return 0, err
+               }
+               conn.readBuffer.Write(adapted)
+       }
+       return conn.readBuffer.Read(data)
+}
+
+func (conn *qmuxCompatConn) readRecord() ([]byte, error) {
+       reader := quicvarint.NewReader(conn.Conn)
+       recordLength, err := quicvarint.Read(reader)
+       if err != nil {
+               return nil, err
+       }
+       payload := make([]byte, recordLength)
+       if _, err := io.ReadFull(reader, payload); err != nil {
+               return nil, err
+       }
+       return adaptStreamFrameRecord(payload)
+}
+
+func adaptStreamFrameRecord(payload []byte) ([]byte, error) {
+       frameType, frameTypeBytes, err := quicvarint.Parse(payload)
+       if err != nil {
+               return nil, err
+       }
+       if frameType&qmuxStreamFrameTypeMask != qmuxStreamFrameType || 
frameType&qmuxStreamFrameLengthBit != 0 {
+               return appendRecord(nil, payload), nil
+       }
+
+       headerEnd := frameTypeBytes
+       _, streamIDBytes, err := quicvarint.Parse(payload[headerEnd:])
+       if err != nil {
+               return nil, err
+       }
+       headerEnd += streamIDBytes
+       if frameType&qmuxStreamFrameOffsetBit != 0 {
+               _, offsetBytes, err := quicvarint.Parse(payload[headerEnd:])
+               if err != nil {
+                       return nil, err
+               }
+               headerEnd += offsetBytes
+       }
+
+       adaptedPayload := quicvarint.Append(nil, 
frameType|qmuxStreamFrameLengthBit)
+       adaptedPayload = append(adaptedPayload, 
payload[frameTypeBytes:headerEnd]...)
+       adaptedPayload = quicvarint.Append(adaptedPayload, 
uint64(len(payload)-headerEnd))
+       adaptedPayload = append(adaptedPayload, payload[headerEnd:]...)
+       return appendRecord(nil, adaptedPayload), nil
+}
+
+func (conn *qmuxCompatConn) readInitialRecord() ([]byte, error) {
+       reader := quicvarint.NewReader(conn.Conn)
+       recordLength, err := quicvarint.Read(reader)
+       if err != nil {
+               return nil, err
+       }
+       payload := make([]byte, recordLength)
+       if _, err := io.ReadFull(reader, payload); err != nil {
+               return nil, err
+       }
+
+       payloadReader := bytes.NewReader(payload)
+       frameType, err := quicvarint.Read(quicvarint.NewReader(payloadReader))
+       if err != nil {
+               return nil, err
+       }
+       if frameType != qmuxTransportParametersFrameType {
+               return nil, fmt.Errorf("expected initial 
QX_TRANSPORT_PARAMETERS frame, got %#x", frameType)
+       }
+       parameterLength, err := 
quicvarint.Read(quicvarint.NewReader(payloadReader))
+       if err != nil {
+               return nil, err
+       }
+       if parameterLength > uint64(payloadReader.Len()) {
+               return nil, fmt.Errorf("QMux transport parameters length %d 
exceeds record payload", parameterLength)
+       }
+
+       parameterBytes := make([]byte, parameterLength)
+       if _, err := io.ReadFull(payloadReader, parameterBytes); err != nil {
+               return nil, err
+       }
+       transportParameters := quicvarint.Append(nil, frameType)
+       transportParameters = append(transportParameters, parameterBytes...)
+       adapted := appendRecord(nil, transportParameters)
+       if payloadReader.Len() > 0 {
+               remainingFrames := make([]byte, payloadReader.Len())
+               if _, err := io.ReadFull(payloadReader, remainingFrames); err 
!= nil {
+                       return nil, err
+               }
+               adapted = appendRecord(adapted, remainingFrames)
+       }
+       return adapted, nil
+}
+
+func (conn *qmuxCompatConn) Write(data []byte) (int, error) {
+       conn.writeMutex.Lock()
+       defer conn.writeMutex.Unlock()
+
+       if conn.writeDone {
+               return conn.Conn.Write(data)
+       }
+       adapted, err := adaptInitialWrite(data)
+       if err != nil {
+               return 0, err
+       }
+       if err := writeAll(conn.Conn, adapted); err != nil {
+               return 0, err
+       }
+       conn.writeDone = true
+       return len(data), nil
+}
+
+func adaptInitialWrite(data []byte) ([]byte, error) {
+       recordLength, recordLengthBytes, err := quicvarint.Parse(data)
+       if err != nil {
+               return nil, err
+       }
+       if recordLength > uint64(len(data)-recordLengthBytes) {
+               return nil, fmt.Errorf("incomplete initial QMux record")
+       }
+       payload := data[recordLengthBytes : recordLengthBytes+int(recordLength)]
+       frameType, frameTypeBytes, err := quicvarint.Parse(payload)
+       if err != nil {
+               return nil, err
+       }
+       if frameType != qmuxTransportParametersFrameType {
+               return nil, fmt.Errorf("expected initial 
QX_TRANSPORT_PARAMETERS frame, got %#x", frameType)
+       }
+
+       parameters := payload[frameTypeBytes:]
+       adaptedPayload := quicvarint.Append(nil, frameType)
+       adaptedPayload = quicvarint.Append(adaptedPayload, 
uint64(len(parameters)))
+       adaptedPayload = append(adaptedPayload, parameters...)
+       adapted := appendRecord(nil, adaptedPayload)
+       return append(adapted, data[recordLengthBytes+int(recordLength):]...), 
nil
+}
+
+func appendRecord(destination []byte, payload []byte) []byte {
+       destination = quicvarint.Append(destination, uint64(len(payload)))
+       return append(destination, payload...)
+}
+
+func writeAll(writer io.Writer, data []byte) error {
+       for len(data) > 0 {
+               written, err := writer.Write(data)
+               if err != nil {
+                       return err
+               }
+               if written == 0 {
+                       return io.ErrShortWrite
+               }
+               data = data[written:]
+       }
+       return nil
+}
diff --git a/tests/gold_tests/qmux/qmux.replay.yaml 
b/tests/gold_tests/qmux/qmux.replay.yaml
new file mode 100644
index 0000000000..d93ca9badb
--- /dev/null
+++ b/tests/gold_tests/qmux/qmux.replay.yaml
@@ -0,0 +1,126 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+# This is a server-only replay file. The Go client generates the downstream
+# HTTP/3 requests, while Proxy Verifier validates ATS's origin requests and
+# generates the origin responses.
+
+meta:
+  version: '1.0'
+
+  blocks:
+  - request_base: &request_base
+      version: '1.1'
+  - empty_response: &empty_response
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Content-Length, '0' ]
+        - [ X-QMux-Response, success ]
+      content:
+        size: 0
+  - generated_100_response: &generated_100_response
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Content-Type, application/octet-stream ]
+        - [ Content-Length, '100' ]
+        - [ X-QMux-Response, success ]
+      content:
+        size: 100
+  - generated_300k_response: &generated_300k_response
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Content-Type, application/octet-stream ]
+        - [ Content-Length, '300000' ]
+        - [ X-QMux-Response, success ]
+      content:
+        size: 300000
+
+sessions:
+- transactions:
+  - client-request:
+      <<: *request_base
+      method: GET
+      url: /qmux-get-empty
+      headers:
+        fields:
+        - [ X-QMux-Client, qmux-go ]
+        - [ X-QMux-Test-Case, qmux-get-empty ]
+        - [ uuid, qmux-get-empty ]
+
+    proxy-request:
+      headers:
+        fields:
+        - [ X-QMux-Client, { value: qmux-go, as: equal } ]
+        - [ X-QMux-Test-Case, { value: qmux-get-empty, as: equal } ]
+
+    server-response:
+      <<: *empty_response
+
+  - client-request:
+      <<: *request_base
+      method: POST
+      url: /qmux-post-small
+      headers:
+        fields:
+        - [ X-QMux-Client, qmux-go ]
+        - [ X-QMux-Test-Case, qmux-post-small ]
+        - [ Content-Type, application/octet-stream ]
+        - [ Content-Length, '100' ]
+        - [ uuid, qmux-post-small ]
+      content:
+        size: 100
+        verify: { as: equal }
+
+    proxy-request:
+      headers:
+        fields:
+        - [ X-QMux-Client, { value: qmux-go, as: equal } ]
+        - [ X-QMux-Test-Case, { value: qmux-post-small, as: equal } ]
+        - [ Content-Length, { value: '100', as: equal } ]
+
+    server-response:
+      <<: *generated_100_response
+
+  - client-request:
+      <<: *request_base
+      method: POST
+      url: /qmux-post-large
+      headers:
+        fields:
+        - [ X-QMux-Client, qmux-go ]
+        - [ X-QMux-Test-Case, qmux-post-large ]
+        - [ Content-Type, application/octet-stream ]
+        - [ Content-Length, '300000' ]
+        - [ uuid, qmux-post-large ]
+      content:
+        size: 300000
+        verify: { as: equal }
+
+    proxy-request:
+      headers:
+        fields:
+        - [ X-QMux-Client, { value: qmux-go, as: equal } ]
+        - [ X-QMux-Test-Case, { value: qmux-post-large, as: equal } ]
+        - [ Content-Length, { value: '300000', as: equal } ]
+
+    server-response:
+      <<: *generated_300k_response
diff --git a/tests/gold_tests/qmux/qmux_go_client.test.py 
b/tests/gold_tests/qmux/qmux_go_client.test.py
new file mode 100644
index 0000000000..8a16a8619e
--- /dev/null
+++ b/tests/gold_tests/qmux/qmux_go_client.test.py
@@ -0,0 +1,128 @@
+'''
+Verify HTTP/3 over QMux interoperability with a Go client.
+'''
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+import os
+
+Test.Summary = '''Verify that a Go QMux client can complete HTTP/3 
transactions through ATS.'''
+
+Test.SkipUnless(
+    Condition.HasATSFeature('TS_USE_QMUX'),
+    Condition.HasGoVersion('1.26'),
+)
+
+
+class TestQMuxGoClient:
+    '''Configure a Go client interoperability test for HTTP/3 over QMux.'''
+
+    replay_file: str = 'qmux.replay.yaml'
+
+    def __init__(self) -> None:
+        '''Configure the test run.'''
+        tr = Test.AddTestRun('Go HTTP/3 over QMux client request')
+        self._configure_server(tr)
+        self._configure_traffic_server(tr)
+        self._configure_client(tr)
+
+    def _configure_server(self, tr: 'TestRun') -> 'Process':
+        '''Configure the Proxy Verifier origin server.
+
+        :param tr: The TestRun to add the server process to.
+        :return: The server process.
+        '''
+        server = tr.AddVerifierServerProcess('server', self.replay_file, 
verbose=False)
+        self._server = server
+        return server
+
+    def _configure_traffic_server(self, tr: 'TestRun') -> 'Process':
+        '''Configure Traffic Server.
+
+        :param tr: The TestRun to add the Traffic Server process to.
+        :return: The Traffic Server process.
+        '''
+        ts = tr.MakeATSProcess('ts', enable_tls=True, enable_cache=False)
+        self._ts = ts
+
+        ts.StartupTimeout = 60
+        ts.addDefaultSSLFiles()
+        ts.Disk.ssl_multicert_yaml.AddLines(
+            '''
+ssl_multicert:
+  - dest_ip: "*"
+    ssl_cert_name: server.pem
+    ssl_key_name: server.key
+'''.split('\n'))
+        ts.Disk.records_config.update(
+            {
+                'proxy.config.diags.debug.enabled': 1,
+                'proxy.config.diags.debug.tags': 'qmux|http3',
+                'proxy.config.http.server_ports': (f'{ts.Variables.port} 
{ts.Variables.ssl_port}:ssl:proto=h3qx-01'),
+                'proxy.config.ssl.server.cert.path': ts.Variables.SSLDir,
+                'proxy.config.ssl.server.private_key.path': 
ts.Variables.SSLDir,
+            })
+        ts.Disk.remap_config.AddLine(f'map / 
http://127.0.0.1:{self._server.Variables.http_port}')
+        ts.Disk.logging_yaml.AddLines(
+            '''
+logging:
+  formats:
+    - name: qmux_access
+      format: 'c_alpn=%<cqssa> client_version=%<cqpv> c_method=%<cqhm> 
c_url=%<cquuc>'
+
+  logs:
+    - filename: qmux_access
+      format: qmux_access
+'''.split('\n'))
+
+        access_log = Test.Disk.File(os.path.join(ts.Variables.LOGDIR, 
'qmux_access.log'), exists=True)
+        access_log.Content = Testers.ContainsExpression(
+            r'c_alpn=h3qx-01 client_version=http/3 c_method=GET '
+            r'c_url=https://qmux\.example\.com:[0-9]+/qmux-get-empty',
+            'ATS should log the empty QMux request as HTTP/3 over the h3qx-01 
ALPN.')
+        access_log.Content += Testers.ContainsExpression(
+            r'c_alpn=h3qx-01 client_version=http/3 c_method=POST '
+            r'c_url=https://qmux\.example\.com:[0-9]+/qmux-post-large',
+            'ATS should log the large QMux request as HTTP/3 over the h3qx-01 
ALPN.')
+        return ts
+
+    def _configure_client(self, tr: 'TestRun') -> 'Process':
+        '''Configure the Go QMux client.
+
+        :param tr: The TestRun to add the client process to.
+        :return: The client process.
+        '''
+        tr.Setup.Copy('go_qmux_client')
+        client = tr.Processes.Default
+        client.Env['GOFLAGS'] = '-mod=readonly -modcacherw'
+        client.Env['GOCACHE'] = os.path.join(tr.RunDirectory, 'gocache')
+        client.Env['GOMODCACHE'] = os.path.join(tr.RunDirectory, 'gomodcache')
+        client.Env['GOTOOLCHAIN'] = 'local'
+        client.Command = (
+            f'cd "{os.path.join(tr.RunDirectory, "go_qmux_client")}" && '
+            f'go run . --addr 127.0.0.1:{self._ts.Variables.ssl_port} '
+            f'--authority qmux.example.com:{self._ts.Variables.ssl_port} '
+            '--server-name qmux.example.com')
+        client.ReturnCode = 0
+        client.Streams.stdout = Testers.ContainsExpression(
+            'completed 3 QMux HTTP/3 requests: alpn=h3qx-01',
+            'The Go client should complete all HTTP/3 requests over one QMux 
session.')
+        client.StartBefore(self._server)
+        client.StartBefore(self._ts)
+        return client
+
+
+TestQMuxGoClient()

Reply via email to