Skip to content

Client hides local EMSGSIZE error when the QUIC Initial exceeds the local egress MTU #1656

Description

@yupic

Describe the bug

On an Android LTE path with an IPv4 MTU of 1300, the Hysteria client cannot send the default 1280-byte QUIC UDP payload: the resulting IPv4 packet would be 1308 bytes. quic-go sets Don't Fragment (IP_MTU_DISCOVER=IP_PMTUDISC_PROBE on Linux/Android), so the kernel does not fragment the packet. It synchronously returns EMSGSIZE (Message too long) for every sendmsg, and no packet leaves the phone.

Hysteria does not surface this cause. Even at debug level, the only user-visible failure is:

INFO  client mode
FATAL failed to initialize client {"error": "connect error: timeout: no recent network activity"}

This makes a local packet-sizing error look like server unavailability or network filtering.

Tested at commit 26a03b085c8def0224e7b468bbd59b695f6698f8, which fixes the BBR seed mismatch but does not expose or adapt the QUIC Initial packet size.

To Reproduce

The problem does not require an Android phone or a naturally occurring MTU-1300 mobile network. It can be reproduced in either of these ways:

  1. Unmodified build and a controlled low-MTU route. Use the default 1280-byte Initial and an IPv4 route MTU below 1308 (at most 1307). An isolated Linux network namespace or veth pair with MTU 1300 is sufficient and does not require changing the host's real network interface.
  2. Test build with an intentionally oversized Initial. Set quic.Config.InitialPacketSize to 1452. The failure is reproduced with an IPv4 MTU of 1479 or lower (1452 + 8 UDP + 20 IPv4 > MTU), or an IPv6 MTU of 1499 or lower without extension headers (1452 + 8 UDP + 40 IPv6 > MTU).

quic-go caps InitialPacketSize at 1452, so increasing it cannot reproduce the failure on an IPv4 interface with MTU 1500: the resulting 1480-byte IP packet still fits. If the real interface MTU is too large, create an isolated virtual interface or network namespace with a smaller MTU. For example, MTU 1300 with the default 1280-byte Initial reliably triggers EMSGSIZE.

For example, create a disposable Linux namespace instead of modifying the MTU of the machine's real interface:

sudo ip netns add hy-mtu-repro
sudo ip link add hy-mtu-host type veth peer name hy-mtu-client
sudo ip addr add 192.0.2.1/24 dev hy-mtu-host
sudo ip link set hy-mtu-host mtu 1300 up
sudo ip link set hy-mtu-client netns hy-mtu-repro
sudo ip netns exec hy-mtu-repro ip link set lo up
sudo ip netns exec hy-mtu-repro ip addr add 192.0.2.2/24 dev hy-mtu-client
sudo ip netns exec hy-mtu-repro ip link set hy-mtu-client mtu 1300 up
sudo ip netns exec hy-mtu-repro ip route get 192.0.2.1

Bind the test Hysteria server to 192.0.2.1:<port>, use 192.0.2.1:<port> in the client configuration, and run the unmodified client inside the namespace:

sudo tcpdump -ni hy-mtu-host udp port <port>
sudo ip netns exec hy-mtu-repro \
  strace -f -e trace=sendmsg,sendto,setsockopt \
  /absolute/path/to/hysteria -l debug -c /absolute/path/to/client.yaml client

After the test, remove only the disposable objects:

sudo ip link del hy-mtu-host 2>/dev/null || true
sudo ip netns del hy-mtu-repro 2>/dev/null || true

This keeps the host's real network MTU unchanged. Directly running ip link set dev <real-interface> mtu 1300 can also trigger the condition, but it can disrupt SSH, VPN, and ordinary connectivity and is unnecessary for this reproduction.

Then:

  1. Build Hysteria from commit 26a03b085c8def0224e7b468bbd59b695f6698f8. For the second variant, pass the chosen value through quic.Config.InitialPacketSize in core/client/client.go.

  2. Use a plain Hysteria server and disable Chrome parroting explicitly:

    server: <server-ip>:<port>
    auth: <redacted>
    obfs:
      type: plain
    tls:
      sni: <server-name>
    quic:
      disableChromeParrot: true
    socks5:
      listen: 127.0.0.1:1081
  3. Run the client at debug level. It waits and reports only timeout: no recent network activity.

  4. Trace socket syscalls while capturing UDP on the server. The client repeatedly reports:

    sendmsg(... destination=<server-ip>:<port>, iov_len=1280, ...) = -1 EMSGSIZE (Message too long)
    

    Ten such failures occurred in the attached run. Server tcpdump, started before the client, captured 0 packets, received 0 by filter, and dropped 0 in the kernel.

  5. As an A/B control for the original 1300-MTU reproduction, set disableChromeParrot: false without changing the binary, route, or server. The client connects. Its first two UDP payloads are 1250 bytes, producing IPv4 packets of 1278 bytes, and the server capture contains 24 packets.

Mode UDP payload Full IPv4 size Client result Server packets
disableChromeParrot: true 1280 1308 timeout; 10 local EMSGSIZE 0
disableChromeParrot: false 1250 1278 connected 24

Expected behavior

At minimum, the client should report the underlying local socket error and the attempted datagram size, for example: failed to send QUIC Initial: UDP payload 1280 exceeds local/path MTU: EMSGSIZE (message too long).

Preferably, Hysteria should also provide one or both of these recovery mechanisms:

  1. Expose a client option such as quic.initialPacketSize and pass it to the existing quic.Config.InitialPacketSize field. The QUIC implementation already clamps this field to its supported range.
  2. If an Initial write returns EMSGSIZE before the handshake, retry with a smaller Initial size, down to the QUIC minimum of 1200 bytes. Where available, the route MTU can provide a better first estimate after subtracting IP and UDP overhead.

RFC 9000 requires Initial-carrying client UDP datagrams to be at least 1200 bytes; 1280 is not a protocol minimum. It also notes that datagrams larger than 1200 risk loss before the peer's transport parameters are known and that implementations may conservatively account for tunnel overhead: RFC 9000 sections 14 and 14.1.

In the quic-go fork used by this build:

  • internal/protocol/params.go defines the default Initial size as 1280.
  • internal/protocol/protocol.go defines the minimum as 1200.
  • chrome_parrot.go selects 1250 for Chrome parroting.
  • config.go already supports Config.InitialPacketSize.
  • send_queue.go recognizes EMSGSIZE but deliberately does not return it from the send queue. With an oversized Initial, retransmissions continue until the handshake times out, and the useful local error is lost.
  • sys_conn.go calls setDF while wrapping a UDP socket, independently of the later DPLPMTUD setting.
  • sys_conn_df_linux.go enables IP_PMTUDISC_PROBE and explicitly documents the resulting sendto: message too long behavior.
  • sys_conn_df_windows.go similarly enables IP_DONTFRAGMENT=1 and recognizes WSAEMSGSIZE.

Hysteria's core/client/client.go constructs quic.Config without setting InitialPacketSize, and app/cmd/client.go has no user-facing field for it.

Chrome parroting happens to work around this particular MTU because 1250 + 8-byte UDP + 20-byte IPv4 = 1278. It is not a robust MTU control: with an MTU of 1270, the workaround would fail as well.

Logs

Device and Operating System

  • Samsung SM-S911B
  • Android 16, arm64-v8a
  • Direct CLI execution via ADB, not VPNService or a relay
  • LTE IPv4 route through rmnet_data0, cached MTU 1300
  • Hysteria v2.11.0-1-g26a03b0, exact commit 26a03b085c8def0224e7b468bbd59b695f6698f8
  • Go 1.26.4
  • github.com/apernet/quic-go v0.61.1-0.20260803204750-bc12a60f32da

Additional context

This issue is specifically about a local direct-socket send failure and opaque error reporting. The empty server capture proves that the oversized Initial datagrams did not reach the network in this reproduction; it should not be interpreted as evidence of QUIC/DPI blocking.

Setting disablePathMTUDiscovery is not equivalent to selecting a safe Initial size for this failure. The socket setup used here still requested no-fragment PMTU probing, and the failure happened on the first Initial writes, before a connection could discover a larger usable size.

If DF were disabled on IPv4, the kernel could fragment a 1308-byte packet for an MTU-1300 route, and such packets can be carried on a path that permits IPv4 fragmentation. This explains why DF=0 is a useful diagnostic control. It is not the preferred fix: RFC 9000 says UDP datagrams carrying QUIC packets must not be fragmented at the IP layer and that IPv4 DF must be set when possible. Hysteria should select a fitting Initial size and surface or recover from EMSGSIZE, rather than rely on fragmentation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions