From 6ac0c88084d06d5eef816178dddae8618b552655 Mon Sep 17 00:00:00 2001 From: SERGE BLCHV Date: Sun, 27 Sep 2026 01:03:43 +0500 Subject: [PATCH] fix(xray): hot-apply Hysteria client changes without replacing the inbound (#6606) * fix(xray): hot-apply Hysteria client changes without replacing the inbound diffInboundUsers only allowed per-user AlterInbound ops for vless, vmess and trojan. For a hysteria inbound every client add/remove/update became DelInbound + AddInbound: the UDP listener was recreated and all QUIC sessions of that inbound were lost. quic-go sends no stateless reset, so every connected client stalled until its idle timeout (30s by default) after each unrelated client mutation. XrayAPI.AddUser already builds a hysteria account and Xray-core's hysteria server implements AddUser/RemoveUser, so adding the protocol to userDiffableProtocols is sufficient. Co-Authored-By: Claude Fable 5.1 * test(xray): say which branch each drop-guard protocol takes With hysteria in userDiffableProtocols its dropped client reaches the guard through the per-user diff, so the test named for protocols the diff cannot handle no longer described its hysteria case. --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: MHSanaei --- internal/xray/hot_diff.go | 2 +- internal/xray/hot_diff_drops_users_test.go | 6 +++--- internal/xray/hot_diff_test.go | 25 ++++++++++++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/internal/xray/hot_diff.go b/internal/xray/hot_diff.go index 2d6e77ba3..b2319bb70 100644 --- a/internal/xray/hot_diff.go +++ b/internal/xray/hot_diff.go @@ -221,7 +221,7 @@ func droppedClients(oldIb, newIb *InboundConfig) []UserOp { return dropped } -var userDiffableProtocols = map[string]struct{}{"vless": {}, "vmess": {}, "trojan": {}} +var userDiffableProtocols = map[string]struct{}{"vless": {}, "vmess": {}, "trojan": {}, "hysteria": {}} // diffInboundUsers emits per-user AlterInbound ops when two same-tag inbounds // differ only in settings.clients, so the handler (and its listener) survives. diff --git a/internal/xray/hot_diff_drops_users_test.go b/internal/xray/hot_diff_drops_users_test.go index c192f55f2..283421853 100644 --- a/internal/xray/hot_diff_drops_users_test.go +++ b/internal/xray/hot_diff_drops_users_test.go @@ -16,9 +16,9 @@ func hotConfigWithClients(clients string) *Config { return cfg } -// diffInboundUsers refuses shadowsocks and hysteria, so their dropped clients -// reach the guard through the inbound instead of through a per-user op. -func TestHotDiffDropsUsersOnProtocolsItCannotDiff(t *testing.T) { +// Shadowsocks reaches the drop guard through the inbound's settings.clients compare, +// hysteria through its per-user diff; either way a dropped client must be reported. +func TestHotDiffDropsUsersOnShadowsocksAndHysteria(t *testing.T) { for _, protocol := range []string{"shadowsocks", "hysteria"} { t.Run(protocol, func(t *testing.T) { withClients := func(clients string) *Config { diff --git a/internal/xray/hot_diff_test.go b/internal/xray/hot_diff_test.go index 15eb1f81b..9acaa46c4 100644 --- a/internal/xray/hot_diff_test.go +++ b/internal/xray/hot_diff_test.go @@ -511,3 +511,28 @@ func TestComputeHotDiff_NoauthSocksBridgeStaysHot(t *testing.T) { t.Fatalf("expected a plain remove+add for the changed bridge, got %+v", diff) } } + +// Replacing a hysteria handler closes the UDP listener its QUIC sessions share, +// and clients get no reset: they stall until their own idle timeout expires. +func TestComputeHotDiff_HysteriaClientOnlyChangeKeepsListener(t *testing.T) { + stream := json_util.RawMessage(`{"network":"hysteria","security":"tls","hysteriaSettings":{"version":2}}`) + oldCfg := makeHotConfig() + oldCfg.InboundConfigs[1].Protocol = "hysteria" + oldCfg.InboundConfigs[1].StreamSettings = stream + oldCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"version":2,"clients":[{"email":"a","auth":"auth-a"}]}`) + newCfg := makeHotConfig() + newCfg.InboundConfigs[1].Protocol = "hysteria" + newCfg.InboundConfigs[1].StreamSettings = stream + newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"version":2,"clients":[{"email":"a","auth":"auth-a"},{"email":"b","auth":"auth-b"}]}`) + + diff, ok := ComputeHotDiff(oldCfg, newCfg) + if !ok { + t.Fatal("client-only change must be hot-appliable") + } + if len(diff.RemovedInboundTags) != 0 || len(diff.AddedInbounds) != 0 { + t.Fatalf("hysteria client-only change must not replace the handler, got removed=%v added=%d", diff.RemovedInboundTags, len(diff.AddedInbounds)) + } + if len(diff.RemovedUsers) != 0 || len(diff.AddedUsers) != 1 || diff.AddedUsers[0].Email != "b" || diff.AddedUsers[0].Protocol != "hysteria" { + t.Fatalf("expected a single AddUser op for b, got %+v", diff) + } +}