From 65150f5cc3d5ee593e194a8536c952bb1d47642a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 27 Feb 2026 13:35:58 +0800 Subject: [PATCH] platform: Improve OOM killer for iOS --- daemon/started_service.go | 2 +- go.mod | 2 +- go.sum | 4 +- option/oom_killer.go | 13 ++- service/oomkiller/config.go | 51 ++++++++++ service/oomkiller/service.go | 82 ++++++++++++--- service/oomkiller/service_stub.go | 54 ++++++++-- service/oomkiller/service_timer.go | 158 +++++++++++++++++++++++++++++ 8 files changed, 341 insertions(+), 25 deletions(-) create mode 100644 service/oomkiller/config.go create mode 100644 service/oomkiller/service_timer.go diff --git a/daemon/started_service.go b/daemon/started_service.go index 5e677f7a..7ebdac1e 100644 --- a/daemon/started_service.go +++ b/daemon/started_service.go @@ -409,7 +409,7 @@ func (s *StartedService) SubscribeStatus(request *SubscribeStatusRequest, server func (s *StartedService) readStatus() *Status { var status Status - status.Memory = memory.Inuse() + status.Memory = memory.Total() status.Goroutines = int32(runtime.NumGoroutine()) s.serviceAccess.RLock() nowService := s.instance diff --git a/go.mod b/go.mod index 506b54cf..37724e7e 100644 --- a/go.mod +++ b/go.mod @@ -33,7 +33,7 @@ require ( github.com/sagernet/gomobile v0.1.11 github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1 github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 - github.com/sagernet/sing v0.8.0-beta.16 + github.com/sagernet/sing v0.8.0-beta.16.0.20260227013657-e419e9875a07 github.com/sagernet/sing-mux v0.3.4 github.com/sagernet/sing-quic v0.6.0-beta.13 github.com/sagernet/sing-shadowsocks v0.2.8 diff --git a/go.sum b/go.sum index c9478c27..8c683a95 100644 --- a/go.sum +++ b/go.sum @@ -226,8 +226,8 @@ github.com/sagernet/nftables v0.3.0-beta.4 h1:kbULlAwAC3jvdGAC1P5Fa3GSxVwQJibNen github.com/sagernet/nftables v0.3.0-beta.4/go.mod h1:OQXAjvjNGGFxaTgVCSTRIhYB5/llyVDeapVoENYBDS8= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 h1:6qvrUW79S+CrPwWz6cMePXohgjHoKxLo3c+MDhNwc3o= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4/go.mod h1:OqILvS182CyOol5zNNo6bguvOGgXzV459+chpRaUC+4= -github.com/sagernet/sing v0.8.0-beta.16 h1:Fe+6E9VHYky9Mx4cf0ugbZPWDcXRflpAu7JQ5bWXvaA= -github.com/sagernet/sing v0.8.0-beta.16/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= +github.com/sagernet/sing v0.8.0-beta.16.0.20260227013657-e419e9875a07 h1:LQqb+xtR5uqF6bePmJQ3sAToF/kMCjxSnz17HnboXA8= +github.com/sagernet/sing v0.8.0-beta.16.0.20260227013657-e419e9875a07/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= github.com/sagernet/sing-mux v0.3.4 h1:ZQplKl8MNXutjzbMVtWvWG31fohhgOfCuUZR4dVQ8+s= github.com/sagernet/sing-mux v0.3.4/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= github.com/sagernet/sing-quic v0.6.0-beta.13 h1:umDr6GC5fVbOIoTvqV4544wY61zEN+ObQwVGNP8sX1M= diff --git a/option/oom_killer.go b/option/oom_killer.go index 9fbbde84..2032ed09 100644 --- a/option/oom_killer.go +++ b/option/oom_killer.go @@ -1,3 +1,14 @@ package option -type OOMKillerServiceOptions struct{} +import ( + "github.com/sagernet/sing/common/byteformats" + "github.com/sagernet/sing/common/json/badoption" +) + +type OOMKillerServiceOptions struct { + MemoryLimit *byteformats.MemoryBytes `json:"memory_limit,omitempty"` + SafetyMargin *byteformats.MemoryBytes `json:"safety_margin,omitempty"` + MinInterval badoption.Duration `json:"min_interval,omitempty"` + MaxInterval badoption.Duration `json:"max_interval,omitempty"` + ChecksBeforeLimit int `json:"checks_before_limit,omitempty"` +} diff --git a/service/oomkiller/config.go b/service/oomkiller/config.go new file mode 100644 index 00000000..693ced99 --- /dev/null +++ b/service/oomkiller/config.go @@ -0,0 +1,51 @@ +package oomkiller + +import ( + "time" + + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func buildTimerConfig(options option.OOMKillerServiceOptions, memoryLimit uint64, useAvailable bool) (timerConfig, error) { + safetyMargin := uint64(defaultSafetyMargin) + if options.SafetyMargin != nil && options.SafetyMargin.Value() > 0 { + safetyMargin = options.SafetyMargin.Value() + } + + minInterval := defaultMinInterval + if options.MinInterval != 0 { + minInterval = time.Duration(options.MinInterval.Build()) + if minInterval <= 0 { + return timerConfig{}, E.New("min_interval must be greater than 0") + } + } + + maxInterval := defaultMaxInterval + if options.MaxInterval != 0 { + maxInterval = time.Duration(options.MaxInterval.Build()) + if maxInterval <= 0 { + return timerConfig{}, E.New("max_interval must be greater than 0") + } + } + if maxInterval < minInterval { + return timerConfig{}, E.New("max_interval must be greater than or equal to min_interval") + } + + checksBeforeLimit := defaultChecksBeforeLimit + if options.ChecksBeforeLimit != 0 { + checksBeforeLimit = options.ChecksBeforeLimit + if checksBeforeLimit <= 0 { + return timerConfig{}, E.New("checks_before_limit must be greater than 0") + } + } + + return timerConfig{ + memoryLimit: memoryLimit, + safetyMargin: safetyMargin, + minInterval: minInterval, + maxInterval: maxInterval, + checksBeforeLimit: checksBeforeLimit, + useAvailable: useAvailable, + }, nil +} diff --git a/service/oomkiller/service.go b/service/oomkiller/service.go index fb486ab9..c3612d92 100644 --- a/service/oomkiller/service.go +++ b/service/oomkiller/service.go @@ -57,37 +57,72 @@ var ( type Service struct { boxService.Adapter - logger log.ContextLogger - router adapter.Router + logger log.ContextLogger + router adapter.Router + memoryLimit uint64 + hasTimerMode bool + useAvailable bool + timerConfig timerConfig + adaptiveTimer *adaptiveTimer } func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.OOMKillerServiceOptions) (adapter.Service, error) { - return &Service{ + s := &Service{ Adapter: boxService.NewAdapter(boxConstant.TypeOOMKiller, tag), logger: logger, router: service.FromContext[adapter.Router](ctx), - }, nil + } + + if options.MemoryLimit != nil { + s.memoryLimit = options.MemoryLimit.Value() + if s.memoryLimit > 0 { + s.hasTimerMode = true + } + } + + config, err := buildTimerConfig(options, s.memoryLimit, s.useAvailable) + if err != nil { + return nil, err + } + s.timerConfig = config + + return s, nil } func (s *Service) Start(stage adapter.StartStage) error { if stage != adapter.StartStateStart { return nil } + + if s.hasTimerMode { + s.adaptiveTimer = newAdaptiveTimer(s.logger, s.router, s.timerConfig) + if s.memoryLimit > 0 { + s.logger.Info("started memory monitor with limit: ", s.memoryLimit/(1024*1024), " MiB") + } else { + s.logger.Info("started memory monitor with available memory detection") + } + } else { + s.logger.Info("started memory pressure monitor") + } + globalAccess.Lock() isFirst := len(globalServices) == 0 globalServices = append(globalServices, s) globalAccess.Unlock() + if isFirst { C.startMemoryPressureMonitor() } - s.logger.Info("started memory pressure monitor") return nil } func (s *Service) Close() error { + if s.adaptiveTimer != nil { + s.adaptiveTimer.stop() + } globalAccess.Lock() - for i, service := range globalServices { - if service == s { + for i, svc := range globalServices { + if svc == s { globalServices = append(globalServices[:i], globalServices[i+1:]...) break } @@ -122,17 +157,36 @@ func goMemoryPressureCallback(status C.ulong) { default: level = "normal" } + var freeOSMemory bool for _, s := range services { - if isCritical { - s.logger.Error("memory pressure: ", level, ", usage: ", memory.Total()/(1024*1024), " MiB, resetting network") - s.router.ResetNetwork() - } else if isWarning { - s.logger.Warn("memory pressure: ", level, ", usage: ", memory.Total()/(1024*1024), " MiB") + usage := memory.Total() + if s.hasTimerMode { + if isCritical { + s.logger.Warn("memory pressure: ", level, ", usage: ", usage/(1024*1024), " MiB") + if s.adaptiveTimer != nil { + s.adaptiveTimer.startNow() + } + } else if isWarning { + s.logger.Warn("memory pressure: ", level, ", usage: ", usage/(1024*1024), " MiB") + } else { + s.logger.Debug("memory pressure: ", level, ", usage: ", usage/(1024*1024), " MiB") + if s.adaptiveTimer != nil { + s.adaptiveTimer.stop() + } + } } else { - s.logger.Debug("memory pressure: ", level, ", usage: ", memory.Total()/(1024*1024), " MiB") + if isCritical { + s.logger.Error("memory pressure: ", level, ", usage: ", usage/(1024*1024), " MiB, resetting network") + s.router.ResetNetwork() + freeOSMemory = true + } else if isWarning { + s.logger.Warn("memory pressure: ", level, ", usage: ", usage/(1024*1024), " MiB") + } else { + s.logger.Debug("memory pressure: ", level, ", usage: ", usage/(1024*1024), " MiB") + } } } - if isCritical { + if freeOSMemory { runtimeDebug.FreeOSMemory() } } diff --git a/service/oomkiller/service_stub.go b/service/oomkiller/service_stub.go index 425f525e..13348bac 100644 --- a/service/oomkiller/service_stub.go +++ b/service/oomkiller/service_stub.go @@ -7,33 +7,75 @@ import ( "github.com/sagernet/sing-box/adapter" boxService "github.com/sagernet/sing-box/adapter/service" - C "github.com/sagernet/sing-box/constant" + boxConstant "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/memory" + "github.com/sagernet/sing/service" ) func RegisterService(registry *boxService.Registry) { - boxService.Register[option.OOMKillerServiceOptions](registry, C.TypeOOMKiller, NewService) + boxService.Register[option.OOMKillerServiceOptions](registry, boxConstant.TypeOOMKiller, NewService) } type Service struct { boxService.Adapter + logger log.ContextLogger + router adapter.Router + adaptiveTimer *adaptiveTimer + timerConfig timerConfig + hasTimerMode bool + useAvailable bool + memoryLimit uint64 } func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.OOMKillerServiceOptions) (adapter.Service, error) { - return &Service{ - Adapter: boxService.NewAdapter(C.TypeOOMKiller, tag), - }, nil + s := &Service{ + Adapter: boxService.NewAdapter(boxConstant.TypeOOMKiller, tag), + logger: logger, + router: service.FromContext[adapter.Router](ctx), + } + + if options.MemoryLimit != nil { + s.memoryLimit = options.MemoryLimit.Value() + } + if s.memoryLimit > 0 { + s.hasTimerMode = true + } else if memory.AvailableSupported() { + s.useAvailable = true + s.hasTimerMode = true + } + + config, err := buildTimerConfig(options, s.memoryLimit, s.useAvailable) + if err != nil { + return nil, err + } + s.timerConfig = config + + return s, nil } func (s *Service) Start(stage adapter.StartStage) error { if stage != adapter.StartStateStart { return nil } - return E.New("memory pressure monitoring is not available on this platform") + if !s.hasTimerMode { + return E.New("memory pressure monitoring is not available on this platform without memory_limit") + } + s.adaptiveTimer = newAdaptiveTimer(s.logger, s.router, s.timerConfig) + s.adaptiveTimer.start(0) + if s.useAvailable { + s.logger.Info("started memory monitor with available memory detection") + } else { + s.logger.Info("started memory monitor with limit: ", s.memoryLimit/(1024*1024), " MiB") + } + return nil } func (s *Service) Close() error { + if s.adaptiveTimer != nil { + s.adaptiveTimer.stop() + } return nil } diff --git a/service/oomkiller/service_timer.go b/service/oomkiller/service_timer.go new file mode 100644 index 00000000..315e1715 --- /dev/null +++ b/service/oomkiller/service_timer.go @@ -0,0 +1,158 @@ +package oomkiller + +import ( + runtimeDebug "runtime/debug" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common/memory" +) + +const ( + defaultChecksBeforeLimit = 4 + defaultMinInterval = 500 * time.Millisecond + defaultMaxInterval = 10 * time.Second + defaultSafetyMargin = 5 * 1024 * 1024 +) + +type adaptiveTimer struct { + logger log.ContextLogger + router adapter.Router + memoryLimit uint64 + safetyMargin uint64 + minInterval time.Duration + maxInterval time.Duration + checksBeforeLimit int + useAvailable bool + + access sync.Mutex + timer *time.Timer + previousUsage uint64 + lastInterval time.Duration +} + +type timerConfig struct { + memoryLimit uint64 + safetyMargin uint64 + minInterval time.Duration + maxInterval time.Duration + checksBeforeLimit int + useAvailable bool +} + +func newAdaptiveTimer(logger log.ContextLogger, router adapter.Router, config timerConfig) *adaptiveTimer { + return &adaptiveTimer{ + logger: logger, + router: router, + memoryLimit: config.memoryLimit, + safetyMargin: config.safetyMargin, + minInterval: config.minInterval, + maxInterval: config.maxInterval, + checksBeforeLimit: config.checksBeforeLimit, + useAvailable: config.useAvailable, + } +} + +func (t *adaptiveTimer) start(_ uint64) { + t.access.Lock() + defer t.access.Unlock() + t.startLocked() +} + +func (t *adaptiveTimer) startNow() { + t.access.Lock() + t.startLocked() + t.access.Unlock() + t.poll() +} + +func (t *adaptiveTimer) startLocked() { + if t.timer != nil { + return + } + t.previousUsage = memory.Total() + t.lastInterval = t.minInterval + t.timer = time.AfterFunc(t.minInterval, t.poll) +} + +func (t *adaptiveTimer) stop() { + t.access.Lock() + defer t.access.Unlock() + t.stopLocked() +} + +func (t *adaptiveTimer) stopLocked() { + if t.timer != nil { + t.timer.Stop() + t.timer = nil + } +} + +func (t *adaptiveTimer) running() bool { + t.access.Lock() + defer t.access.Unlock() + return t.timer != nil +} + +func (t *adaptiveTimer) poll() { + t.access.Lock() + defer t.access.Unlock() + if t.timer == nil { + return + } + + usage := memory.Total() + delta := int64(usage) - int64(t.previousUsage) + t.previousUsage = usage + + var remaining uint64 + var triggered bool + + if t.memoryLimit > 0 { + if usage >= t.memoryLimit { + remaining = 0 + triggered = true + } else { + remaining = t.memoryLimit - usage + } + } else if t.useAvailable { + available := memory.Available() + if available <= t.safetyMargin { + remaining = 0 + triggered = true + } else { + remaining = available - t.safetyMargin + } + } else { + remaining = 0 + } + + if triggered { + t.logger.Error("memory threshold reached, usage: ", usage/(1024*1024), " MiB, resetting network") + t.router.ResetNetwork() + runtimeDebug.FreeOSMemory() + } + + var interval time.Duration + if triggered { + interval = t.maxInterval + } else if delta <= 0 { + interval = t.maxInterval + } else if t.checksBeforeLimit <= 0 { + interval = t.maxInterval + } else { + timeToLimit := time.Duration(float64(remaining) / float64(delta) * float64(t.lastInterval)) + interval = timeToLimit / time.Duration(t.checksBeforeLimit) + if interval < t.minInterval { + interval = t.minInterval + } + if interval > t.maxInterval { + interval = t.maxInterval + } + } + + t.lastInterval = interval + t.timer.Reset(interval) +}