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.
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.
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.
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.
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.
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
Write into the target blocks, and the server stops reading from the stream.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 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:
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:
Close and works with any net.Conn an outbound returns;server.Config.TCPStallTimeout (default 10 s, negative disables it);Close is idempotent, because two paths can now close the target.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.
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.
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.
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:
CancelWrite, i.e. RESET_STREAM) would release the window even on servers without the fix. A contributor has since traced it on Xray's main branch, reproduced the stall on loopback, and plans a CancelWrite-based fix.fast, slow, upclose, stall) narrowed this down faster than any amount of packet reading.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.