From 92dfdc542cf68b38adc1bd388d8cc83ec9cf387f Mon Sep 17 00:00:00 2001 From: Muse Date: Fri, 23 Oct 2026 14:26:22 +0000 Subject: [PATCH 1/7] =?UTF-8?q?v221=20=E2=91=A0=20windows=20probe:=20gate?= =?UTF-8?q?=20optional=20procs=20via=20Find=20+=20per-source=20recover=20g?= =?UTF-8?q?uard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/agent/probe_guard.go | 56 +++++++++++++++++ internal/agent/probe_guard_test.go | 63 +++++++++++++++++++ internal/agent/probe_windows.go | 97 +++++++++++++++++++++++++----- 3 files changed, 201 insertions(+), 15 deletions(-) create mode 100644 internal/agent/probe_guard.go create mode 100644 internal/agent/probe_guard_test.go diff --git a/internal/agent/probe_guard.go b/internal/agent/probe_guard.go new file mode 100644 index 0000000..556853c --- /dev/null +++ b/internal/agent/probe_guard.go @@ -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() +} diff --git a/internal/agent/probe_guard_test.go b/internal/agent/probe_guard_test.go new file mode 100644 index 0000000..6d41b0a --- /dev/null +++ b/internal/agent/probe_guard_test.go @@ -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") + } +} diff --git a/internal/agent/probe_windows.go b/internal/agent/probe_windows.go index 041b3ea..af29e1c 100644 --- a/internal/agent/probe_windows.go +++ b/internal/agent/probe_windows.go @@ -35,6 +35,14 @@ package agent // degradation discipline Load stays zero and TCP/UDP stay 0 // until a wrapper exists; // - 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 ( "net" @@ -50,15 +58,35 @@ import ( "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 ( kernel32DLL = windows.NewLazySystemDLL("kernel32.dll") procGetSystemTimes = kernel32DLL.NewProc("GetSystemTimes") procGlobalMemoryStatus = kernel32DLL.NewProc("GlobalMemoryStatusEx") iphlpapiDLL = windows.NewLazySystemDLL("iphlpapi.dll") + procGetIfTable2Ex = iphlpapiDLL.NewProc("GetIfTable2Ex") 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 { memTotal, _, swapTotal, _ := winMemory() 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 { probeColl.mu.Lock() defer probeColl.mu.Unlock() - memTotal, memUsed, swapTotal, swapUsed := winMemory() - diskTotal, diskUsed := winDiskUsage() - rows := winIfaceRows() + // Every source runs guarded: a panic in one (a Win32 surface the + // gates did not foresee) zeroes that source alone instead of + // 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)) counted := make([]netCounters, 0, len(rows)) for _, r := range rows { @@ -107,23 +149,25 @@ func collectMetrics() *ProbeMetrics { } rxRate, txRate := probeColl.netRate(counted, time.Now()) return &ProbeMetrics{ - BootID: bootEpoch(winMachineGUID(), names), - Uptime: uint64(windows.DurationSinceBoot().Seconds()), - CPU: probeColl.cpuPercent(winCPUSample()), + BootID: bootEpoch(winMachineGUID(), names), + Uptime: guardedCall("uptime", func() uint64 { + return uint64(windows.DurationSinceBoot().Seconds()) + }), + CPU: probeColl.cpuPercent(guardedCall("cpu", winCPUSample)), Load: [3]float32{}, - MemTotal: memTotal, - MemUsed: memUsed, - SwapTotal: swapTotal, - SwapUsed: swapUsed, - DiskTotal: diskTotal, - DiskUsed: diskUsed, + MemTotal: mem.total, + MemUsed: mem.used, + SwapTotal: mem.swapTotal, + SwapUsed: mem.swapUsed, + DiskTotal: disk.total, + DiskUsed: disk.used, NetRxTotal: rxTotal, NetTxTotal: txTotal, NetRx: rxRate, NetTx: txRate, TCP: 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: // total = kernel + user (kernel already includes idle, like jiffies). func winCPUSample() *cpuSample { + if !gateSystemTimes.enabled("GetSystemTimes", procProbe(procGetSystemTimes)) { + return nil + } var idle, kernel, user windows.Filetime r, _, _ := procGetSystemTimes.Call( uintptr(unsafe.Pointer(&idle)), @@ -248,6 +295,9 @@ func usedFromTotalAvail(total, avail uint64) uint64 { } func winMemory() (total, used, swapTotal, swapUsed uint64) { + if !gateMemoryStatus.enabled("GlobalMemoryStatusEx", procProbe(procGlobalMemoryStatus)) { + return 0, 0, 0, 0 + } var ms memoryStatusEx ms.Length = uint32(unsafe.Sizeof(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 // counted set from it. 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 - 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 } defer procFreeMibIfTable2.Call(uintptr(unsafe.Pointer(table))) From 1df78dd8744e9631cb7b1bb1458f28f10597bbb8 Mon Sep 17 00:00:00 2001 From: Muse Date: Fri, 23 Oct 2026 14:26:31 +0000 Subject: [PATCH 2/7] =?UTF-8?q?v221=20=E2=91=A1=20node=20probe:=20agent=20?= =?UTF-8?q?nodes=20answer=20from=20heartbeat,=20not=20a=20dead=20dial?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NodeService.Probe now checks for a bound agent first: agent-managed nodes synthesize the result from agent_nodes.last_seen (MAX over bound agents, ms) — fresh within the same 180s window the heartbeat job expires by = online with the heartbeat age as latency; stale or never-seen = offline with LatencyMs -1. No dial, and LastError is never written for agent nodes (the stored dial error from the placeholder address https://agent:2053 is also healed on next ⚡, since the synthesized patch carries an empty error through UpdateHeartbeat). Traditional-node dial path unchanged; verdict is a pure function with unit tests. Frontend: the ⚡ tooltip/menu used t('pages.nodes.probe'), which since the probe-metadata work resolves to the status-page SECTION object, so i18next fell back to rendering the raw key. The existing pages.nodes.testConnection key (present in all 13 locales) is the right label — point the three usages at it. No new i18n keys. --- frontend/src/pages/nodes/NodeList.tsx | 6 +- internal/web/service/node.go | 67 ++++++++++++++++++ internal/web/service/node_agent_probe_test.go | 70 +++++++++++++++++++ 3 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 internal/web/service/node_agent_probe_test.go diff --git a/frontend/src/pages/nodes/NodeList.tsx b/frontend/src/pages/nodes/NodeList.tsx index 9e1bc4c..df31669 100644 --- a/frontend/src/pages/nodes/NodeList.tsx +++ b/frontend/src/pages/nodes/NodeList.tsx @@ -307,13 +307,13 @@ export default function NodeList({ ) : ( - +