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) + } +}