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:
Muse
2026-10-23 14:26:22 +00:00
parent a25c51003e
commit 92dfdc542c
3 changed files with 201 additions and 15 deletions
+56
View File
@@ -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()
}
+63
View File
@@ -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")
}
}
+82 -15
View File
@@ -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)))