v221 ① windows probe: gate optional procs via Find + per-source recover guard
LazyProc.Call must-finds and panics on a missing export; the trimmed Tianyi cloud-PC image has no FreeMibIfTable2 in iphlpapi.dll, which killed the agent on its first collection after a successful register. Optional procs (GetSystemTimes/GlobalMemoryStatusEx/GetIfTable2Ex+ FreeMibIfTable2 as one gated pair) now resolve through sourceGate (Find once, missing = source disabled with one warning); the iface pair is gated together so a missing free proc never leaks the system table. collectMetrics additionally runs every source through guardedCall so any residual panic zeroes that source alone. Pure gate/guard logic lives in probe_guard.go (no build tag) with Linux-runnable regression tests.
This commit is contained in:
@@ -0,0 +1,56 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file holds the probe collector's degrade-don't-die plumbing:
|
||||||
|
// the per-source independence discipline as reusable mechanics,
|
||||||
|
// shared by every platform sampler but exercised today by the Windows
|
||||||
|
// sampler (the Linux sampler reads /proc files, whose failure mode is
|
||||||
|
// an error return, not a panic).
|
||||||
|
//
|
||||||
|
// Two layers:
|
||||||
|
//
|
||||||
|
// - source gates: an optional OS facility (a Win32 procedure, say)
|
||||||
|
// is probed once; when it is missing the source is skipped —
|
||||||
|
// zeroed — with a single warning, never re-probed, and never
|
||||||
|
// reached through a call path that panics on a missing export the
|
||||||
|
// way LazyProc.Call does when it must-finds a procedure;
|
||||||
|
// - guarded calls: a panicking source is recovered, zeroed and
|
||||||
|
// logged, so one bad source can never take the agent process (and
|
||||||
|
// every other source with it) down.
|
||||||
|
|
||||||
|
// sourceGate caches one availability probe. The zero value is ready.
|
||||||
|
type sourceGate struct {
|
||||||
|
once sync.Once
|
||||||
|
available bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// enabled runs probe on first use and reports whether the source may
|
||||||
|
// be used. probe returns nil when the facility is present. A failed
|
||||||
|
// probe disables the source for the process lifetime and logs once.
|
||||||
|
func (g *sourceGate) enabled(name string, probe func() error) bool {
|
||||||
|
g.once.Do(func() {
|
||||||
|
if err := probe(); err != nil {
|
||||||
|
logger.Warningf("probe: %s unavailable, source disabled: %v", name, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g.available = true
|
||||||
|
})
|
||||||
|
return g.available
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardedCall runs fn and converts a panic into the zero value plus a
|
||||||
|
// warning, so one failing metrics source degrades alone.
|
||||||
|
func guardedCall[T any](name string, fn func() T) (out T) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
logger.Warningf("probe: %s panicked, source zeroed: %v", name, r)
|
||||||
|
out = *new(T)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return fn()
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The degrade-don't-die mechanics behind V2.2.1 ①: on the trimmed
|
||||||
|
// Tianyi cloud-PC image the Windows sampler died on its first
|
||||||
|
// collection because a missing iphlpapi export made LazyProc.Call
|
||||||
|
// panic. These tests pin the two layers that replace that failure
|
||||||
|
// mode — a failed availability probe disables the source (probed
|
||||||
|
// once, not per poll), and a panicking source is zeroed, never
|
||||||
|
// propagated.
|
||||||
|
|
||||||
|
func TestSourceGateDisablesFailedProbeOnce(t *testing.T) {
|
||||||
|
var g sourceGate
|
||||||
|
calls := 0
|
||||||
|
probe := func() error {
|
||||||
|
calls++
|
||||||
|
return errors.New("procedure not found")
|
||||||
|
}
|
||||||
|
for i := range 3 {
|
||||||
|
if g.enabled("test-source", probe) {
|
||||||
|
t.Fatalf("call %d: gate enabled despite failed probe", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if calls != 1 {
|
||||||
|
t.Fatalf("probe ran %d times, want exactly 1 (cached verdict)", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSourceGateEnablesSuccessfulProbeOnce(t *testing.T) {
|
||||||
|
var g sourceGate
|
||||||
|
calls := 0
|
||||||
|
probe := func() error {
|
||||||
|
calls++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for i := range 3 {
|
||||||
|
if !g.enabled("test-source", probe) {
|
||||||
|
t.Fatalf("call %d: gate disabled despite successful probe", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if calls != 1 {
|
||||||
|
t.Fatalf("probe ran %d times, want exactly 1 (cached verdict)", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGuardedCallRecoversPanicToZero(t *testing.T) {
|
||||||
|
got := guardedCall("test-source", func() uint64 {
|
||||||
|
panic("Failed to find FreeMibIfTable2 procedure in iphlpapi.dll")
|
||||||
|
})
|
||||||
|
if got != 0 {
|
||||||
|
t.Fatalf("guardedCall after panic = %d, want zero value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGuardedCallPassesValueThrough(t *testing.T) {
|
||||||
|
if got := guardedCall("test-source", func() string { return "ok" }); got != "ok" {
|
||||||
|
t.Fatalf("guardedCall = %q, want %q", got, "ok")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,14 @@ package agent
|
|||||||
// degradation discipline Load stays zero and TCP/UDP stay 0
|
// degradation discipline Load stays zero and TCP/UDP stay 0
|
||||||
// until a wrapper exists;
|
// until a wrapper exists;
|
||||||
// - every call fails soft to zeros, like an unreadable /proc file.
|
// - every call fails soft to zeros, like an unreadable /proc file.
|
||||||
|
// Optional procedures are resolved through source gates
|
||||||
|
// (probe_guard.go): LazyProc.Call must-finds and PANICS on a
|
||||||
|
// missing export, and trimmed images do ship DLLs without the
|
||||||
|
// full surface — the Tianyi cloud PC's iphlpapi.dll has no
|
||||||
|
// FreeMibIfTable2, which killed the V2.2.0 agent on its first
|
||||||
|
// collection (V2.2.1 ①). A missing procedure now disables just
|
||||||
|
// its source, and collectMetrics additionally guards each source
|
||||||
|
// so a panic anywhere degrades that source alone.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
@@ -50,15 +58,35 @@ import (
|
|||||||
"golang.org/x/sys/windows/registry"
|
"golang.org/x/sys/windows/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
// kernel32 procs x/sys/windows v0.47.0 does not wrap.
|
// kernel32 procs x/sys/windows v0.47.0 does not wrap, plus the
|
||||||
|
// iphlpapi pair this file calls directly (gated below, so x/sys'
|
||||||
|
// own GetIfTable2Ex wrapper — whose Addr() panics the same way on a
|
||||||
|
// missing export — is deliberately not used).
|
||||||
var (
|
var (
|
||||||
kernel32DLL = windows.NewLazySystemDLL("kernel32.dll")
|
kernel32DLL = windows.NewLazySystemDLL("kernel32.dll")
|
||||||
procGetSystemTimes = kernel32DLL.NewProc("GetSystemTimes")
|
procGetSystemTimes = kernel32DLL.NewProc("GetSystemTimes")
|
||||||
procGlobalMemoryStatus = kernel32DLL.NewProc("GlobalMemoryStatusEx")
|
procGlobalMemoryStatus = kernel32DLL.NewProc("GlobalMemoryStatusEx")
|
||||||
iphlpapiDLL = windows.NewLazySystemDLL("iphlpapi.dll")
|
iphlpapiDLL = windows.NewLazySystemDLL("iphlpapi.dll")
|
||||||
|
procGetIfTable2Ex = iphlpapiDLL.NewProc("GetIfTable2Ex")
|
||||||
procFreeMibIfTable2 = iphlpapiDLL.NewProc("FreeMibIfTable2")
|
procFreeMibIfTable2 = iphlpapiDLL.NewProc("FreeMibIfTable2")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Availability gates for the optional procedures above. A LazyProc
|
||||||
|
// must never be Call()ed before its gate has proven the export
|
||||||
|
// exists: Call must-finds, so a missing procedure is a process-killing
|
||||||
|
// panic, not an error.
|
||||||
|
var (
|
||||||
|
gateSystemTimes sourceGate
|
||||||
|
gateMemoryStatus sourceGate
|
||||||
|
gateIfTable sourceGate
|
||||||
|
)
|
||||||
|
|
||||||
|
// procProbe adapts a LazyProc to a source-gate probe: Find resolves
|
||||||
|
// the export WITHOUT the mustFind panic baked into Call/Addr.
|
||||||
|
func procProbe(p *windows.LazyProc) func() error {
|
||||||
|
return func() error { return p.Find() }
|
||||||
|
}
|
||||||
|
|
||||||
func sampleFacts() *ProbeFacts {
|
func sampleFacts() *ProbeFacts {
|
||||||
memTotal, _, swapTotal, _ := winMemory()
|
memTotal, _, swapTotal, _ := winMemory()
|
||||||
diskTotal, _ := winDiskUsage()
|
diskTotal, _ := winDiskUsage()
|
||||||
@@ -80,12 +108,26 @@ func sampleFacts() *ProbeFacts {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// memSnapshot / diskSnapshot pack the multi-value samplers so each
|
||||||
|
// can run as one guarded source.
|
||||||
|
type memSnapshot struct{ total, used, swapTotal, swapUsed uint64 }
|
||||||
|
type diskSnapshot struct{ total, used uint64 }
|
||||||
|
|
||||||
func collectMetrics() *ProbeMetrics {
|
func collectMetrics() *ProbeMetrics {
|
||||||
probeColl.mu.Lock()
|
probeColl.mu.Lock()
|
||||||
defer probeColl.mu.Unlock()
|
defer probeColl.mu.Unlock()
|
||||||
memTotal, memUsed, swapTotal, swapUsed := winMemory()
|
// Every source runs guarded: a panic in one (a Win32 surface the
|
||||||
diskTotal, diskUsed := winDiskUsage()
|
// gates did not foresee) zeroes that source alone instead of
|
||||||
rows := winIfaceRows()
|
// killing the agent on its first collection.
|
||||||
|
mem := guardedCall("memory", func() memSnapshot {
|
||||||
|
total, used, swapTotal, swapUsed := winMemory()
|
||||||
|
return memSnapshot{total, used, swapTotal, swapUsed}
|
||||||
|
})
|
||||||
|
disk := guardedCall("disk", func() diskSnapshot {
|
||||||
|
total, used := winDiskUsage()
|
||||||
|
return diskSnapshot{total, used}
|
||||||
|
})
|
||||||
|
rows := guardedCall("interfaces", winIfaceRows)
|
||||||
all := make([]netCounters, 0, len(rows))
|
all := make([]netCounters, 0, len(rows))
|
||||||
counted := make([]netCounters, 0, len(rows))
|
counted := make([]netCounters, 0, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
@@ -107,23 +149,25 @@ func collectMetrics() *ProbeMetrics {
|
|||||||
}
|
}
|
||||||
rxRate, txRate := probeColl.netRate(counted, time.Now())
|
rxRate, txRate := probeColl.netRate(counted, time.Now())
|
||||||
return &ProbeMetrics{
|
return &ProbeMetrics{
|
||||||
BootID: bootEpoch(winMachineGUID(), names),
|
BootID: bootEpoch(winMachineGUID(), names),
|
||||||
Uptime: uint64(windows.DurationSinceBoot().Seconds()),
|
Uptime: guardedCall("uptime", func() uint64 {
|
||||||
CPU: probeColl.cpuPercent(winCPUSample()),
|
return uint64(windows.DurationSinceBoot().Seconds())
|
||||||
|
}),
|
||||||
|
CPU: probeColl.cpuPercent(guardedCall("cpu", winCPUSample)),
|
||||||
Load: [3]float32{},
|
Load: [3]float32{},
|
||||||
MemTotal: memTotal,
|
MemTotal: mem.total,
|
||||||
MemUsed: memUsed,
|
MemUsed: mem.used,
|
||||||
SwapTotal: swapTotal,
|
SwapTotal: mem.swapTotal,
|
||||||
SwapUsed: swapUsed,
|
SwapUsed: mem.swapUsed,
|
||||||
DiskTotal: diskTotal,
|
DiskTotal: disk.total,
|
||||||
DiskUsed: diskUsed,
|
DiskUsed: disk.used,
|
||||||
NetRxTotal: rxTotal,
|
NetRxTotal: rxTotal,
|
||||||
NetTxTotal: txTotal,
|
NetTxTotal: txTotal,
|
||||||
NetRx: rxRate,
|
NetRx: rxRate,
|
||||||
NetTx: txRate,
|
NetTx: txRate,
|
||||||
TCP: 0, // no exported x/sys wrapper (see header)
|
TCP: 0, // no exported x/sys wrapper (see header)
|
||||||
UDP: 0, // no exported x/sys wrapper (see header)
|
UDP: 0, // no exported x/sys wrapper (see header)
|
||||||
Procs: winProcCount(),
|
Procs: guardedCall("processes", winProcCount),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +256,9 @@ func filetime100ns(ft windows.Filetime) uint64 {
|
|||||||
// winCPUSample reads the cumulative system times as one cpuSample:
|
// winCPUSample reads the cumulative system times as one cpuSample:
|
||||||
// total = kernel + user (kernel already includes idle, like jiffies).
|
// total = kernel + user (kernel already includes idle, like jiffies).
|
||||||
func winCPUSample() *cpuSample {
|
func winCPUSample() *cpuSample {
|
||||||
|
if !gateSystemTimes.enabled("GetSystemTimes", procProbe(procGetSystemTimes)) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
var idle, kernel, user windows.Filetime
|
var idle, kernel, user windows.Filetime
|
||||||
r, _, _ := procGetSystemTimes.Call(
|
r, _, _ := procGetSystemTimes.Call(
|
||||||
uintptr(unsafe.Pointer(&idle)),
|
uintptr(unsafe.Pointer(&idle)),
|
||||||
@@ -248,6 +295,9 @@ func usedFromTotalAvail(total, avail uint64) uint64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func winMemory() (total, used, swapTotal, swapUsed uint64) {
|
func winMemory() (total, used, swapTotal, swapUsed uint64) {
|
||||||
|
if !gateMemoryStatus.enabled("GlobalMemoryStatusEx", procProbe(procGlobalMemoryStatus)) {
|
||||||
|
return 0, 0, 0, 0
|
||||||
|
}
|
||||||
var ms memoryStatusEx
|
var ms memoryStatusEx
|
||||||
ms.Length = uint32(unsafe.Sizeof(ms))
|
ms.Length = uint32(unsafe.Sizeof(ms))
|
||||||
r, _, _ := procGlobalMemoryStatus.Call(uintptr(unsafe.Pointer(&ms)))
|
r, _, _ := procGlobalMemoryStatus.Call(uintptr(unsafe.Pointer(&ms)))
|
||||||
@@ -359,8 +409,25 @@ func winIfaceCounts(r winIfaceRow) bool {
|
|||||||
// derives both the all-interfaces list (for the operator pin) and the
|
// derives both the all-interfaces list (for the operator pin) and the
|
||||||
// counted set from it.
|
// counted set from it.
|
||||||
func winIfaceRows() []winIfaceRow {
|
func winIfaceRows() []winIfaceRow {
|
||||||
|
// The get/free pair is gated as ONE source: calling GetIfTable2Ex
|
||||||
|
// without FreeMibIfTable2 would leak the system-allocated table
|
||||||
|
// on every poll, and either export missing must degrade the
|
||||||
|
// source to zero rows instead of panicking — the trimmed Tianyi
|
||||||
|
// cloud-PC image ships an iphlpapi.dll without FreeMibIfTable2,
|
||||||
|
// and the V2.2.0 sampler's unguarded LazyProc.Call died on it.
|
||||||
|
if !gateIfTable.enabled("GetIfTable2Ex/FreeMibIfTable2 (iphlpapi)", func() error {
|
||||||
|
if err := procGetIfTable2Ex.Find(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return procFreeMibIfTable2.Find()
|
||||||
|
}) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
var table *windows.MibIfTable2
|
var table *windows.MibIfTable2
|
||||||
if err := windows.GetIfTable2Ex(windows.MibIfTableNormal, &table); err != nil || table == nil {
|
r, _, _ := procGetIfTable2Ex.Call(
|
||||||
|
uintptr(windows.MibIfTableNormal),
|
||||||
|
uintptr(unsafe.Pointer(&table)))
|
||||||
|
if r != 0 || table == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
defer procFreeMibIfTable2.Call(uintptr(unsafe.Pointer(table)))
|
defer procFreeMibIfTable2.Call(uintptr(unsafe.Pointer(table)))
|
||||||
|
|||||||
Reference in New Issue
Block a user