diff --git a/adapter/network.go b/adapter/network.go index 2ef2c5d2d..aeb9171a3 100644 --- a/adapter/network.go +++ b/adapter/network.go @@ -36,6 +36,7 @@ type NetworkManager interface { UpdateWIFIState(ctx context.Context) ResetNetwork(ctx context.Context) FlushSystemDNSCache() + ReleaseMemory(ctx context.Context) } type NetworkOptions struct { diff --git a/common/dialer/default.go b/common/dialer/default.go index 91770da22..da80ae69d 100644 --- a/common/dialer/default.go +++ b/common/dialer/default.go @@ -62,7 +62,7 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial var ( dialer net.Dialer - listener net.ListenConfig + listenConfig net.ListenConfig interfaceFinder control.InterfaceFinder bindInterface string networkStrategy *C.NetworkStrategy @@ -77,6 +77,9 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial } else { interfaceFinder = control.NewDefaultInterfaceFinder() } + socketBufferFunc := control.UDPSocketBuffer(listener.UDPSocketBufferSize()) + dialer.Control = control.Append(dialer.Control, socketBufferFunc) + listenConfig.Control = control.Append(listenConfig.Control, socketBufferFunc) if options.BindInterface != "" { if !(C.IsLinux || C.IsDarwin || C.IsWindows) { return nil, E.New("`bind_interface` is only supported on Linux, macOS and Windows") @@ -88,7 +91,7 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial } } dialer.Control = control.Append(dialer.Control, bindFunc) - listener.Control = control.Append(listener.Control, bindFunc) + listenConfig.Control = control.Append(listenConfig.Control, bindFunc) bindInterface = options.BindInterface } if options.RoutingMark > 0 { @@ -96,7 +99,7 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial return nil, E.New("`routing_mark` is only supported on Linux") } dialer.Control = control.Append(dialer.Control, setMarkWrapper(networkManager, uint32(options.RoutingMark), false)) - listener.Control = control.Append(listener.Control, setMarkWrapper(networkManager, uint32(options.RoutingMark), false)) + listenConfig.Control = control.Append(listenConfig.Control, setMarkWrapper(networkManager, uint32(options.RoutingMark), false)) } disableDefaultBind := options.BindInterface != "" || options.Inet4BindAddress != nil || options.Inet6BindAddress != nil if disableDefaultBind || options.TCPFastOpen { @@ -110,7 +113,7 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial if defaultOptions.BindInterface != "" && !disableDefaultBind && options.NetNs == "" { bindFunc := control.BindToInterface(networkManager.InterfaceFinder(), defaultOptions.BindInterface, -1) dialer.Control = control.Append(dialer.Control, bindFunc) - listener.Control = control.Append(listener.Control, bindFunc) + listenConfig.Control = control.Append(listenConfig.Control, bindFunc) bindInterface = defaultOptions.BindInterface } else if networkManager.AutoDetectInterface() && !disableDefaultBind && options.NetNs == "" { if platformInterface != nil && platformInterface.UsePlatformNetworkInterfaces() { @@ -132,7 +135,7 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial } bindFunc := networkManager.ProtectFunc() dialer.Control = control.Append(dialer.Control, bindFunc) - listener.Control = control.Append(listener.Control, bindFunc) + listenConfig.Control = control.Append(listenConfig.Control, bindFunc) } else { bindFunc := networkManager.AutoDetectInterfaceFunc() dialer.Control = control.Append(dialer.Control, bindFunc) @@ -141,20 +144,20 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial } if options.RoutingMark == 0 && defaultOptions.RoutingMark != 0 { dialer.Control = control.Append(dialer.Control, setMarkWrapper(networkManager, defaultOptions.RoutingMark, true)) - listener.Control = control.Append(listener.Control, setMarkWrapper(networkManager, defaultOptions.RoutingMark, true)) + listenConfig.Control = control.Append(listenConfig.Control, setMarkWrapper(networkManager, defaultOptions.RoutingMark, true)) } } if networkManager != nil { markFunc := networkManager.AutoRedirectOutputMarkFunc() dialer.Control = control.Append(dialer.Control, markFunc) - listener.Control = control.Append(listener.Control, markFunc) + listenConfig.Control = control.Append(listenConfig.Control, markFunc) } if options.ReuseAddr { - listener.Control = control.Append(listener.Control, control.ReuseAddr()) + listenConfig.Control = control.Append(listenConfig.Control, control.ReuseAddr()) } if options.ProtectPath != "" { dialer.Control = control.Append(dialer.Control, control.ProtectPath(options.ProtectPath)) - listener.Control = control.Append(listener.Control, control.ProtectPath(options.ProtectPath)) + listenConfig.Control = control.Append(listenConfig.Control, control.ProtectPath(options.ProtectPath)) } if options.BindAddressNoPort { if !C.IsLinux { @@ -200,10 +203,10 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial } if udpFragment { dialer.Control = control.Append(dialer.Control, control.EnableUDPFragment()) - listener.Control = control.Append(listener.Control, control.EnableUDPFragment()) + listenConfig.Control = control.Append(listenConfig.Control, control.EnableUDPFragment()) } else { dialer.Control = control.Append(dialer.Control, control.DisableUDPFragment()) - listener.Control = control.Append(listener.Control, control.DisableUDPFragment()) + listenConfig.Control = control.Append(listenConfig.Control, control.DisableUDPFragment()) } var ( dialer4 = dialer @@ -243,7 +246,7 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial dialer6: tcpDialer6, udpDialer4: udpDialer4, udpDialer6: udpDialer6, - udpListener: listener, + udpListener: listenConfig, udpAddr4: udpAddr4, udpAddr6: udpAddr6, netns: options.NetNs, diff --git a/common/listener/listener_udp.go b/common/listener/listener_udp.go index 2d54a1c47..e2441e87d 100644 --- a/common/listener/listener_udp.go +++ b/common/listener/listener_udp.go @@ -88,6 +88,7 @@ func (l *Listener) DialContext(dialer net.Dialer, ctx context.Context, network s func (l *Listener) ListenPacket(listenConfig net.ListenConfig, ctx context.Context, network string, address string) (net.PacketConn, error) { return ListenNetworkNamespace[net.PacketConn](l.ctx, l.listenOptions.NetNs, func() (net.PacketConn, error) { + listenConfig.Control = control.Append(listenConfig.Control, control.UDPSocketBuffer(UDPSocketBufferSize())) if l.listenOptions.BindInterface != "" { listenConfig.Control = control.Append(listenConfig.Control, l.bindInterfaceFunc()) } diff --git a/common/listener/socket_buffer_bsd.go b/common/listener/socket_buffer_bsd.go new file mode 100644 index 000000000..8ba64def6 --- /dev/null +++ b/common/listener/socket_buffer_bsd.go @@ -0,0 +1,20 @@ +//go:build darwin || dragonfly || freebsd || netbsd || openbsd + +package listener + +import ( + "sync" + + C "github.com/sagernet/sing-box/constant" + + "golang.org/x/sys/unix" +) + +var UDPSocketBufferSize = sync.OnceValue(func() int { + maxSocketBuffer, err := unix.SysctlUint32("kern.ipc.maxsockbuf") + if err != nil { + return C.UDPSocketBufferSize + } + // xnu sbreserve() before xnu-11215 rejects cc > sb_max * MCLBYTES / (MSIZE + MCLBYTES) with ENOBUFS instead of clamping. + return int(uint64(maxSocketBuffer) * 2048 / (256 + 2048)) +}) diff --git a/common/listener/socket_buffer_other.go b/common/listener/socket_buffer_other.go new file mode 100644 index 000000000..219c23db5 --- /dev/null +++ b/common/listener/socket_buffer_other.go @@ -0,0 +1,11 @@ +//go:build !(darwin || dragonfly || freebsd || netbsd || openbsd) + +package listener + +import ( + C "github.com/sagernet/sing-box/constant" +) + +func UDPSocketBufferSize() int { + return C.UDPSocketBufferSize +} diff --git a/constant/network.go b/constant/network.go index 88a1dd815..7eeda1ca1 100644 --- a/constant/network.go +++ b/constant/network.go @@ -5,6 +5,8 @@ import ( F "github.com/sagernet/sing/common/format" ) +const UDPSocketBufferSize = 8 << 20 + type InterfaceType uint8 const ( diff --git a/docs/configuration/inbound/tun.md b/docs/configuration/inbound/tun.md index a3480e2c5..ec2b77124 100644 --- a/docs/configuration/inbound/tun.md +++ b/docs/configuration/inbound/tun.md @@ -4,7 +4,9 @@ icon: material/new-box !!! quote "Changes in sing-box 1.15.0" - :material-plus: [auto_redirect_tproxy_mark](#auto_redirect_tproxy_mark) + :material-plus: [auto_redirect_tproxy_mark](#auto_redirect_tproxy_mark) + :material-plus: [multi_queue](#multi_queue) + :material-delete-clock: [stack](#stack) !!! quote "Changes in sing-box 1.14.0" @@ -123,7 +125,7 @@ icon: material/new-box ... // UDP NAT Fields - "stack": "system", + "multi_queue": false, "include_interface": [ "lan0" ], @@ -168,6 +170,7 @@ icon: material/new-box } }, // Deprecated + "stack": "system", "gso": false, "inet4_address": [ "172.19.0.1/30" @@ -563,6 +566,17 @@ to customize the mapping and filtering behavior. #### stack +!!! failure "Deprecated in sing-box 1.15.0" + + `stack` is deprecated and will be removed in sing-box 1.17.0. + Remove the `stack` option to use sing-tun's own TCP/IP stack. + See [Migration](/migration/#migrate-tun-stack). + +!!! quote "Changes in sing-box 1.15.0" + + Since 1.15.0, sing-tun uses its own TCP/IP stack, with substantial improvements over all previous + implementations in peak performance, energy efficiency, and memory usage. + !!! quote "Changes in sing-box 1.8.0" :material-delete-alert: The legacy LWIP stack has been deprecated and removed. @@ -575,7 +589,13 @@ TCP/IP stack. | `gvisor` | Perform L3 to L4 translation using [gVisor](https://github.com/google/gvisor)'s virtual network stack | | `mixed` | Mixed `system` TCP stack and `gvisor` UDP stack | -Defaults to the `mixed` stack if the gVisor build tag is enabled, otherwise defaults to the `system` stack. +#### multi_queue + +!!! quote "" + + Only supported on Linux, and requires sing-tun's own TCP/IP stack. + +Enable multi-queue support based on `IFF_MULTI_QUEUE`, allowing throughput to scale with the number of CPU cores. #### include_interface diff --git a/docs/configuration/inbound/tun.zh.md b/docs/configuration/inbound/tun.zh.md index e064b51e6..a7b6fa13a 100644 --- a/docs/configuration/inbound/tun.zh.md +++ b/docs/configuration/inbound/tun.zh.md @@ -4,7 +4,9 @@ icon: material/new-box !!! quote "sing-box 1.15.0 中的更改" - :material-plus: [auto_redirect_tproxy_mark](#auto_redirect_tproxy_mark) + :material-plus: [auto_redirect_tproxy_mark](#auto_redirect_tproxy_mark) + :material-plus: [multi_queue](#multi_queue) + :material-delete-clock: [stack](#stack) !!! quote "sing-box 1.14.0 中的更改" @@ -124,7 +126,7 @@ icon: material/new-box ... // UDP NAT 字段 - "stack": "system", + "multi_queue": false, "include_interface": [ "lan0" ], @@ -170,6 +172,7 @@ icon: material/new-box }, // 已弃用 + "stack": "system", "gso": false, "inet4_address": [ "172.19.0.1/30" @@ -550,6 +553,16 @@ TUN 接口上 DNS 的处理方式。 #### stack +!!! failure "已在 sing-box 1.15.0 废弃" + + `stack` 已废弃,并将在 sing-box 1.17.0 中被移除。 + 移除 `stack` 参数以使用 sing-tun 自有的 TCP/IP stack。 + 参阅[迁移指南](/zh/migration/#迁移-tun-stack)。 + +!!! quote "sing-box 1.15.0 中的更改" + + 自 1.15.0 起,sing-tun 使用自有 TCP/IP stack,极限性能、能效以及内存占用均大幅领先于所有旧实现。 + !!! quote "sing-box 1.8.0 中的更改" :material-delete-alert: 旧的 LWIP 栈已被弃用并移除。 @@ -562,7 +575,13 @@ TCP/IP 栈。 | `gvisor` | 基于 [gVisor](https://github.com/google/gvisor) 虚拟网络栈执行 L3 到 L4 转换 | | `mixed` | 混合 `system` TCP 栈与 `gvisor` UDP 栈 | -默认使用 `mixed` 栈如果 gVisor 构建标记已启用,否则默认使用 `system` 栈。 +#### multi_queue + +!!! quote "" + + 仅在 Linux 下被支持,且需要使用 sing-tun 自有的 TCP/IP stack。 + +启用基于 `IFF_MULTI_QUEUE` 的多队列支持,使吞吐量能够随 CPU 核心数量扩展。 #### include_interface diff --git a/docs/deprecated.md b/docs/deprecated.md index 96470afda..32a1d40a6 100644 --- a/docs/deprecated.md +++ b/docs/deprecated.md @@ -4,6 +4,16 @@ icon: material/delete-alert # Deprecated Feature List +## 1.15.0 + +#### TUN `stack` option + +The TUN `stack` option is deprecated and will be removed in sing-box 1.17.0. + +Since 1.15.0, sing-tun uses its own TCP/IP stack, with substantial improvements over all previous +implementations in peak performance, energy efficiency, and memory usage. +Remove the `stack` option to use it, see [Migration](/migration/#migrate-tun-stack). + ## 1.14.0 #### Legacy `download_detour` remote rule-set option diff --git a/docs/deprecated.zh.md b/docs/deprecated.zh.md index 2b96258c9..8b7e526e0 100644 --- a/docs/deprecated.zh.md +++ b/docs/deprecated.zh.md @@ -4,6 +4,15 @@ icon: material/delete-alert # 废弃功能列表 +## 1.15.0 + +#### TUN `stack` 参数 + +TUN 的 `stack` 参数已废弃,并将在 sing-box 1.17.0 中被移除。 + +自 1.15.0 起,sing-tun 使用自有 TCP/IP stack,极限性能、能效以及内存占用均大幅领先于所有旧实现。 +移除 `stack` 参数以使用,参阅[迁移指南](/zh/migration/#迁移-tun-stack)。 + ## 1.14.0 #### 旧版远程规则集 `download_detour` 选项 diff --git a/docs/manual/misc/tunnelvision.md b/docs/manual/misc/tunnelvision.md index 0d6caf76d..6f9efff70 100644 --- a/docs/manual/misc/tunnelvision.md +++ b/docs/manual/misc/tunnelvision.md @@ -20,8 +20,9 @@ Android does not handle DHCP option 121 and is not affected. Update [sing-box graphical client](/clients/apple/#download) to `1.9.0-rc.16` or newer, then enable `includeAllNetworks` in `Settings` — `Packet Tunnel` and you will be unaffected. -Note: when `includeAllNetworks` is enabled, the default TUN stack is changed to `gvisor`, -and the `system` and `mixed` stacks are not available. +Since sing-box 1.15.0, sing-tun's own TCP/IP stack supports `includeAllNetworks`. +Remove the `stack` option to use it. The legacy `system` and `mixed` stacks are not available +when `includeAllNetworks` is enabled. ### Linux diff --git a/docs/manual/proxy/client.md b/docs/manual/proxy/client.md index b8c26cf9d..7f3cd9de2 100644 --- a/docs/manual/proxy/client.md +++ b/docs/manual/proxy/client.md @@ -62,11 +62,9 @@ flowchart TB tun[TUN interface] windows -. route .-> tun linux -. iproute2 route/rule .-> tun - tun --> gvisor[gVisor TUN stack] - tun --> system[system TUN stack] + tun --> stack[sing-tun TCP/IP stack] assemble([L3 to L4 assemble]) - gvisor --> assemble - system --> assemble + stack --> assemble assemble --> conn[TCP and UDP connections] conn --> router[sing-box Router] router --> direct[Direct outbound] diff --git a/docs/migration.md b/docs/migration.md index 83e816564..5984a5daa 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -2,6 +2,43 @@ icon: material/arrange-bring-forward --- +## 1.15.0 + +### Migrate TUN stack + +Since 1.15.0, sing-tun uses its own TCP/IP stack, with substantial improvements over all previous +implementations in peak performance, energy efficiency, and memory usage. +Remove the `stack` option to use it. + +The `stack` option is deprecated in sing-box 1.15.0 and will be removed in sing-box 1.17.0. + +=== ":material-card-remove: Deprecated" + + ```json + { + "inbounds": [ + { + "type": "tun", + "address": ["172.18.0.1/30"], + "stack": "system" + } + ] + } + ``` + +=== ":material-card-multiple: Migrated" + + ```json + { + "inbounds": [ + { + "type": "tun", + "address": ["172.18.0.1/30"] + } + ] + } + ``` + ## 1.14.0 ### Migrate the macOS standalone client data diff --git a/docs/migration.zh.md b/docs/migration.zh.md index 508c80a74..788ed96f5 100644 --- a/docs/migration.zh.md +++ b/docs/migration.zh.md @@ -2,6 +2,42 @@ icon: material/arrange-bring-forward --- +## 1.15.0 + +### 迁移 TUN stack + +自 1.15.0 起,sing-tun 使用自有 TCP/IP stack,极限性能、能效以及内存占用均大幅领先于所有旧实现。 +移除 `stack` 参数以使用。 + +`stack` 参数自 sing-box 1.15.0 起废弃,并将在 sing-box 1.17.0 中被移除。 + +=== ":material-card-remove: 已废弃" + + ```json + { + "inbounds": [ + { + "type": "tun", + "address": ["172.18.0.1/30"], + "stack": "system" + } + ] + } + ``` + +=== ":material-card-multiple: 已迁移" + + ```json + { + "inbounds": [ + { + "type": "tun", + "address": ["172.18.0.1/30"] + } + ] + } + ``` + ## 1.14.0 ### 迁移 macOS standalone 客户端数据 diff --git a/docs/schema.json b/docs/schema.json index d2e96e3e8..aa0c5056e 100644 --- a/docs/schema.json +++ b/docs/schema.json @@ -7889,13 +7889,8 @@ "minimum": 0, "maximum": 4294967295 }, - "stack": { - "type": "string", - "enum": [ - "system", - "gvisor", - "mixed" - ] + "multi_queue": { + "type": "boolean" }, "platform": { "$ref": "#/$defs/TunPlatformOptions" diff --git a/experimental/deprecated/constants.go b/experimental/deprecated/constants.go index c68bb69b9..1beb018e5 100644 --- a/experimental/deprecated/constants.go +++ b/experimental/deprecated/constants.go @@ -158,6 +158,15 @@ var OptionImplicitDefaultHTTPClient = Note{ EnvName: "IMPLICIT_DEFAULT_HTTP_CLIENT", } +var OptionTunStack = Note{ + Name: "tun-stack", + Description: "`stack` option in TUN", + DeprecatedVersion: "1.15.0", + ScheduledVersion: "1.17.0", + EnvName: "TUN_STACK", + MigrationLink: "https://sing-box.sagernet.org/migration/#migrate-tun-stack", +} + var Options = []Note{ OptionOutboundDNSRuleItem, OptionMissingDomainResolver, @@ -170,4 +179,5 @@ var Options = []Note{ OptionIndependentDNSCache, OptionStoreRDRC, OptionImplicitDefaultHTTPClient, + OptionTunStack, } diff --git a/experimental/libbox/setup.go b/experimental/libbox/setup.go index 07f16734f..3c8a00d77 100644 --- a/experimental/libbox/setup.go +++ b/experimental/libbox/setup.go @@ -103,7 +103,7 @@ func ReloadSetupOptions(options *SetupOptions) { debug.SetGCPercent(oomkiller.DefaultAppleNetworkExtensionGCPercent) } if sOOMMemoryLimit > 0 { - debug.SetMemoryLimit(sOOMMemoryLimit * 4 / 5) + debug.SetMemoryLimit(int64(oomkiller.RuntimeMemoryLimit(uint64(sOOMMemoryLimit)))) } else { debug.SetMemoryLimit(math.MaxInt64) } diff --git a/option/tun.go b/option/tun.go index aa9d11997..2a20c9bb0 100644 --- a/option/tun.go +++ b/option/tun.go @@ -50,10 +50,12 @@ type TunInboundOptions struct { UDPMapping UDPNATBehavior `json:"udp_mapping,omitempty"` UDPFiltering UDPNATBehavior `json:"udp_filtering,omitempty"` UDPNATMax uint32 `json:"udp_nat_max,omitempty"` - Stack string `json:"stack,omitempty" enum:"system,gvisor,mixed"` + MultiQueue bool `json:"multi_queue,omitempty"` Platform *TunPlatformOptions `json:"platform,omitempty"` InboundOptions + Stack string `json:"stack,omitempty" schema:"omit"` + // Deprecated: removed GSO bool `json:"gso,omitempty" schema:"omit"` // Deprecated: merged to Address diff --git a/protocol/tun/inbound.go b/protocol/tun/inbound.go index 90f1b0448..31797b96e 100644 --- a/protocol/tun/inbound.go +++ b/protocol/tun/inbound.go @@ -15,8 +15,10 @@ import ( "github.com/sagernet/sing-box/adapter/inbound" "github.com/sagernet/sing-box/common/taskmonitor" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/experimental/deprecated" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/service/oomkiller" "github.com/sagernet/sing-tun" "github.com/sagernet/sing-tun/gtcpip/header" "github.com/sagernet/sing/common" @@ -79,6 +81,9 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo if options.InboundOptions != (option.InboundOptions{}) { return nil, E.New("legacy inbound fields are deprecated in sing-box 1.11.0 and removed in sing-box 1.13.0, checkout migration: https://sing-box.sagernet.org/migration/#migrate-legacy-inbound-fields-to-rule-actions") } + if options.Stack != "" { + deprecated.Report(ctx, deprecated.OptionTunStack) + } address := options.Address inet4Address := common.Filter(address, func(it netip.Prefix) bool { @@ -123,7 +128,20 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo } var enableGSO bool if C.IsLinux && !usePlatformInterface { - enableGSO = (options.Stack == "gvisor" && tunMTU < 49152) + switch options.Stack { + case "", "go", "gvisor": + enableGSO = tunMTU < 49152 + } + } + if options.MultiQueue { + if !C.IsLinux || usePlatformInterface { + return nil, E.New("`multi_queue` is only supported on Linux") + } + switch options.Stack { + case "", "go": + default: + return nil, E.New("`multi_queue` is only supported by the `go` stack") + } } var udpTimeout time.Duration if options.UDPTimeout != 0 { @@ -180,7 +198,6 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo excludeMACAddress = append(excludeMACAddress, mac) } networkManager := service.FromContext[adapter.NetworkManager](ctx) - multiPendingPackets := C.IsDarwin && ((options.Stack == "gvisor" && tunMTU < 32768) || (options.Stack != "gvisor" && tunMTU <= 9000)) inbound := &Inbound{ tag: tag, ctx: ctx, @@ -192,6 +209,7 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo NetNs: options.NetNs, MTU: tunMTU, GSO: enableGSO, + MultiQueue: options.MultiQueue, Inet4Address: inet4Address, Inet6Address: inet6Address, DNSMode: options.DNSMode, @@ -224,7 +242,7 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo ExcludeMACAddress: excludeMACAddress, InterfaceMonitor: networkManager.InterfaceMonitor(), Logger: logger, - EXP_MultiPendingPackets: multiPendingPackets, + EXP_MultiPendingPackets: C.IsDarwin, }, udpTimeout: udpTimeout, udpMapping: tun.NATMapping(options.UDPMapping), @@ -467,6 +485,11 @@ func (t *Inbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { if t.platformInterface != nil && t.platformInterface.UnderNetworkExtension() { includeAllNetworks = t.platformInterface.NetworkExtensionIncludeAllNetworks() } + var memoryPressure func() tun.MemoryPressure + oomKiller := service.FromContext[*oomkiller.Service](t.ctx) + if oomKiller != nil { + memoryPressure = oomKiller.MemoryPressure + } tunStack, err := tun.NewStack(t.stack, tun.StackOptions{ Context: t.ctx, Tun: tunInterface, @@ -481,6 +504,7 @@ func (t *Inbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { ForwarderBindInterface: C.IsDarwin, InterfaceFinder: t.networkManager.InterfaceFinder(), IncludeAllNetworks: includeAllNetworks, + MemoryPressure: memoryPressure, }) if err != nil { return err diff --git a/route/conn.go b/route/conn.go index 6f8fde570..f567096b0 100644 --- a/route/conn.go +++ b/route/conn.go @@ -10,6 +10,7 @@ import ( "strings" "sync" "sync/atomic" + "syscall" "time" "github.com/sagernet/sing-box/adapter" @@ -19,6 +20,7 @@ import ( "github.com/sagernet/sing-box/common/tlsspoof" "github.com/sagernet/sing-box/common/urltest" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/buf" "github.com/sagernet/sing/common/bufio" @@ -59,6 +61,8 @@ func (m *ConnectionManager) Start(stage adapter.StartStage, scope *adapter.Scope } func (m *ConnectionManager) Count() int { + m.access.Lock() + defer m.access.Unlock() return m.connections.Len() } @@ -78,25 +82,27 @@ func (m *ConnectionManager) CloseAll() { } func (m *ConnectionManager) TrackConn(conn net.Conn) net.Conn { - m.access.Lock() - element := m.connections.PushBack(conn) - m.access.Unlock() - return &trackedConn{ - Conn: conn, - manager: m, - element: element, + tracked := &trackedConn{ + Conn: conn, + socketOwner: socketOwner{original: conn}, + manager: m, } + m.access.Lock() + tracked.element = m.connections.PushBack(tracked) + m.access.Unlock() + return tracked } func (m *ConnectionManager) TrackPacketConn(conn net.PacketConn) net.PacketConn { - m.access.Lock() - element := m.connections.PushBack(conn) - m.access.Unlock() - return &trackedPacketConn{ - PacketConn: conn, - manager: m, - element: element, + tracked := &trackedPacketConn{ + NetPacketConn: bufio.NewPacketConn(conn), + socketOwner: socketOwner{original: conn}, + manager: m, } + m.access.Lock() + tracked.element = m.connections.PushBack(tracked) + m.access.Unlock() + return tracked } func (m *ConnectionManager) NewConnection(ctx context.Context, this N.Dialer, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { @@ -135,6 +141,19 @@ func (m *ConnectionManager) NewConnection(ctx context.Context, this N.Dialer, co m.logger.ErrorContext(ctx, err) return } + if !metadata.TLSFragment && !metadata.TLSRecordFragment && metadata.TLSSpoof == "" { + var spliced bool + spliced, err = m.spliceConnection(ctx, conn, remoteConn, onClose) + if err != nil { + N.CloseOnHandshakeFailure(conn, onClose, err) + remoteConn.Close() + m.logger.ErrorContext(ctx, err) + return + } + if spliced { + return + } + } if metadata.TLSFragment || metadata.TLSRecordFragment { remoteConn = tf.NewConn(remoteConn, ctx, metadata.TLSFragment, metadata.TLSRecordFragment, metadata.TLSFragmentFallbackDelay) } @@ -236,6 +255,18 @@ func (m *ConnectionManager) NewPacketConnection(ctx context.Context, this N.Dial m.logger.ErrorContext(ctx, err) return } + udpTimeout := packetTimeout(&metadata) + var ( + spliceRemote any = remotePacketConn + spliced bool + ) + if remoteConn != nil { + spliceRemote = remoteConn + } + conn, spliced = m.splicePacketConnection(ctx, conn, spliceRemote, &metadata, destinationAddress, udpTimeout, onClose) + if spliced { + return + } if destinationAddress.IsValid() { var originDestination M.Socksaddr if metadata.RouteOriginalDestination.IsValid() { @@ -260,18 +291,6 @@ func (m *ConnectionManager) NewPacketConnection(ctx context.Context, this N.Dial } else if metadata.RouteOriginalDestination.IsValid() && metadata.RouteOriginalDestination != metadata.Destination { remotePacketConn = bufio.NewDestinationNATPacketConn(bufio.NewPacketConn(remotePacketConn), metadata.Destination, metadata.RouteOriginalDestination) } - var udpTimeout time.Duration - if metadata.UDPTimeout > 0 { - udpTimeout = metadata.UDPTimeout - } else { - protocol := metadata.Protocol - if protocol == "" { - protocol = C.PortProtocols[metadata.Destination.Port] - } - if protocol != "" { - udpTimeout = C.ProtocolTimeouts[protocol] - } - } if udpTimeout > 0 { ctx, conn = canceler.NewPacketConn(ctx, conn, udpTimeout) } @@ -416,16 +435,70 @@ func (m *ConnectionManager) packetConnectionCopy(ctx context.Context, source N.P common.Close(source, destination) } +type socketOwner struct { + access sync.Mutex + original io.Closer + owner io.Closer + closed bool +} + +func (o *socketOwner) Attach(closer io.Closer) (io.Closer, bool) { + o.access.Lock() + defer o.access.Unlock() + if o.closed || o.owner != nil { + return nil, false + } + o.owner = closer + return o.original, true +} + +func (o *socketOwner) detach() bool { + o.access.Lock() + defer o.access.Unlock() + o.owner = nil + return o.closed +} + +func (o *socketOwner) close() bool { + o.access.Lock() + o.closed = true + owner := o.owner + o.access.Unlock() + if owner == nil { + return false + } + owner.Close() + return true +} + type trackedConn struct { net.Conn + socketOwner manager *ConnectionManager element *list.Element[io.Closer] } +func (c *trackedConn) SyscallConn() (syscall.RawConn, error) { + syscallConn, isSyscallConn := c.Conn.(syscall.Conn) + if !isSyscallConn { + return nil, os.ErrInvalid + } + return syscallConn.SyscallConn() +} + +func (c *trackedConn) Detach() { + if c.socketOwner.detach() { + c.Conn.Close() + } +} + func (c *trackedConn) Close() error { c.manager.access.Lock() c.manager.connections.Remove(c.element) c.manager.access.Unlock() + if c.socketOwner.close() { + return nil + } return c.Conn.Close() } @@ -442,20 +515,38 @@ func (c *trackedConn) WriterReplaceable() bool { } type trackedPacketConn struct { - net.PacketConn + N.NetPacketConn + socketOwner manager *ConnectionManager element *list.Element[io.Closer] } +func (c *trackedPacketConn) SyscallConn() (syscall.RawConn, error) { + syscallConn, isSyscallConn := c.NetPacketConn.(syscall.Conn) + if !isSyscallConn { + return nil, os.ErrInvalid + } + return syscallConn.SyscallConn() +} + +func (c *trackedPacketConn) Detach() { + if c.socketOwner.detach() { + c.NetPacketConn.Close() + } +} + func (c *trackedPacketConn) Close() error { c.manager.access.Lock() c.manager.connections.Remove(c.element) c.manager.access.Unlock() - return c.PacketConn.Close() + if c.socketOwner.close() { + return nil + } + return c.NetPacketConn.Close() } func (c *trackedPacketConn) Upstream() any { - return bufio.NewPacketConn(c.PacketConn) + return c.NetPacketConn } func (c *trackedPacketConn) ReaderReplaceable() bool { @@ -465,3 +556,8 @@ func (c *trackedPacketConn) ReaderReplaceable() bool { func (c *trackedPacketConn) WriterReplaceable() bool { return true } + +var ( + _ tun.SpliceSocket = (*trackedConn)(nil) + _ tun.SpliceSocket = (*trackedPacketConn)(nil) +) diff --git a/route/network.go b/route/network.go index 1ff6e49e1..aa1634592 100644 --- a/route/network.go +++ b/route/network.go @@ -496,6 +496,16 @@ func (r *NetworkManager) FlushSystemDNSCache() { } } +func (r *NetworkManager) ReleaseMemory(ctx context.Context) { + r.ResetNetwork(ctx) + for _, outbound := range r.outbound.Outbounds() { + keeper, isKeeper := outbound.(adapter.IdleConnectionKeeper) + if isKeeper { + keeper.CloseIdleConnections() + } + } +} + func (r *NetworkManager) notifyInterfaceUpdate(_ *control.Interface, _ int) { r.interfaceUpdateAccess.Lock() defer r.interfaceUpdateAccess.Unlock() diff --git a/route/splice.go b/route/splice.go new file mode 100644 index 000000000..61826df30 --- /dev/null +++ b/route/splice.go @@ -0,0 +1,321 @@ +package route + +import ( + "context" + "io" + "net" + "net/netip" + "slices" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/iponly" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +type spliceTarget struct { + socket tun.SpliceSocket + offload N.PacketOffload + readCounters []N.CountFunc + writeCounters []N.CountFunc +} + +func unwrapSpliceTarget(conn any, allowOffload bool) (spliceTarget, bool) { + var target spliceTarget + for { + readCounter, isReadCounter := conn.(N.ReadCounter) + writeCounter, isWriteCounter := conn.(N.WriteCounter) + if isReadCounter || isWriteCounter { + if !isReadCounter || !isWriteCounter { + return spliceTarget{}, false + } + reader, readCounters := readCounter.UnwrapReader() + writer, writeCounters := writeCounter.UnwrapWriter() + if any(reader) != any(writer) { + return spliceTarget{}, false + } + target.readCounters = append(target.readCounters, readCounters...) + target.writeCounters = append(target.writeCounters, writeCounters...) + conn = reader + continue + } + packetReadCounter, isPacketReadCounter := conn.(N.PacketReadCounter) + packetWriteCounter, isPacketWriteCounter := conn.(N.PacketWriteCounter) + if isPacketReadCounter || isPacketWriteCounter { + if !isPacketReadCounter || !isPacketWriteCounter { + return spliceTarget{}, false + } + reader, readCounters := packetReadCounter.UnwrapPacketReader() + writer, writeCounters := packetWriteCounter.UnwrapPacketWriter() + if any(reader) != any(writer) { + return spliceTarget{}, false + } + target.readCounters = append(target.readCounters, readCounters...) + target.writeCounters = append(target.writeCounters, writeCounters...) + conn = reader + continue + } + if allowOffload { + upstream, offload := N.UnwrapPacketOffload(conn) + if offload != nil { + socket, isSocket := upstream.(tun.SpliceSocket) + if !isSocket { + return spliceTarget{}, false + } + target.socket = socket + target.offload = offload + return target, true + } + } + ipOnlyConn, isIPOnly := conn.(*iponly.PacketConn) + if isIPOnly { + conn = ipOnlyConn.Upstream() + continue + } + readerWithUpstream, isReaderWithUpstream := conn.(N.ReaderWithUpstream) + if !isReaderWithUpstream || !readerWithUpstream.ReaderReplaceable() { + return spliceTarget{}, false + } + writerWithUpstream, isWriterWithUpstream := conn.(N.WriterWithUpstream) + if !isWriterWithUpstream || !writerWithUpstream.WriterReplaceable() { + return spliceTarget{}, false + } + socket, isSocket := conn.(tun.SpliceSocket) + if isSocket { + target.socket = socket + return target, true + } + withUpstream, hasUpstream := conn.(common.WithUpstream) + if hasUpstream { + conn = withUpstream.Upstream() + continue + } + upstreamReader, hasUpstreamReader := conn.(N.WithUpstreamReader) + upstreamWriter, hasUpstreamWriter := conn.(N.WithUpstreamWriter) + if !hasUpstreamReader || !hasUpstreamWriter { + return spliceTarget{}, false + } + reader := upstreamReader.UpstreamReader() + if reader != upstreamWriter.UpstreamWriter() { + return spliceTarget{}, false + } + conn = reader + } +} + +func (m *ConnectionManager) spliceClose(ctx context.Context, conn io.Closer, remote io.Closer, onClose N.CloseHandlerFunc) N.CloseHandlerFunc { + return func(err error) { + if err == nil { + m.logger.DebugContext(ctx, "connection finished") + } else if !E.IsClosedOrCanceled(err) { + m.logger.ErrorContext(ctx, "connection closed: ", err) + } else { + m.logger.TraceContext(ctx, "connection closed") + } + if onClose != nil { + onClose(err) + } + common.Close(conn, remote) + } +} + +func (m *ConnectionManager) spliceConnection(ctx context.Context, conn net.Conn, remoteConn net.Conn, onClose N.CloseHandlerFunc) (bool, error) { + destination, writeCounters := N.UnwrapCountWriter(conn, nil) + goConn, isGoConn := N.CastWriter[*tun.GoConn](destination) + if !isGoConn { + return false, nil + } + target, isTarget := unwrapSpliceTarget(remoteConn, false) + if !isTarget { + return false, nil + } + var ( + source io.Reader = conn + readCounters []N.CountFunc + ) + for { + source, readCounters = N.UnwrapCountReader(source, readCounters) + cachedReader, isCached := source.(N.CachedReader) + if !isCached { + break + } + buffer := cachedReader.ReadCached() + if buffer == nil { + break + } + dataLen := buffer.Len() + _, err := remoteConn.Write(buffer.Bytes()) + buffer.Release() + if err != nil { + return false, err + } + for _, counter := range readCounters { + counter(int64(dataLen)) + } + } + goReader, isGoReader := N.CastReader[*tun.GoConn](source) + if !isGoReader || goReader != goConn { + return false, nil + } + return goConn.Splice(target.socket, tun.SpliceOptions{ + ReadCounters: append(readCounters, target.writeCounters...), + WriteCounters: append(target.readCounters, writeCounters...), + OnClose: m.spliceClose(ctx, conn, remoteConn, onClose), + }), nil +} + +type spliceSource struct { + natConn *tun.UDPNatConn + cachedReaders []N.CachedPacketReader + readCounters []N.CountFunc + writeCounters []N.CountFunc +} + +func unwrapSpliceSource(conn N.PacketConn) (spliceSource, bool) { + var source spliceSource + writer, writeCounters := N.UnwrapCountPacketWriter(conn, nil) + natWriter, isNATWriter := N.CastPacketWriter[*tun.UDPNatConn](writer) + if !isNATWriter { + return spliceSource{}, false + } + source.writeCounters = writeCounters + var reader N.PacketReader = conn + for { + readCounter, isReadCounter := reader.(N.PacketReadCounter) + if isReadCounter { + upstreamReader, readCounters := readCounter.UnwrapPacketReader() + source.readCounters = append(source.readCounters, readCounters...) + reader = upstreamReader + continue + } + natReader, isNATReader := reader.(*tun.UDPNatConn) + if isNATReader { + if natReader != natWriter { + return spliceSource{}, false + } + source.natConn = natReader + return source, true + } + cachedReader, isCached := reader.(N.CachedPacketReader) + if isCached { + source.cachedReaders = append(source.cachedReaders, cachedReader) + } else { + readerWithUpstream, isReaderWithUpstream := reader.(N.ReaderWithUpstream) + if !isReaderWithUpstream || !readerWithUpstream.ReaderReplaceable() { + return spliceSource{}, false + } + } + withUpstream, hasUpstream := reader.(common.WithUpstream) + if hasUpstream { + reader, _ = withUpstream.Upstream().(N.PacketReader) + } else { + upstreamReader, hasUpstreamReader := reader.(N.WithUpstreamReader) + if !hasUpstreamReader { + return spliceSource{}, false + } + reader, _ = upstreamReader.UpstreamReader().(N.PacketReader) + } + if reader == nil { + return spliceSource{}, false + } + } +} + +func (s *spliceSource) takeCached() []*N.PacketBuffer { + var cached []*N.PacketBuffer + for _, cachedReader := range s.cachedReaders { + packet := cachedReader.ReadCachedPacket() + if packet == nil { + continue + } + if packet.Buffer == nil { + N.PutPacketBuffer(packet) + continue + } + cached = append(cached, packet) + } + return cached +} + +func (m *ConnectionManager) splicePacketConnection(ctx context.Context, conn N.PacketConn, remote any, metadata *adapter.InboundContext, destinationAddress netip.Addr, udpTimeout time.Duration, onClose N.CloseHandlerFunc) (N.PacketConn, bool) { + var nat tun.PacketNAT + source := conn + fakeIPConn, isFakeIP := conn.(*fakeIPNATPacketConn) + if isFakeIP { + source = fakeIPConn.NetPacketConn + } + spliceSource, isSpliceSource := unwrapSpliceSource(source) + if !isSpliceSource { + return conn, false + } + target, isTarget := unwrapSpliceTarget(remote, true) + if !isTarget { + return conn, false + } + if destinationAddress.IsValid() { + nat.Destination = M.SocksaddrFrom(destinationAddress, metadata.Destination.Port) + } else { + nat.Destination = metadata.Destination + } + switch { + case isFakeIP: + nat.Origin = metadata.OriginDestination + nat.FakeIP = true + case metadata.RouteOriginalDestination.IsValid() && metadata.RouteOriginalDestination != metadata.Destination: + nat.Origin = metadata.RouteOriginalDestination + case destinationAddress.IsValid() && metadata.Destination.IsIP(): + nat.Origin = metadata.Destination + } + nat.Unidirectional = !isFakeIP && metadata.UDPDisableDomainUnmapping && !metadata.Destination.IsIP() + cached := spliceSource.takeCached() + if spliceSource.natConn.Splice(target.socket, tun.SplicePacketOptions{ + SpliceOptions: tun.SpliceOptions{ + ReadCounters: append(spliceSource.readCounters, target.writeCounters...), + WriteCounters: append(target.readCounters, spliceSource.writeCounters...), + OnClose: m.spliceClose(ctx, conn, remote.(io.Closer), onClose), + }, + Timeout: udpTimeout, + NAT: nat, + Cached: cached, + Offload: target.offload, + FrontHeadroom: N.CalculateFrontHeadroom(remote), + RearHeadroom: N.CalculateRearHeadroom(remote), + }) { + return conn, true + } + if len(cached) == 0 { + return conn, false + } + for _, packet := range slices.Backward(cached) { + for _, counter := range spliceSource.readCounters { + counter(int64(packet.Buffer.Len())) + } + source = bufio.NewCachedPacketConn(source, packet.Buffer, packet.Destination) + N.PutPacketBuffer(packet) + } + if isFakeIP { + fakeIPConn.NetPacketConn = bufio.NewNetPacketConn(source) + return conn, false + } + return source, false +} + +func packetTimeout(metadata *adapter.InboundContext) time.Duration { + if metadata.UDPTimeout > 0 { + return metadata.UDPTimeout + } + protocol := metadata.Protocol + if protocol == "" { + protocol = C.PortProtocols[metadata.Destination.Port] + } + if protocol != "" { + return C.ProtocolTimeouts[protocol] + } + return 0 +} diff --git a/service/oomkiller/promote.go b/service/oomkiller/promote.go index 6069624ad..485900583 100644 --- a/service/oomkiller/promote.go +++ b/service/oomkiller/promote.go @@ -37,7 +37,7 @@ func PromoteDraft(basePath string, accept func(metadataContent []byte) bool) { return } } - promoteDirectory(draftPath, filepath.Join(basePath, ReportsDirectoryName)) + promoteDirectory(draftPath, filepath.Join(basePath, ReportsDirectoryName), info.ModTime().UTC()) } func draftNotable(draftPath string) bool { @@ -64,7 +64,7 @@ func draftNotable(draftPath string) bool { return false } -func promoteDirectory(draftPath string, reportsPath string) { +func promoteDirectory(draftPath string, reportsPath string, timestamp time.Time) { info, err := os.Stat(draftPath) if err != nil || !info.IsDir() { return @@ -73,7 +73,7 @@ func promoteDirectory(draftPath string, reportsPath string) { if err != nil { return } - destPath, err := nextAvailableReportPath(reportsPath, info.ModTime().UTC()) + destPath, err := nextAvailableReportPath(reportsPath, timestamp) if err != nil { os.RemoveAll(draftPath) return diff --git a/service/oomkiller/recorder.go b/service/oomkiller/recorder.go index a8cdcc2ab..33c8563df 100644 --- a/service/oomkiller/recorder.go +++ b/service/oomkiller/recorder.go @@ -222,7 +222,7 @@ func (r *Recorder) WriteReport() error { sample.availableKnown = true sample.available = memory.Available() } - err := r.snapshot(SnapshotReasonManual, sample, false, true) + err := r.snapshot(SnapshotReasonManual, sample, true, true) if err != nil { return E.Cause(err, "write snapshot") } diff --git a/service/oomkiller/service.go b/service/oomkiller/service.go index b5b8810f5..0fbb313de 100644 --- a/service/oomkiller/service.go +++ b/service/oomkiller/service.go @@ -2,12 +2,14 @@ package oomkiller import ( "context" + "sync/atomic" "github.com/sagernet/sing-box/adapter" boxService "github.com/sagernet/sing-box/adapter/service" boxConstant "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-tun" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/service" ) @@ -25,6 +27,7 @@ type Service struct { recorder *Recorder timerConfig timerConfig adaptiveTimer *adaptiveTimer + pressure atomic.Uint32 } func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.OOMKillerServiceOptions) (adapter.Service, error) { @@ -33,7 +36,7 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio if err != nil { return nil, err } - return &Service{ + s := &Service{ Adapter: boxService.NewAdapter(boxConstant.TypeOOMKiller, tag), ctx: ctx, logger: logger, @@ -41,14 +44,20 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio connections: service.FromContext[adapter.ConnectionManager](ctx), recorder: service.FromContext[*Recorder](ctx), timerConfig: config, - }, nil + } + service.MustRegister[*Service](ctx, s) + return s, nil +} + +func (s *Service) MemoryPressure() tun.MemoryPressure { + return tun.MemoryPressure(s.pressure.Load()) } func (s *Service) startTimer() error { if !s.timerConfig.policyMode.hasTimerMode() { return E.New("memory pressure monitoring is not available on this platform without memory_limit") } - s.adaptiveTimer = newAdaptiveTimer(s.logger, s.network, s.connections, service.FromContext[adapter.CacheFile](s.ctx), s.recorder, s.timerConfig) + s.adaptiveTimer = newAdaptiveTimer(s.logger, s.network, s.connections, service.FromContext[adapter.CacheFile](s.ctx), s.recorder, &s.pressure, s.timerConfig) var carriedState *timerState if s.recorder != nil { carriedState = s.recorder.instanceStarted(s.timerConfig, s.adaptiveTimer.limitThresholds) @@ -59,6 +68,7 @@ func (s *Service) startTimer() error { func (s *Service) stopTimer() { state := s.adaptiveTimer.stop() + s.pressure.Store(uint32(tun.MemoryPressureNone)) if s.recorder != nil { s.recorder.instanceStopped(state) } diff --git a/service/oomkiller/timer.go b/service/oomkiller/timer.go index 01b6b6c2a..fc1cf7ede 100644 --- a/service/oomkiller/timer.go +++ b/service/oomkiller/timer.go @@ -2,13 +2,17 @@ package oomkiller import ( "context" + "runtime" runtimeDebug "runtime/debug" + "runtime/metrics" "sync" + "sync/atomic" "time" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common/byteformats" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/memory" @@ -16,8 +20,11 @@ import ( const ( defaultMinInterval = 100 * time.Millisecond - defaultArmedInterval = time.Second defaultMaxInterval = 10 * time.Second + defaultReleaseInterval = time.Second + defaultReleaseShrinkFloor = 16 + defaultReleaseShrinkDivisor = 10 + defaultMaxRateLookahead = time.Second defaultSafetyMargin = 5 * 1024 * 1024 defaultAvailableTriggerMarginMin = 32 * 1024 * 1024 defaultAvailableTriggerMarginMax = 128 * 1024 * 1024 @@ -40,6 +47,7 @@ type memorySample struct { type pressureThresholds struct { trigger uint64 armed uint64 + resume uint64 } type timerConfig struct { @@ -47,7 +55,6 @@ type timerConfig struct { safetyMargin uint64 hasSafetyMargin bool minInterval time.Duration - armedInterval time.Duration maxInterval time.Duration policyMode policyMode killerDisabled bool @@ -90,7 +97,6 @@ func buildTimerConfig(options option.OOMKillerServiceOptions, memoryLimit uint64 safetyMargin: safetyMargin, hasSafetyMargin: hasSafetyMargin, minInterval: minInterval, - armedInterval: max(min(defaultArmedInterval, maxInterval), minInterval), maxInterval: maxInterval, policyMode: policyMode, killerDisabled: killerDisabled, @@ -113,14 +119,20 @@ type adaptiveTimer struct { connections adapter.ConnectionManager cacheFile adapter.CacheFile recorder *Recorder + pressure *atomic.Uint32 limitThresholds pressureThresholds - access sync.Mutex - timer *time.Timer + access sync.Mutex + timer *time.Timer + lastGoroutines int + lastConnections int + lastGCCycles uint64 + releaseGCCycles uint64 + lastRelease time.Time timerState } -func newAdaptiveTimer(logger log.ContextLogger, network adapter.NetworkManager, connections adapter.ConnectionManager, cacheFile adapter.CacheFile, recorder *Recorder, config timerConfig) *adaptiveTimer { +func newAdaptiveTimer(logger log.ContextLogger, network adapter.NetworkManager, connections adapter.ConnectionManager, cacheFile adapter.CacheFile, recorder *Recorder, pressure *atomic.Uint32, config timerConfig) *adaptiveTimer { t := &adaptiveTimer{ timerConfig: config, logger: logger, @@ -128,6 +140,7 @@ func newAdaptiveTimer(logger log.ContextLogger, network adapter.NetworkManager, connections: connections, cacheFile: cacheFile, recorder: recorder, + pressure: pressure, } if config.policyMode == policyModeMemoryLimit || config.policyMode == policyModeNetworkExtension { t.limitThresholds = computeLimitThresholds(config.memoryLimit, config.safetyMargin) @@ -191,7 +204,8 @@ func (t *adaptiveTimer) poll() { t.pressureBaselineTime = time.Time{} } } - t.timer.Reset(t.intervalForState()) + interval := t.intervalForState() + t.timer.Reset(interval) triggered = previousState != pressureStateTriggered && t.state == pressureStateTriggered if !triggered && !t.pressureBaselineTime.IsZero() && t.memoryLimit > 0 && sample.usage > t.pressureBaseline.usage && sample.usage < t.memoryLimit { @@ -201,7 +215,8 @@ func (t *adaptiveTimer) poll() { ratePerSecond := float64(growth) / elapsed.Seconds() headroom := t.memoryLimit - sample.usage secondsUntilLimit := float64(headroom) / ratePerSecond - if secondsUntilLimit < t.minInterval.Seconds() { + lookahead := min(2*interval, defaultMaxRateLookahead) + if secondsUntilLimit < lookahead.Seconds() { triggered = true rateTriggered = true t.state = pressureStateTriggered @@ -210,6 +225,7 @@ func (t *adaptiveTimer) poll() { } state := t.state t.access.Unlock() + t.pressure.Store(uint32(state.memoryPressure())) var connections int if t.connections != nil { connections = t.connections.Count() @@ -221,6 +237,7 @@ func (t *adaptiveTimer) poll() { } } if !triggered { + t.releaseIdleMemory(sample, connections) return } var reason string @@ -230,7 +247,7 @@ func (t *adaptiveTimer) poll() { t.logger.Warn("memory growth rate critical (report only), usage: ", byteformats.FormatMemoryBytes(sample.usage), t.logDetails(sample)) } else { t.logger.Error("memory growth rate critical, usage: ", byteformats.FormatMemoryBytes(sample.usage), t.logDetails(sample), ", resetting network") - t.network.ResetNetwork(context.Background()) + t.network.ReleaseMemory(context.Background()) } } else { reason = resetReasonThreshold @@ -238,7 +255,7 @@ func (t *adaptiveTimer) poll() { t.logger.Warn("memory threshold reached (report only), usage: ", byteformats.FormatMemoryBytes(sample.usage), t.logDetails(sample)) } else { t.logger.Error("memory threshold reached, usage: ", byteformats.FormatMemoryBytes(sample.usage), t.logDetails(sample), ", resetting network") - t.network.ResetNetwork(context.Background()) + t.network.ReleaseMemory(context.Background()) } } t.releaseMemory() @@ -249,6 +266,57 @@ func (t *adaptiveTimer) poll() { } } +func (t *adaptiveTimer) releaseIdleMemory(sample memorySample, connections int) { + goroutines := runtime.NumGoroutine() + gcCycles := readGCCycles() + now := time.Now() + t.access.Lock() + shrank := hasMeaningfulDrop(t.lastGoroutines, goroutines) || hasMeaningfulDrop(t.lastConnections, connections) + idleGC := gcCycles == t.lastGCCycles && gcCycles != t.releaseGCCycles + t.lastGoroutines = goroutines + t.lastConnections = connections + t.lastGCCycles = gcCycles + if t.belowResume(sample) || now.Sub(t.lastRelease) < defaultReleaseInterval || (!shrank && !idleGC) { + t.access.Unlock() + return + } + t.lastRelease = now + t.access.Unlock() + runtimeDebug.FreeOSMemory() + t.access.Lock() + t.lastGCCycles = readGCCycles() + t.releaseGCCycles = t.lastGCCycles + t.access.Unlock() + t.logger.Trace("released idle memory, usage: ", byteformats.FormatMemoryBytes(sample.usage), " -> ", byteformats.FormatMemoryBytes(memory.Total())) +} + +func hasMeaningfulDrop(previous int, current int) bool { + if current >= previous { + return false + } + return previous-current >= max(defaultReleaseShrinkFloor, previous/defaultReleaseShrinkDivisor) +} + +func readGCCycles() uint64 { + samples := []metrics.Sample{{Name: "/gc/cycles/total:gc-cycles"}} + metrics.Read(samples) + if samples[0].Value.Kind() != metrics.KindUint64 { + return 0 + } + return samples[0].Value.Uint64() +} + +func (t *adaptiveTimer) belowResume(sample memorySample) bool { + switch t.policyMode { + case policyModeMemoryLimit, policyModeNetworkExtension: + return sample.usage < t.limitThresholds.resume + case policyModeAvailable: + return !sample.availableKnown || sample.available > t.availableThresholds(sample).resume + default: + return true + } +} + func (t *adaptiveTimer) belowTrigger(sample memorySample) bool { switch t.policyMode { case policyModeMemoryLimit, policyModeNetworkExtension: @@ -264,8 +332,23 @@ func (t *adaptiveTimer) releaseMemory() { if t.cacheFile != nil { t.cacheFile.Flush() } + t.pressure.Store(uint32(tun.MemoryPressureCritical)) badCleanup() runtimeDebug.FreeOSMemory() + t.access.Lock() + t.releaseGCCycles = readGCCycles() + t.access.Unlock() +} + +func (s pressureState) memoryPressure() tun.MemoryPressure { + switch s { + case pressureStateArmed: + return tun.MemoryPressureWarning + case pressureStateTriggered: + return tun.MemoryPressureCritical + default: + return tun.MemoryPressureNone + } } func (t *adaptiveTimer) nextState(sample memorySample) pressureState { @@ -292,12 +375,18 @@ func (t *adaptiveTimer) nextState(sample memorySample) pressureState { func computeLimitThresholds(memoryLimit uint64, safetyMargin uint64) pressureThresholds { triggerMargin := min(safetyMargin, memoryLimit) armedMargin := min(triggerMargin*2, memoryLimit) + resumeMargin := min(triggerMargin*4, memoryLimit) return pressureThresholds{ trigger: memoryLimit - triggerMargin, armed: memoryLimit - armedMargin, + resume: memoryLimit - resumeMargin, } } +func RuntimeMemoryLimit(memoryLimit uint64) uint64 { + return computeLimitThresholds(memoryLimit, defaultSafetyMargin).armed +} + func (t *adaptiveTimer) availableThresholds(sample memorySample) pressureThresholds { var triggerMargin uint64 if t.hasSafetyMargin { @@ -310,15 +399,14 @@ func (t *adaptiveTimer) availableThresholds(sample memorySample) pressureThresho return pressureThresholds{ trigger: triggerMargin, armed: triggerMargin * 2, + resume: triggerMargin * 4, } } func (t *adaptiveTimer) intervalForState() time.Duration { switch { - case t.forceMinInterval || t.state == pressureStateTriggered: + case t.forceMinInterval || t.state != pressureStateNormal || !t.pressureBaselineTime.IsZero(): t.currentInterval = t.minInterval - case t.state == pressureStateArmed: - t.currentInterval = t.armedInterval default: if t.currentInterval == 0 { t.currentInterval = t.maxInterval