From eb7deeae8aa6160310202a0792e1addd681e292b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 3 Sep 2026 13:17:12 +0800 Subject: [PATCH] Improve power report attribution and sampling --- adapter/inbound.go | 10 ++ common/dialer/default.go | 12 ++ common/mux/client.go | 4 +- daemon/instance.go | 21 +++ dns/router.go | 6 +- protocol/anytls/outbound.go | 2 +- service/powerreport/device_darwin.go | 41 +++++ service/powerreport/device_darwin.h | 7 + service/powerreport/device_darwin.m | 121 +++++++++++++ service/powerreport/device_stub.go | 7 + service/powerreport/record.go | 45 ++++- service/powerreport/recorder.go | 227 +++++++++++++++++++++++-- service/powerreport/sysstats_darwin.go | 41 +++-- service/powerreport/sysstats_linux.go | 11 +- service/powerreport/traffic.go | 73 ++++++++ transport/wireguard/endpoint.go | 3 + 16 files changed, 588 insertions(+), 43 deletions(-) create mode 100644 service/powerreport/device_darwin.go create mode 100644 service/powerreport/device_darwin.h create mode 100644 service/powerreport/device_darwin.m create mode 100644 service/powerreport/device_stub.go create mode 100644 service/powerreport/traffic.go diff --git a/adapter/inbound.go b/adapter/inbound.go index ae1067d9..954ec9c4 100644 --- a/adapter/inbound.go +++ b/adapter/inbound.go @@ -189,6 +189,16 @@ func DNSTransportTagFromContext(ctx context.Context) (string, bool) { return transportTag, loaded } +func ContextForMultiplexSession(ctx context.Context) context.Context { + var sessionContext InboundContext + metadata := ContextFrom(ctx) + if metadata != nil { + sessionContext.Outbound = metadata.Outbound + } + ctx = ContextWithDNSTransportTag(ctx, "") + return WithContext(ctx, &sessionContext) +} + func WithContext(ctx context.Context, inboundContext *InboundContext) context.Context { return context.WithValue(ctx, (*inboundContextKey)(nil), inboundContext) } diff --git a/common/dialer/default.go b/common/dialer/default.go index a48f675d..154835dc 100644 --- a/common/dialer/default.go +++ b/common/dialer/default.go @@ -431,9 +431,15 @@ func (d *DefaultDialer) trackConn(ctx context.Context, destination M.Socksaddr, if recorder != nil { recorder.CountConnectionOpened() attribution := d.dialAttribution(ctx, destination) + outboundCounter := recorder.TrafficCounter(powerreport.TrafficOutbound, attribution.Outbound) + dnsCounter := recorder.TrafficCounter(powerreport.TrafficDNSTransport, attribution.DNS) conn = bufio.NewCounterConn(conn, []N.CountFunc{func(n int64) { + outboundCounter.CountIn(n) + dnsCounter.CountIn(n) recorder.Touch(powerreport.DirectionInbound, int(n), attribution) }}, []N.CountFunc{func(n int64) { + outboundCounter.CountOut(n) + dnsCounter.CountOut(n) recorder.Touch(powerreport.DirectionOutbound, int(n), attribution) }}) } @@ -453,9 +459,15 @@ func (d *DefaultDialer) trackPacketConn(ctx context.Context, destination M.Socks if recorder != nil { recorder.CountConnectionOpened() attribution := d.dialAttribution(ctx, destination) + outboundCounter := recorder.TrafficCounter(powerreport.TrafficOutbound, attribution.Outbound) + dnsCounter := recorder.TrafficCounter(powerreport.TrafficDNSTransport, attribution.DNS) conn = bufio.NewNetPacketConn(bufio.NewCounterPacketConn(bufio.NewPacketConn(conn), []N.CountFunc{func(n int64) { + outboundCounter.CountIn(n) + dnsCounter.CountIn(n) recorder.Touch(powerreport.DirectionInbound, int(n), attribution) }}, []N.CountFunc{func(n int64) { + outboundCounter.CountOut(n) + dnsCounter.CountOut(n) recorder.Touch(powerreport.DirectionOutbound, int(n), attribution) }})) } diff --git a/common/mux/client.go b/common/mux/client.go index bf103355..7e679b5c 100644 --- a/common/mux/client.go +++ b/common/mux/client.go @@ -51,9 +51,9 @@ type clientDialer struct { } func (d *clientDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { - return d.Dialer.DialContext(adapter.OverrideContext(ctx), network, destination) + return d.Dialer.DialContext(adapter.ContextForMultiplexSession(ctx), network, destination) } func (d *clientDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { - return d.Dialer.ListenPacket(adapter.OverrideContext(ctx), destination) + return d.Dialer.ListenPacket(adapter.ContextForMultiplexSession(ctx), destination) } diff --git a/daemon/instance.go b/daemon/instance.go index 5e7502ce..4040693f 100644 --- a/daemon/instance.go +++ b/daemon/instance.go @@ -14,9 +14,11 @@ import ( "github.com/sagernet/sing-box/experimental/locale" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/service/powerreport" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/x/list" "github.com/sagernet/sing/service" "github.com/sagernet/sing/service/pause" ) @@ -30,6 +32,7 @@ type Instance struct { trafficManager *trafficcontrol.Manager cacheFile adapter.CacheFile pauseManager pause.Manager + pauseCallback *list.Element[pause.Callback] urlTestHistoryStorage *urltest.HistoryStorage outboundManager adapter.OutboundManager endpointManager adapter.EndpointManager @@ -135,6 +138,7 @@ func (s *StartedService) newInstance(ctx context.Context, profileContent string, i.clashMode = service.PtrFromContext[clashmode.Manager](ctx) i.trafficManager = service.PtrFromContext[trafficcontrol.Manager](ctx) i.pauseManager = service.FromContext[pause.Manager](ctx) + i.registerPauseCallback(ctx) i.cacheFile = service.FromContext[adapter.CacheFile](ctx) i.outboundManager = service.FromContext[adapter.OutboundManager](ctx) i.endpointManager = service.FromContext[adapter.EndpointManager](ctx) @@ -164,10 +168,27 @@ func (i *Instance) Start() error { func (i *Instance) Close() error { i.cancel() + if i.pauseCallback != nil { + i.pauseManager.UnregisterCallback(i.pauseCallback) + i.pauseCallback = nil + } i.urlTestHistoryStorage.Close() return i.instance.Close() } +func (i *Instance) registerPauseCallback(ctx context.Context) { + powerManager := service.FromContext[*powerreport.Manager](ctx) + if i.pauseManager == nil || powerManager == nil { + return + } + i.pauseCallback = i.pauseManager.RegisterCallback(func(event int) { + recorder := powerManager.Recorder() + if recorder != nil { + recorder.RecordPauseEvent(event) + } + }) +} + func (i *Instance) Box() *box.Box { return i.instance } diff --git a/dns/router.go b/dns/router.go index e7c0e964..d5b07031 100644 --- a/dns/router.go +++ b/dns/router.go @@ -1059,7 +1059,11 @@ func (r *Router) prepareExchange(ctx context.Context, message *mDNS.Msg) (*dnsEx if r.powerManager != nil { recorder := r.powerManager.Recorder() if recorder != nil { - recorder.CountDNSQuery() + var domain string + if len(message.Question) == 1 { + domain = message.Question[0].Name + } + recorder.CountDNSQuery(domain) } } if len(message.Question) != 1 { diff --git a/protocol/anytls/outbound.go b/protocol/anytls/outbound.go index 83f54477..b4c07521 100644 --- a/protocol/anytls/outbound.go +++ b/protocol/anytls/outbound.go @@ -131,7 +131,7 @@ func (d anytlsDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) } func (h *Outbound) dialOut(ctx context.Context) (net.Conn, error) { - return h.dialer.DialTLSContext(ctx, h.server) + return h.dialer.DialTLSContext(adapter.ContextForMultiplexSession(ctx), h.server) } func (h *Outbound) MultiplexEnabled() bool { diff --git a/service/powerreport/device_darwin.go b/service/powerreport/device_darwin.go new file mode 100644 index 00000000..7df868cd --- /dev/null +++ b/service/powerreport/device_darwin.go @@ -0,0 +1,41 @@ +package powerreport + +/* +#cgo LDFLAGS: -framework Foundation +#cgo ios LDFLAGS: -framework UIKit +#cgo !ios LDFLAGS: -framework CoreFoundation -framework IOKit + +#include "device_darwin.h" +*/ +import "C" + +func readDeviceState() (deviceState, bool) { + var batteryLevel C.int + state := deviceState{ + LowPowerMode: C.boxPowerLowPowerMode() != 0, + ThermalState: thermalStateName(int(C.boxPowerThermalState())), + } + switch C.boxPowerSource(&batteryLevel) { + case 1: + state.PowerSource = "battery" + case 2: + state.PowerSource = "ac" + } + state.BatteryLevel = int(batteryLevel) + return state, true +} + +func thermalStateName(state int) string { + switch state { + case 0: + return "nominal" + case 1: + return "fair" + case 2: + return "serious" + case 3: + return "critical" + default: + return "" + } +} diff --git a/service/powerreport/device_darwin.h b/service/powerreport/device_darwin.h new file mode 100644 index 00000000..d7bcd423 --- /dev/null +++ b/service/powerreport/device_darwin.h @@ -0,0 +1,7 @@ +#pragma once + +int boxPowerLowPowerMode(void); + +int boxPowerThermalState(void); + +int boxPowerSource(int *level); diff --git a/service/powerreport/device_darwin.m b/service/powerreport/device_darwin.m new file mode 100644 index 00000000..4a8d5efa --- /dev/null +++ b/service/powerreport/device_darwin.m @@ -0,0 +1,121 @@ +#import +#import + +#include "device_darwin.h" + +#if TARGET_OS_IOS +#import +#include +#elif !TARGET_OS_IPHONE +#include +#include +#include +#endif + +int boxPowerLowPowerMode(void) { + @autoreleasepool { + return [[NSProcessInfo processInfo] isLowPowerModeEnabled] ? 1 : 0; + } +} + +int boxPowerThermalState(void) { + @autoreleasepool { + return (int) [[NSProcessInfo processInfo] thermalState]; + } +} + +#if TARGET_OS_IOS + +__attribute__((constructor)) static void boxPowerInitBatteryMonitoring(void) { + dispatch_async(dispatch_get_main_queue(), ^{ + @autoreleasepool { + [UIDevice currentDevice].batteryMonitoringEnabled = YES; + } + }); +} + +int boxPowerSource(int *level) { + @autoreleasepool { + *level = 0; + __block int source = 0; + __block float batteryLevel = -1; + dispatch_block_t read = ^{ + UIDevice *device = [UIDevice currentDevice]; + switch (device.batteryState) { + case UIDeviceBatteryStateUnplugged: + source = 1; + break; + case UIDeviceBatteryStateCharging: + case UIDeviceBatteryStateFull: + source = 2; + break; + default: + break; + } + batteryLevel = device.batteryLevel; + }; + if ([NSThread isMainThread]) { + read(); + } else { + dispatch_sync(dispatch_get_main_queue(), read); + } + if (batteryLevel >= 0) { + *level = (int) lroundf(batteryLevel * 100); + } + return source; + } +} + +#elif TARGET_OS_IPHONE + +int boxPowerSource(int *level) { + *level = 0; + return 0; +} + +#else + +int boxPowerSource(int *level) { + *level = 0; + CFTypeRef info = IOPSCopyPowerSourcesInfo(); + if (info == NULL) { + return 0; + } + CFArrayRef sources = IOPSCopyPowerSourcesList(info); + if (sources == NULL) { + CFRelease(info); + return 0; + } + int result = 0; + if (CFArrayGetCount(sources) > 0) { + CFDictionaryRef source = IOPSGetPowerSourceDescription(info, CFArrayGetValueAtIndex(sources, 0)); + if (source != NULL) { + CFStringRef state = CFDictionaryGetValue(source, CFSTR(kIOPSPowerSourceStateKey)); + if (state != NULL) { + if (CFStringCompare(state, CFSTR(kIOPSACPowerValue), 0) == kCFCompareEqualTo) { + result = 2; + } else if (CFStringCompare(state, CFSTR(kIOPSBatteryPowerValue), 0) == kCFCompareEqualTo) { + result = 1; + } + } + int current = 0; + int maximum = 0; + CFNumberRef currentValue = CFDictionaryGetValue(source, CFSTR(kIOPSCurrentCapacityKey)); + CFNumberRef maximumValue = CFDictionaryGetValue(source, CFSTR(kIOPSMaxCapacityKey)); + if (currentValue != NULL) { + CFNumberGetValue(currentValue, kCFNumberIntType, ¤t); + } + if (maximumValue != NULL) { + CFNumberGetValue(maximumValue, kCFNumberIntType, &maximum); + } + if (maximum > 0) { + *level = current * 100 / maximum; + } + } + } + CFRelease(sources); + CFRelease(info); + return result; +} + +#endif diff --git a/service/powerreport/device_stub.go b/service/powerreport/device_stub.go new file mode 100644 index 00000000..dfde18f6 --- /dev/null +++ b/service/powerreport/device_stub.go @@ -0,0 +1,7 @@ +//go:build !darwin || !cgo + +package powerreport + +func readDeviceState() (deviceState, bool) { + return deviceState{}, false +} diff --git a/service/powerreport/record.go b/service/powerreport/record.go index d0e3220c..94e37aa0 100644 --- a/service/powerreport/record.go +++ b/service/powerreport/record.go @@ -64,10 +64,30 @@ type timelineRow struct { DNSQueries uint64 `json:"dnsQueries,omitempty"` ConnectionsOpened uint64 `json:"connectionsOpened,omitempty"` InterfacePackets map[string]uint64 `json:"interfacePackets,omitempty"` + InterfaceBytes map[string]uint64 `json:"interfaceBytes,omitempty"` + Traffic trafficBreakdown `json:"traffic,omitempty"` + DNSDomains map[string]uint64 `json:"dnsDomains,omitempty"` NetworkType string `json:"network,omitempty"` NetworkPathUpdates uint64 `json:"pathUpdates,omitempty"` } +type trafficBreakdown map[TrafficKind]map[string]*trafficDelta + +type trafficDelta struct { + In uint64 `json:"in,omitempty"` + Out uint64 `json:"out,omitempty"` +} + +type trafficKey struct { + kind TrafficKind + tag string +} + +type trafficBytes struct { + inBytes uint64 + outBytes uint64 +} + type qosBreakdown struct { DefaultMS int64 `json:"default,omitempty"` MaintenanceMS int64 `json:"maintenance,omitempty"` @@ -79,9 +99,14 @@ type qosBreakdown struct { } const ( - eventTypeBreak = "break" - eventTypeNetwork = "network" - eventTypePath = "path" + eventTypeBreak = "break" + eventTypeNetwork = "network" + eventTypePath = "path" + eventTypeDevice = "device" + eventTypeDevicePause = "device-pause" + eventTypeDeviceWake = "device-wake" + eventTypeNetworkPause = "network-pause" + eventTypeNetworkWake = "network-wake" ) type eventRecord struct { @@ -93,6 +118,14 @@ type eventRecord struct { NetworkType string `json:"network,omitempty"` NetworkPath string `json:"path,omitempty"` By *Attribution `json:"by,omitempty"` + Device *deviceState `json:"device,omitempty"` +} + +type deviceState struct { + PowerSource string `json:"powerSource,omitempty"` + BatteryLevel int `json:"batteryLevel,omitempty"` + LowPowerMode bool `json:"lowPowerMode,omitempty"` + ThermalState string `json:"thermalState,omitempty"` } type systemUsage struct { @@ -116,6 +149,8 @@ type systemUsage struct { } type interfaceCounters struct { - inPackets uint32 - outPackets uint32 + inPackets uint64 + outPackets uint64 + inBytes uint64 + outBytes uint64 } diff --git a/service/powerreport/recorder.go b/service/powerreport/recorder.go index 5cb2767a..920464aa 100644 --- a/service/powerreport/recorder.go +++ b/service/powerreport/recorder.go @@ -1,11 +1,13 @@ package powerreport import ( + "cmp" "encoding/json" "os" "path/filepath" "runtime/metrics" "runtime/pprof" + "slices" "sync" "sync/atomic" "time" @@ -13,6 +15,7 @@ import ( E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" "github.com/sagernet/sing/common/memory" + "github.com/sagernet/sing/service/pause" ) const ( @@ -34,6 +37,11 @@ const ( rowCapacity = 4096 eventCapacity = 8192 + + maxDNSDomains = 512 + maxRowDNSDomains = 8 + maxProfiles = 3 + profileMinInterval = 2 * time.Minute ) type Options struct { @@ -74,16 +82,26 @@ type Recorder struct { connectionsOpened atomic.Uint64 networkPathUpdates atomic.Uint64 - access sync.Mutex - networkType string - networkPath string - rows []timelineRow - events []eventRecord - previous previousSample - lastFlushAt time.Time - started bool - closed bool - metricsSamples []metrics.Sample + trafficAccess sync.RWMutex + traffic map[trafficKey]*TrafficCounter + + dnsAccess sync.Mutex + dnsDomains map[string]uint64 + + access sync.Mutex + networkType string + networkPath string + rows []timelineRow + events []eventRecord + previous previousSample + lastFlushAt time.Time + started bool + closed bool + metricsSamples []metrics.Sample + lastDeviceState deviceState + deviceStateValid bool + profiles []profileEntry + lastProfileAt time.Time done chan struct{} workerDone chan struct{} @@ -108,6 +126,17 @@ type previousSample struct { dnsQueries uint64 connectionsOpened uint64 networkPathUpdates uint64 + traffic map[trafficKey]trafficBytes +} + +type profileEntry struct { + rate float64 + path string +} + +type profileRequest struct { + writePath string + removePath string } func NewRecorder(options Options) *Recorder { @@ -144,6 +173,8 @@ func NewRecorder(options Options) *Recorder { fallbackInterval: fallbackInterval, baseTime: time.Now(), notify: make(chan struct{}, 1), + traffic: make(map[trafficKey]*TrafficCounter), + dnsDomains: make(map[string]uint64), metricsSamples: []metrics.Sample{ {Name: "/cpu/classes/gc/total:cpu-seconds"}, {Name: "/sched/goroutines:goroutines"}, @@ -181,6 +212,7 @@ func (r *Recorder) Start() error { r.lastSampleAt.Store(int64(now.Sub(r.baseTime))) r.lastFlushAt = now r.started = true + r.updateDeviceStateLocked(now) go r.worker() return nil } @@ -248,8 +280,30 @@ func (r *Recorder) Touch(direction Direction, size int, by *Attribution) { } } -func (r *Recorder) CountDNSQuery() { +func (r *Recorder) CountDNSQuery(domain string) { r.dnsQueries.Add(1) + if domain == "" { + return + } + r.dnsAccess.Lock() + count, found := r.dnsDomains[domain] + if found || len(r.dnsDomains) < maxDNSDomains { + r.dnsDomains[domain] = count + 1 + } + r.dnsAccess.Unlock() +} + +func (r *Recorder) RecordPauseEvent(event int) { + switch event { + case pause.EventDevicePaused: + r.RecordPlatformEvent(eventTypeDevicePause) + case pause.EventDeviceWake: + r.RecordPlatformEvent(eventTypeDeviceWake) + case pause.EventNetworkPause: + r.RecordPlatformEvent(eventTypeNetworkPause) + case pause.EventNetworkWake: + r.RecordPlatformEvent(eventTypeNetworkWake) + } } func (r *Recorder) CountConnectionOpened() { @@ -332,19 +386,24 @@ func (r *Recorder) worker() { func (r *Recorder) process() { now := time.Now() r.access.Lock() - defer r.access.Unlock() if !r.started || r.closed { + r.access.Unlock() return } r.consumeBreakLocked() + var request *profileRequest nowNano := int64(now.Sub(r.baseTime)) if nowNano-r.lastSampleAt.Load() >= r.sampleNano { r.lastSampleAt.Store(nowNano) - r.sampleLocked(now) + request = r.sampleLocked(now) } if now.Sub(r.lastFlushAt) >= r.flushInterval || len(r.rows) >= rowCapacity || len(r.events) >= eventCapacity { r.flushLocked(now) } + r.access.Unlock() + if request != nil { + r.writeSampleProfile(request) + } } func (r *Recorder) consumeBreakLocked() { @@ -374,11 +433,12 @@ func (r *Recorder) resetPreviousLocked(now time.Time) { dnsQueries: r.dnsQueries.Load(), connectionsOpened: r.connectionsOpened.Load(), networkPathUpdates: r.networkPathUpdates.Load(), + traffic: r.snapshotTrafficLocked(), } r.previous.absoluteTime, r.previous.continuousTime = readClocks() } -func (r *Recorder) sampleLocked(now time.Time) { +func (r *Recorder) sampleLocked(now time.Time) *profileRequest { previous := r.previous r.resetPreviousLocked(now) current := &r.previous @@ -391,7 +451,9 @@ func (r *Recorder) sampleLocked(now time.Time) { GoMemoryBytes: r.metricsSamples[3].Value.Uint64(), GoHeapLiveBytes: r.metricsSamples[4].Value.Uint64(), DNSQueries: current.dnsQueries - previous.dnsQueries, + DNSDomains: r.collectDNSDomains(), ConnectionsOpened: current.connectionsOpened - previous.connectionsOpened, + Traffic: trafficDeltas(current.traffic, previous.traffic), NetworkType: r.networkType, NetworkPathUpdates: current.networkPathUpdates - previous.networkPathUpdates, } @@ -433,21 +495,152 @@ func (r *Recorder) sampleLocked(now time.Time) { } if len(current.interfaces) > 0 && len(previous.interfaces) > 0 { interfacePackets := make(map[string]uint64) + interfaceBytes := make(map[string]uint64) for name, counters := range current.interfaces { previousCounters, found := previous.interfaces[name] if !found { continue } - delta := uint64(counters.inPackets-previousCounters.inPackets) + uint64(counters.outPackets-previousCounters.outPackets) - if delta > 0 { - interfacePackets[name] = delta + packets := counters.inPackets - previousCounters.inPackets + counters.outPackets - previousCounters.outPackets + if packets > 0 { + interfacePackets[name] = packets + } + bytesDelta := counters.inBytes - previousCounters.inBytes + counters.outBytes - previousCounters.outBytes + if bytesDelta > 0 { + interfaceBytes[name] = bytesDelta } } if len(interfacePackets) > 0 { row.InterfacePackets = interfacePackets } + if len(interfaceBytes) > 0 { + row.InterfaceBytes = interfaceBytes + } } + r.updateDeviceStateLocked(now) r.rows = append(r.rows, row) + return r.requestProfileLocked(now, &row, now.Sub(previous.at)) +} + +func (r *Recorder) collectDNSDomains() map[string]uint64 { + r.dnsAccess.Lock() + defer r.dnsAccess.Unlock() + if len(r.dnsDomains) == 0 { + return nil + } + type domainCount struct { + domain string + count uint64 + } + counts := make([]domainCount, 0, len(r.dnsDomains)) + for domain, count := range r.dnsDomains { + counts = append(counts, domainCount{domain, count}) + } + clear(r.dnsDomains) + slices.SortFunc(counts, func(a domainCount, b domainCount) int { + return cmp.Compare(b.count, a.count) + }) + if len(counts) > maxRowDNSDomains { + counts = counts[:maxRowDNSDomains] + } + result := make(map[string]uint64, len(counts)) + for _, entry := range counts { + result[entry.domain] = entry.count + } + return result +} + +func trafficDeltas(current map[trafficKey]trafficBytes, previous map[trafficKey]trafficBytes) trafficBreakdown { + var result trafficBreakdown + for key, currentBytes := range current { + previousBytes := previous[key] + delta := trafficDelta{ + In: currentBytes.inBytes - previousBytes.inBytes, + Out: currentBytes.outBytes - previousBytes.outBytes, + } + if delta == (trafficDelta{}) { + continue + } + if result == nil { + result = make(trafficBreakdown) + } + byTag := result[key.kind] + if byTag == nil { + byTag = make(map[string]*trafficDelta) + result[key.kind] = byTag + } + byTag[key.tag] = &delta + } + return result +} + +func (r *Recorder) updateDeviceStateLocked(now time.Time) { + state, loaded := readDeviceState() + if !loaded { + return + } + if r.deviceStateValid && r.lastDeviceState == state { + return + } + r.lastDeviceState = state + r.deviceStateValid = true + r.events = append(r.events, eventRecord{ + Type: eventTypeDevice, + At: now.UTC().Format(time.RFC3339), + Device: &state, + }) +} + +func (r *Recorder) requestProfileLocked(now time.Time, row *timelineRow, duration time.Duration) *profileRequest { + if r.closed || duration <= 0 { + return nil + } + work := float64(row.EnergyNanojoules) + if work == 0 { + work = float64(row.CPUUserMS + row.CPUSystemMS) + } + if work == 0 { + return nil + } + rate := work / duration.Seconds() + lowestIndex := -1 + if len(r.profiles) >= maxProfiles { + lowestIndex = 0 + for index := 1; index < len(r.profiles); index++ { + if r.profiles[index].rate < r.profiles[lowestIndex].rate { + lowestIndex = index + } + } + if rate <= r.profiles[lowestIndex].rate { + return nil + } + } + if !r.lastProfileAt.IsZero() && now.Sub(r.lastProfileAt) < profileMinInterval { + return nil + } + request := &profileRequest{ + writePath: filepath.Join(r.draftPath, "goroutine-"+now.UTC().Format("150405")+".pb"), + } + if lowestIndex >= 0 { + request.removePath = r.profiles[lowestIndex].path + r.profiles = slices.Delete(r.profiles, lowestIndex, lowestIndex+1) + } + r.profiles = append(r.profiles, profileEntry{rate: rate, path: request.writePath}) + r.lastProfileAt = now + return request +} + +func (r *Recorder) writeSampleProfile(request *profileRequest) { + if request.removePath != "" { + os.Remove(request.removePath) + } + file, err := os.OpenFile(request.writePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o666) + if err != nil { + return + } + defer file.Close() + r.chown(request.writePath) + pprof.Lookup("goroutine").WriteTo(file, 0) } func (r *Recorder) chown(path string) { diff --git a/service/powerreport/sysstats_darwin.go b/service/powerreport/sysstats_darwin.go index 632d7440..332e130a 100644 --- a/service/powerreport/sysstats_darwin.go +++ b/service/powerreport/sysstats_darwin.go @@ -1,7 +1,6 @@ package powerreport /* -#include #include #include #include @@ -16,9 +15,11 @@ int proc_pid_rusage(int pid, int flavor, rusage_info_t *buffer); import "C" import ( + "encoding/binary" "os" "strings" "sync" + "syscall" "unsafe" ) @@ -99,25 +100,35 @@ func readClocks() (absoluteTime int64, continuousTime int64) { } func readInterfaceCounters() map[string]interfaceCounters { - var list *C.struct_ifaddrs - if C.getifaddrs(&list) != 0 { + table, err := syscall.RouteRIB(syscall.NET_RT_IFLIST2, 0) + if err != nil { return nil } - defer C.freeifaddrs(list) + var message C.struct_if_msghdr2 + messageSize := int(unsafe.Sizeof(message)) + messageBytes := unsafe.Slice((*byte)(unsafe.Pointer(&message)), messageSize) result := make(map[string]interfaceCounters) - for entry := list; entry != nil; entry = entry.ifa_next { - if entry.ifa_addr == nil || entry.ifa_addr.sa_family != C.AF_LINK || entry.ifa_data == nil { - continue + for len(table) >= 4 { + messageLength := int(binary.NativeEndian.Uint16(table)) + if messageLength < 4 || messageLength > len(table) { + break } - name := C.GoString(entry.ifa_name) - if !strings.HasPrefix(name, "en") && !strings.HasPrefix(name, "pdp_ip") { - continue - } - data := (*C.struct_if_data)(entry.ifa_data) - result[name] = interfaceCounters{ - inPackets: uint32(data.ifi_ipackets), - outPackets: uint32(data.ifi_opackets), + if table[3] == syscall.RTM_IFINFO2 && messageLength >= messageSize { + copy(messageBytes, table[:messageSize]) + var nameBuffer [C.IF_NAMESIZE]C.char + if C.if_indextoname(C.uint(message.ifm_index), &nameBuffer[0]) != nil { + name := C.GoString(&nameBuffer[0]) + if strings.HasPrefix(name, "en") || strings.HasPrefix(name, "pdp_ip") { + result[name] = interfaceCounters{ + inPackets: uint64(message.ifm_data.ifi_ipackets), + outPackets: uint64(message.ifm_data.ifi_opackets), + inBytes: uint64(message.ifm_data.ifi_ibytes), + outBytes: uint64(message.ifm_data.ifi_obytes), + } + } + } } + table = table[messageLength:] } return result } diff --git a/service/powerreport/sysstats_linux.go b/service/powerreport/sysstats_linux.go index cdc82f14..366d7307 100644 --- a/service/powerreport/sysstats_linux.go +++ b/service/powerreport/sysstats_linux.go @@ -83,9 +83,16 @@ func readInterfaceCounters() map[string]interfaceCounters { if inErr != nil || outErr != nil { continue } + inBytes, inBytesErr := strconv.ParseUint(fields[0], 10, 64) + outBytes, outBytesErr := strconv.ParseUint(fields[8], 10, 64) + if inBytesErr != nil || outBytesErr != nil { + continue + } result[name] = interfaceCounters{ - inPackets: uint32(inPackets), - outPackets: uint32(outPackets), + inPackets: inPackets, + outPackets: outPackets, + inBytes: inBytes, + outBytes: outBytes, } } return result diff --git a/service/powerreport/traffic.go b/service/powerreport/traffic.go new file mode 100644 index 00000000..41c0f3ea --- /dev/null +++ b/service/powerreport/traffic.go @@ -0,0 +1,73 @@ +package powerreport + +import "sync/atomic" + +type TrafficKind string + +const ( + TrafficOutbound TrafficKind = "outbound" + TrafficEndpoint TrafficKind = "endpoint" + TrafficDNSTransport TrafficKind = "dns" +) + +const maxTrafficCounters = 512 + +type TrafficCounter struct { + inBytes atomic.Uint64 + outBytes atomic.Uint64 +} + +func (c *TrafficCounter) CountIn(n int64) { + if c == nil || n <= 0 { + return + } + c.inBytes.Add(uint64(n)) +} + +func (c *TrafficCounter) CountOut(n int64) { + if c == nil || n <= 0 { + return + } + c.outBytes.Add(uint64(n)) +} + +func (r *Recorder) TrafficCounter(kind TrafficKind, tag string) *TrafficCounter { + if r == nil || tag == "" { + return nil + } + key := trafficKey{kind: kind, tag: tag} + r.trafficAccess.RLock() + counter := r.traffic[key] + r.trafficAccess.RUnlock() + if counter != nil { + return counter + } + r.trafficAccess.Lock() + defer r.trafficAccess.Unlock() + counter = r.traffic[key] + if counter != nil { + return counter + } + if len(r.traffic) >= maxTrafficCounters { + return nil + } + counter = new(TrafficCounter) + r.traffic[key] = counter + return counter +} + +func (r *Recorder) snapshotTrafficLocked() map[trafficKey]trafficBytes { + r.trafficAccess.RLock() + defer r.trafficAccess.RUnlock() + if len(r.traffic) == 0 { + return nil + } + snapshot := make(map[trafficKey]trafficBytes, len(r.traffic)) + for key, counter := range r.traffic { + snapshot[key] = trafficBytes{ + inBytes: counter.inBytes.Load(), + outBytes: counter.outBytes.Load(), + } + } + return snapshot +} diff --git a/transport/wireguard/endpoint.go b/transport/wireguard/endpoint.go index 7237c09c..f942851f 100644 --- a/transport/wireguard/endpoint.go +++ b/transport/wireguard/endpoint.go @@ -158,9 +158,12 @@ func (e *Endpoint) Start(postStart bool) error { recorder := powerManager.Recorder() if recorder != nil { attribution := &powerreport.Attribution{Endpoint: e.options.Tag} + counter := recorder.TrafficCounter(powerreport.TrafficEndpoint, e.options.Tag) standardBind.SetIOActivityFuncs(func(size int) { + counter.CountIn(int64(size)) recorder.Touch(powerreport.DirectionInbound, size, attribution) }, func(size int) { + counter.CountOut(int64(size)) recorder.Touch(powerreport.DirectionOutbound, size, attribution) }) }