You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
TUIC & hysteria2 connection stays alive for a while after traffic quota is exhausted (VLESS closes immediately) #175
Describe the bug
When a client's data/traffic quota (e.g. 10 MB) is fully consumed:
VLESS: the user is disabled in the UI, and the VPN connection closes almost immediately.
TUIC: the user is disabled in the UI at the same time, but the VPN connection stays alive and usable for a noticeable period afterward before it eventually drops.
Root cause (as far as I can tell)
ConnTracker (core/tracker_conn.go) tracks one ConnectionInfo per routed stream (RoutedConnection / RoutedPacketConnection), wrapping either net.Conn (TCP) or network.PacketConn (UDP), and closes those when quota enforcement triggers CloseConnByInboundUsers (or similar).
For VLESS, each tracked entry maps 1:1 to the client's actual TCP socket, so closing it kills the real transport connection immediately.
For TUIC, the client authenticates once over a single QUIC connection, and every subsequent relayed stream (Connect/Packet) multiplexes over that already-authenticated session without re-authenticating. ConnTracker only tracks/closes these individual streams, not the underlying QUIC transport connection — so the client can keep opening new streams on the same session right after enforcement runs. The connection only actually dies later, likely once the QUIC session itself times out (idle timeout / heartbeat) or a later periodic enforcement pass happens to catch it, rather than being cut off deterministically at quota-exhaustion time.
To Reproduce
Steps to reproduce the behavior:
Set a 10 MB data limit on a user.
Connect via VLESS, consume the 10 MB. Observe: UI shows disabled, VPN connection closes almost immediately.
Repeat with TUIC for the same limit. Observe: UI shows disabled at the same time, but the VPN connection keeps working for some time before it eventually drops.
Observe the delayed disconnect on TUIC compared to VLESS.
Expected behavior
Once quota enforcement disables a user, all active connections — including TUIC — should be cut off promptly and deterministically, not just after an unpredictable delay.
Confirmed, and your root cause analysis is exactly right. Thanks for writing it up that carefully — it saved a lot of digging.
Two things to add.
The scope is wider than TUIC. The pattern is "one authenticated transport carrying many routed connections", so it also covers hysteria v1 and anytls, and any vless/vmess/trojan/shadowsocks inbound with multiplex enabled. VLESS closes immediately in your test only because plain VLESS over TCP is 1:1 — the tracked connection is the client's socket.
The delay is not just unpredictable, it can be unbounded. For TUIC the server sends a heartbeat datagram every heartbeat interval (10s by default), which is ack-eliciting, so the QUIC idle timeout never fires on its own. The session ends when the client decides to reconnect, not on any server-side schedule.
Why the panel cannot fix this on its own.ConnTracker.CloseConnByInboundUsers closes the relayed streams, and the client just opens new ones on the same session. To cut the session we would have to close the underlying QUIC connection, and there is no way to reach it: sing-quic's Service exports only UpdateUsers (which replaces a map and touches no live session), Start and Close (the whole listener); serverSession is unexported, and the *quic.Stream handed to the inbound gives no handle on its *quic.Conn. The only thing available today is restarting the entire inbound, which disconnects every other user on it — that is precisely what the in-place user update was introduced to avoid.
So the fix belongs upstream. Patches are filed, each with a regression test that fails without the change:
Both make UpdateUsers close the sessions of users that are gone from the new table. Once they land, 2s-ui picks the fix up by bumping the dependency and re-copying core/protocol/; enforcement then becomes immediate and deterministic for every protocol, which is the behaviour you asked for.
An interim panel-side workaround was considered — refusing every new stream from a disabled user, so the tunnel carries nothing even though the session stays up. It was rejected: it does not actually cut the connection (the client still shows "connected"), and every refused stream still costs one real outbound dial, so a disabled client retrying in a loop keeps the server opening connections to third parties. Waiting for the proper fix is the better trade.
Activity
shenaba commentedon Sep 11, 2026
Confirmed, and your root cause analysis is exactly right. Thanks for writing it up that carefully — it saved a lot of digging.
Two things to add.
The scope is wider than TUIC. The pattern is "one authenticated transport carrying many routed connections", so it also covers hysteria v1 and anytls, and any vless/vmess/trojan/shadowsocks inbound with
multiplexenabled. VLESS closes immediately in your test only because plain VLESS over TCP is 1:1 — the tracked connection is the client's socket.The delay is not just unpredictable, it can be unbounded. For TUIC the server sends a heartbeat datagram every
heartbeatinterval (10s by default), which is ack-eliciting, so the QUIC idle timeout never fires on its own. The session ends when the client decides to reconnect, not on any server-side schedule.Why the panel cannot fix this on its own.
ConnTracker.CloseConnByInboundUserscloses the relayed streams, and the client just opens new ones on the same session. To cut the session we would have to close the underlying QUIC connection, and there is no way to reach it:sing-quic'sServiceexports onlyUpdateUsers(which replaces a map and touches no live session),StartandClose(the whole listener);serverSessionis unexported, and the*quic.Streamhanded to the inbound gives no handle on its*quic.Conn. The only thing available today is restarting the entire inbound, which disconnects every other user on it — that is precisely what the in-place user update was introduced to avoid.So the fix belongs upstream. Patches are filed, each with a regression test that fails without the change:
Both make
UpdateUsersclose the sessions of users that are gone from the new table. Once they land, 2s-ui picks the fix up by bumping the dependency and re-copyingcore/protocol/; enforcement then becomes immediate and deterministic for every protocol, which is the behaviour you asked for.An interim panel-side workaround was considered — refusing every new stream from a disabled user, so the tunnel carries nothing even though the session stays up. It was rejected: it does not actually cut the connection (the client still shows "connected"), and every refused stream still costs one real outbound dial, so a disabled client retrying in a loop keeps the server opening connections to third parties. Waiting for the proper fix is the better trade.
Leaving this open to track the dependency bump.
fix(core): rebuild the inbound when a removed user's session cannot b…
fix(core): disconnect the live session of a user who was just removed (…