← 목록

Our Hysteria2 connections died after every upload speed test. The fix was 37 lines.

devto 2026-10-16 원문 보기 ↗


We build Colitu, a VPN with open-source apps for Android, iOS, Windows, Linux and a browser extension. The apps carry traffic over several transports. Hysteria2 is a QUIC-based one that our apps prefer on many networks, because it holds up well on lossy mobile links.

The apps also check their own connection. Every 5 seconds they send a small request through the tunnel. If three checks in a row get no answer within 4 seconds, the transport is declared dead, and traffic moves to a TCP spare that is already connected. Users stay online. But a transport that keeps getting declared dead is a bug, even if nobody notices.

How we noticed

We were testing port hopping for Hysteria2 on an Android phone on home Wi-Fi. With port hopping, the client moves to a new UDP port every ~30 seconds, so networks that slow down long-running UDP flows have a harder time doing it.

Hopping itself worked. We saw five hops, and the server's replies arrived on every new port.

Then we ran a speed test. Hysteria2 carried traffic for about 50–60 seconds and then stopped for good. The health check gave up and traffic went to the spare. It happened every time.

We repeated the test on a server without port hopping and got the same result. So hopping was not the cause; it was just the test that showed the bug.

What the packets said

We captured traffic on the phone. After the stall:

A connection that is alive but will not send stream data points at flow control, not at the network.

What we ruled out first

We tested each of these instead of assuming:

Suspect How we tested it Result
ISP shaping UDP read the capture packets kept flowing both ways; the client stopped sending
Probe starvation under load watched after the load ended the stall lasted for minutes after the load was gone
Congestion control A/B build with Reno instead of BBR died the same way
Xray's "Chrome parrot" QUIC mode A/B build with it disabled died the same way
Stream limit counted streams ~90 open against a limit of 1,024
Many connections in general 1,400 short sequential connections all fine

One more thing we tried: a bigger flow-control window (64 MB per connection, 4 MB per stream). It did not fix anything. It only delayed the stall: with 6 parallel streams it failed in round 3, with 20 streams immediately. That was the strongest hint about where to look.

Reproducing it on one PC

To get rid of the network entirely, we built the official Hysteria server v2.11.0 from source and ran it on loopback with an Xray 26.10.10 client. A small Python harness opened parallel uploads through the client, aborted them with a TCP reset, and then checked whether new requests still worked.

The only thing we changed between runs was how the upload's destination behaved:

Target mode What the destination does Connection stalled?
fast reads everything no
slow reads 1 MB/s no
upclose closes the connection first no
stall stops reading but keeps the connection open yes: 0 of 5 new requests worked, no recovery in 30 s

An upload speed test behaves exactly like stall. The test server stops reading once it has measured enough, while the browser's upload request is still open.

The mechanism

QUIC limits how much unread data a peer may have in flight, per stream and for the whole connection. Hysteria's defaults are 8 MB per stream and 20 MB per connection. Received bytes count against both windows until the application actually reads them.

Here is what happens:

client (Xray)                 Hysteria server                 target
     |  upload on stream A  ->  |  io.Copy(target, stream A) -> |
     |                          |                               |  stops reading,
     |                          |  target's TCP buffer full     |  keeps conn open
     |                          |  write to target BLOCKS       |
     |                          |  -> server stops reading A    |
     |  A's unread bytes keep counting against the connection window
     |  (a few streams like A fill all 20 MB)
     |
     |  new request on stream B: blocked, no connection credit left
     |  writer for A: blocked on the same window, so the client
     |  never gets to cancel A either
     |
     |  UDP datagrams: not flow-controlled -> still flowing
  1. The client uploads through a Hysteria2 stream. The server dials the target over TCP and copies the stream into it.
  2. The target stops reading but does not close. Its receive buffer fills, the server's Write into the target blocks, and the server stops reading from the stream.
  3. The stream's unread upload data keeps occupying the connection-level receive window. A few such streams use up the whole window. From then on the client cannot send on any stream of the connection, including brand-new ones.
  4. The client never gives the stream up, because its own writer is blocked on the same window. On an instrumented server, the "stream given up" signal only arrived when we killed the client process.
  5. UDP datagrams are not covered by stream flow control, which is why DNS kept working and the connection looked healthy.

Interesting detail: we first suspected the server never calls CancelRead on dead streams. That was wrong. The stream's Close already does that. The real problem is that nothing ever reaches that Close, because the copy into the target is stuck in a blocking Write.

The fix

The server cannot tell a stalled target from a slow one by looking at the stream. It can tell by progress: a single write that makes no progress for a long time.

So the patch does two things:

  1. Every write into a TCP target gets a fresh 10-second budget. If one write makes no progress for 10 s, the target is closed. The normal cleanup then closes the stream, which releases its share of the connection window.
  2. When the client gives the stream up, the target is closed immediately, so both copy directions end even when the target is silent.

A slow target that keeps moving is never cut off, because every write starts a new timer. Only a target that has stopped accepting data is closed.

This is the version we sent upstream in PR #1703:

if h.config.TCPStallTimeout > 0 {
    guard := newStallGuardConn(tConn, h.config.TCPStallTimeout)
    tConn = guard
    stop := context.AfterFunc(stream.Context(), func() { _ = guard.Close() })
    defer stop()
}
// stallGuardConn closes the wrapped connection when a single Write makes no
// progress for timeout, and makes Close idempotent. Every Write gets a fresh
// timeout, so a slow target that keeps accepting data is never closed.
type stallGuardConn struct {
    net.Conn
    timeout   time.Duration
    timer     *time.Timer
    closeOnce sync.Once
    closeErr  error
}

func newStallGuardConn(conn net.Conn, timeout time.Duration) *stallGuardConn {
    c := &stallGuardConn{Conn: conn, timeout: timeout}
    c.timer = time.AfterFunc(timeout, func() { _ = c.Close() })
    c.timer.Stop()
    return c
}

func (c *stallGuardConn) Write(p []byte) (int, error) {
    c.timer.Reset(c.timeout)
    n, err := c.Conn.Write(p)
    c.timer.Stop()
    return n, err
}

func (c *stallGuardConn) Close() error {
    c.closeOnce.Do(func() { c.closeErr = c.Conn.Close() })
    return c.closeErr
}

Our production build (v2.11.0-colitu1) does the same thing with SetWriteDeadline before each write. For the upstream version we made three changes:

The PR also adds an integration test. Four streams upload into targets that never read (one end of a net.Pipe()), then a new request to an echo server must complete on the same connection. Without the fix it times out after 10 s. With it, it passes in about 3.5 s, with the stall timeout set to 2 s for the test.

One gotcha while writing that test: with one stalled stream it passed even without the fix. One stream can only fill its own 8 MB window, not the 20 MB connection window. It takes three or four.

Results

In the loopback lab:

Server Target Streams New requests work again after
official v2.11.0 stall 6 never (> 30 s)
patched stall 6 7.8 s
patched stall 20 7.8 s
patched fast / slow / upclose — 0.0 s (no regression)

On the phone, we ran four speed tests in a row over 160 seconds on a patched server in Berlin. Hysteria2 was never declared dead. Before the patch it died at ~60 s every time, both in Berlin and on a server in Vilnius.

Rollout without an app update

Because the bug was on the server, the fix did not need a new app release. Our node image now builds Hysteria from the official source tag with the patch applied. We rolled it out the same night, one server at a time. Each server's VPN service restarted for about 2 seconds. Android, iOS, Windows and Linux users all got the fix at once.

Upstream

The bug is in the open-source Hysteria server, and its client-side half is in Xray, so every service that runs them is affected, not only ours. We reported both:

Limitations

Takeaways

If you run Hysteria2 and your users see TCP die after big uploads while DNS keeps working, this is probably it. The PR is small and easy to apply to v2.11.0.

Full report with setup details: lab.colitu.com/research/hysteria2-stall-after-upload. Our apps are open source at github.com/colitu. Questions and corrections are welcome in the comments.