diff --git a/cpu/cpu.go b/cpu/cpu.go index 9bc3dfb51a..2af1cc2a57 100644 --- a/cpu/cpu.go +++ b/cpu/cpu.go @@ -152,6 +152,12 @@ func Percent(interval time.Duration, percpu bool) ([]float64, error) { } func PercentWithContext(ctx context.Context, interval time.Duration, percpu bool) ([]float64, error) { + // AIX TimesWithContext returns instantaneous percentages (not cumulative + // ticks), so the delta math in calculateBusy does not apply. + if runtime.GOOS == "aix" { + return aixPercent(ctx, percpu) + } + if interval <= 0 { return percentUsedFromLastCallWithContext(ctx, percpu) } @@ -180,6 +186,10 @@ func percentUsedFromLastCall(percpu bool) ([]float64, error) { } func percentUsedFromLastCallWithContext(ctx context.Context, percpu bool) ([]float64, error) { + if runtime.GOOS == "aix" { + return aixPercent(ctx, percpu) + } + cpuTimes, err := TimesWithContext(ctx, percpu) if err != nil { return nil, err diff --git a/cpu/cpu_aix.go b/cpu/cpu_aix.go index bc766bd4fe..bd5571d3a5 100644 --- a/cpu/cpu_aix.go +++ b/cpu/cpu_aix.go @@ -14,3 +14,17 @@ func Times(percpu bool) ([]TimesStat, error) { func Info() ([]InfoStat, error) { return InfoWithContext(context.Background()) } + +// aixPercent returns CPU busy percentages directly from TimesWithContext, +// which on AIX already contains instantaneous percentage values. +func aixPercent(ctx context.Context, percpu bool) ([]float64, error) { + times, err := TimesWithContext(ctx, percpu) + if err != nil { + return nil, err + } + ret := make([]float64, len(times)) + for i, t := range times { + ret[i] = t.User + t.System + t.Iowait + } + return ret, nil +} diff --git a/cpu/cpu_aix_nocgo.go b/cpu/cpu_aix_nocgo.go index 981e32e512..9e6191af77 100644 --- a/cpu/cpu_aix_nocgo.go +++ b/cpu/cpu_aix_nocgo.go @@ -14,7 +14,7 @@ import ( func TimesWithContext(ctx context.Context, percpu bool) ([]TimesStat, error) { var ret []TimesStat if percpu { - perOut, err := invoke.CommandWithContext(ctx, "sar", "-u", "-P", "ALL", "10", "1") + perOut, err := invoke.CommandWithContext(ctx, "sar", "-u", "-P", "ALL", "1", "1") if err != nil { return nil, err } @@ -25,11 +25,24 @@ func TimesWithContext(ctx context.Context, percpu bool) ([]TimesStat, error) { hp := strings.Fields(lines[5]) // headers for l := 6; l < len(lines)-1; l++ { - ct := &TimesStat{} v := strings.Fields(lines[l]) // values + if len(v) == 0 { + continue + } + + // Determine the CPU field position: first line has a timestamp + // prefix, continuation lines do not + cpuField := strings.TrimSpace(v[0]) + if l == 6 && len(v) > 1 { + cpuField = strings.TrimSpace(v[1]) + } + if _, err := strconv.Atoi(cpuField); err != nil { + continue + } + + ct := &TimesStat{} for i, header := range hp { - // We're done in any of these use cases - if i >= len(v) || v[0] == "-" { + if i >= len(v) { break } @@ -60,11 +73,10 @@ func TimesWithContext(ctx context.Context, percpu bool) ([]TimesStat, error) { } } } - // Valid CPU data, so append it ret = append(ret, *ct) } } else { - out, err := invoke.CommandWithContext(ctx, "sar", "-u", "10", "1") + out, err := invoke.CommandWithContext(ctx, "sar", "-u", "1", "1") if err != nil { return nil, err } diff --git a/cpu/cpu_notaix.go b/cpu/cpu_notaix.go new file mode 100644 index 0000000000..f97ae52497 --- /dev/null +++ b/cpu/cpu_notaix.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: BSD-3-Clause +//go:build !aix + +package cpu + +import ( + "context" + + "github.com/shirou/gopsutil/v4/internal/common" +) + +func aixPercent(_ context.Context, _ bool) ([]float64, error) { + return nil, common.ErrNotImplementedError +} diff --git a/disk/disk_aix_nocgo.go b/disk/disk_aix_nocgo.go index 4fe1258c8a..06efcbcb5a 100644 --- a/disk/disk_aix_nocgo.go +++ b/disk/disk_aix_nocgo.go @@ -26,8 +26,46 @@ var ( } ) -func IOCountersWithContext(_ context.Context, _ ...string) (map[string]IOCountersStat, error) { - return nil, common.ErrNotImplementedError +func IOCountersWithContext(ctx context.Context, names ...string) (map[string]IOCountersStat, error) { + out, err := invoke.CommandWithContext(ctx, "iostat", "-d") + if err != nil { + return nil, err + } + + ret := make(map[string]IOCountersStat) + lines := strings.Split(string(out), "\n") + for _, line := range lines { + fields := strings.Fields(line) + if len(fields) < 6 { + continue + } + // Skip the header line + if fields[0] == "Disks:" { + continue + } + + name := fields[0] + if len(names) > 0 && !common.StringsHas(names, name) { + continue + } + + kbRead, err := strconv.ParseUint(fields[4], 10, 64) + if err != nil { + continue + } + kbWritten, err := strconv.ParseUint(fields[5], 10, 64) + if err != nil { + continue + } + + ret[name] = IOCountersStat{ + Name: name, + ReadBytes: kbRead * 1024, + WriteBytes: kbWritten * 1024, + } + } + + return ret, nil } func PartitionsWithContext(ctx context.Context, _ bool) ([]PartitionStat, error) { @@ -131,21 +169,23 @@ func UsageWithContext(ctx context.Context, path string) (*UsageStat, error) { return nil, err } case `Used`: - ret.Used, err = strconv.ParseUint(fs[i], 10, 64) + used, err := strconv.ParseUint(fs[i], 10, 64) if err != nil { return nil, err } + ret.Used = used * blocksize case `Free`: - ret.Free, err = strconv.ParseUint(fs[i], 10, 64) + free, err := strconv.ParseUint(fs[i], 10, 64) if err != nil { return nil, err } + ret.Free = free * blocksize case `%Used`: val, err := strconv.ParseInt(strings.ReplaceAll(fs[i], "%", ""), 10, 32) if err != nil { return nil, err } - ret.UsedPercent = float64(val) / float64(100) + ret.UsedPercent = float64(val) case `Ifree`: ret.InodesFree, err = strconv.ParseUint(fs[i], 10, 64) if err != nil { @@ -161,7 +201,7 @@ func UsageWithContext(ctx context.Context, path string) (*UsageStat, error) { if err != nil { return nil, err } - ret.InodesUsedPercent = float64(val) / float64(100) + ret.InodesUsedPercent = float64(val) } } diff --git a/load/load_aix.go b/load/load_aix.go index eb5b5b0661..6dfdb5ca25 100644 --- a/load/load_aix.go +++ b/load/load_aix.go @@ -17,3 +17,13 @@ func Avg() (*AvgStat, error) { func Misc() (*MiscStat, error) { return MiscWithContext(context.Background()) } + +// SystemCalls returns the number of system calls since boot. +func SystemCalls() (int, error) { + return SystemCallsWithContext(context.Background()) +} + +// Interrupts returns the number of interrupts since boot. +func Interrupts() (int, error) { + return InterruptsWithContext(context.Background()) +} diff --git a/load/load_aix_cgo.go b/load/load_aix_cgo.go index 72d158bc62..0b1e500771 100644 --- a/load/load_aix_cgo.go +++ b/load/load_aix_cgo.go @@ -17,6 +17,7 @@ import ( "unsafe" "github.com/power-devops/perfstat" + "github.com/shirou/gopsutil/v4/internal/common" ) func AvgWithContext(ctx context.Context) (*AvgStat, error) { @@ -81,3 +82,19 @@ func MiscWithContext(ctx context.Context) (*MiscStat, error) { return &ret, nil } + +func SystemCallsWithContext(_ context.Context) (int, error) { + c, err := perfstat.CpuTotalStat() + if err != nil { + return 0, err + } + return int(c.Syscall), nil +} + +func InterruptsWithContext(_ context.Context) (int, error) { + c, err := perfstat.CpuTotalStat() + if err != nil { + return 0, err + } + return int(c.DevIntrs), nil +} diff --git a/load/load_aix_nocgo.go b/load/load_aix_nocgo.go index 5e4ba6d0f5..be1da392c6 100644 --- a/load/load_aix_nocgo.go +++ b/load/load_aix_nocgo.go @@ -15,8 +15,19 @@ import ( var separator = regexp.MustCompile(`,?\s+`) +// testInvoker is used for dependency injection in tests +var testInvoker common.Invoker + +// getInvoker returns the test invoker if set, otherwise returns the default +func getInvoker() common.Invoker { + if testInvoker != nil { + return testInvoker + } + return common.Invoke{} +} + func AvgWithContext(ctx context.Context) (*AvgStat, error) { - line, err := invoke.CommandWithContext(ctx, "uptime") + line, err := getInvoker().CommandWithContext(ctx, "uptime") if err != nil { return nil, err } @@ -44,24 +55,104 @@ func AvgWithContext(ctx context.Context) (*AvgStat, error) { return nil, common.ErrNotImplementedError } +// SystemCallsWithContext returns the cumulative number of system calls since boot +func SystemCallsWithContext(ctx context.Context) (int, error) { + _, _, syscalls, err := getVmstatMetrics(ctx) + return syscalls, err +} + +// InterruptsWithContext returns the cumulative number of device interrupts since boot +func InterruptsWithContext(ctx context.Context) (int, error) { + _, interrupts, _, err := getVmstatMetrics(ctx) + return interrupts, err +} + func MiscWithContext(ctx context.Context) (*MiscStat, error) { - out, err := invoke.CommandWithContext(ctx, "ps", "-Ao", "state") + out, err := getInvoker().CommandWithContext(ctx, "ps", "-e", "-o", "state") if err != nil { return nil, err } ret := &MiscStat{} - for _, line := range strings.Split(string(out), "\n") { - ret.ProcsTotal++ + lines := strings.Split(string(out), "\n") + + for _, line := range lines { + line = strings.TrimSpace(line) + + // Skip header line and empty lines + if line == "ST" || line == "STATE" || line == "S" || line == "" { + continue + } + + // Count processes by state (AIX process states from official docs) + // A = Active (running or ready to run) + // W = Swapped (not in main memory) + // I = Idle (waiting for startup) + // Z = Canceled (zombie - terminated, waiting for parent) + // T = Stopped (trace stopped) + // O = Nonexistent switch line { - case "R": - case "A": + case "A", "I": + // Active or Idle processes (ready to run or awaiting startup) ret.ProcsRunning++ - case "T": + case "W", "T", "Z": + // Swapped, Stopped, or Zombie processes (blocked/not runnable) ret.ProcsBlocked++ - default: - continue } + ret.ProcsTotal++ + } + + // Get context switches from vmstat + ctxt, _, _, err := getVmstatMetrics(ctx) + if err == nil { + ret.Ctxt = ctxt } + return ret, nil } + +// getVmstatMetrics runs vmstat -s and returns cumulative since-boot counters +// for context switches, device interrupts, and syscalls. +func getVmstatMetrics(ctx context.Context) (ctxt, interrupts, syscalls int, err error) { + out, err := getInvoker().CommandWithContext(ctx, "vmstat", "-s") + if err != nil { + return 0, 0, 0, err + } + + // vmstat -s output format: + // Example lines: + // 5842393706 cpu context switches + // 33412179 device interrupts + // 12918944607 syscalls + lines := strings.Split(string(out), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + // Split into number and description at the first space after the number + idx := strings.IndexByte(line, ' ') + if idx < 0 { + continue + } + valStr := line[:idx] + desc := strings.TrimSpace(line[idx+1:]) + + v, parseErr := strconv.Atoi(valStr) + if parseErr != nil { + continue + } + + switch desc { + case "cpu context switches": + ctxt = v + case "device interrupts": + interrupts = v + case "syscalls": + syscalls = v + } + } + + return ctxt, interrupts, syscalls, nil +} diff --git a/load/load_aix_test.go b/load/load_aix_test.go new file mode 100644 index 0000000000..b1a78df29d --- /dev/null +++ b/load/load_aix_test.go @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: BSD-3-Clause +//go:build aix + +package load + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMiscWithContextAIX(t *testing.T) { + ctx := context.Background() + misc, err := MiscWithContext(ctx) + require.NoError(t, err) + assert.NotNil(t, misc) + + // Process counts should be non-negative + assert.GreaterOrEqual(t, misc.ProcsTotal, 0) + assert.GreaterOrEqual(t, misc.ProcsRunning, 0) + assert.GreaterOrEqual(t, misc.ProcsBlocked, 0) + + // Total should be >= running + blocked + assert.GreaterOrEqual(t, misc.ProcsTotal, misc.ProcsRunning+misc.ProcsBlocked) + + // Context switches should be positive (system has been running) + assert.Positive(t, misc.Ctxt, "Context switches should be > 0 since system is running") +} + +func TestMiscAIX(t *testing.T) { + // Test the non-context version + misc, err := Misc() + require.NoError(t, err) + assert.NotNil(t, misc) + + // Process counts should be non-negative + assert.GreaterOrEqual(t, misc.ProcsTotal, 0) + assert.GreaterOrEqual(t, misc.ProcsRunning, 0) + assert.GreaterOrEqual(t, misc.ProcsBlocked, 0) +} + +func TestSystemCallsWithContext(t *testing.T) { + ctx := context.Background() + syscalls, err := SystemCallsWithContext(ctx) + require.NoError(t, err) + + // System calls should be positive since system is running + assert.Positive(t, syscalls, "System calls should be > 0 since system is running") +} + +func TestSystemCalls(t *testing.T) { + syscalls, err := SystemCalls() + require.NoError(t, err) + + // System calls should be positive + assert.Positive(t, syscalls) +} + +func TestInterruptsWithContext(t *testing.T) { + ctx := context.Background() + interrupts, err := InterruptsWithContext(ctx) + require.NoError(t, err) + + // Interrupts should be positive since system is running + assert.Positive(t, interrupts, "Interrupts should be > 0 since system is running") +} + +func TestInterrupts(t *testing.T) { + interrupts, err := Interrupts() + require.NoError(t, err) + + // Interrupts should be positive + assert.Positive(t, interrupts) +} diff --git a/load/load_mock_invoker_test.go b/load/load_mock_invoker_test.go new file mode 100644 index 0000000000..f76f607176 --- /dev/null +++ b/load/load_mock_invoker_test.go @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: BSD-3-Clause +//go:build aix + +package load + +import ( + "context" + "fmt" + "strings" + + "github.com/shirou/gopsutil/v4/internal/common" +) + +// MockInvoker allows mocking command output for testing +type MockInvoker struct { + commands map[string][]byte +} + +// NewMockInvoker creates a new mock invoker with predefined responses +func NewMockInvoker() *MockInvoker { + return &MockInvoker{ + commands: make(map[string][]byte), + } +} + +// SetResponse sets the response for a command +func (m *MockInvoker) SetResponse(name string, args []string, output string) { + key := name + " " + strings.Join(args, " ") + m.commands[key] = []byte(output) +} + +// Command implements the Invoker interface +func (m *MockInvoker) Command(name string, arg ...string) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), common.Timeout) + defer cancel() + return m.CommandWithContext(ctx, name, arg...) +} + +// CommandWithContext implements the Invoker interface +func (m *MockInvoker) CommandWithContext(_ context.Context, name string, arg ...string) ([]byte, error) { + key := name + " " + strings.Join(arg, " ") + if output, ok := m.commands[key]; ok { + return output, nil + } + return nil, fmt.Errorf("command not mocked: %s", key) +} + +// SetupSystemMetricsMock configures the mock for system metrics testing +func (m *MockInvoker) SetupSystemMetricsMock() { + // AIX vmstat -s output (cumulative since-boot counters) + vmstatSOutput := ` 3492560274 total address trans. faults + 116707 page ins + 3595620 page outs + 0 paging space page ins + 0 paging space page outs + 0 total reclaims + 627328604 zero filled pages faults + 11232 executable filled pages faults + 0 pages examined by clock + 0 revolutions of the clock hand + 0 pages freed by the clock + 46730047 backtracks + 0 free frame waits + 0 extend XPT waits + 26953 pending I/O waits + 3712327 start I/Os + 1823837 iodones + 5842393706 cpu context switches + 33412179 device interrupts + 357175605 software interrupts + 2684865841 decrementer interrupts + 1106 mpc-sent interrupts + 1106 mpc-receive interrupts + 0 phantom interrupts + 0 traps + 12918944607 syscalls +` + m.SetResponse("vmstat", []string{"-s"}, vmstatSOutput) + + // AIX ps output for process state + psOutput := `STATE +A +A +A +W +I +A +A +A +Z +A +` + m.SetResponse("ps", []string{"-e", "-o", "state"}, psOutput) +} diff --git a/load/load_mock_test.go b/load/load_mock_test.go new file mode 100644 index 0000000000..83b0dcc544 --- /dev/null +++ b/load/load_mock_test.go @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: BSD-3-Clause +//go:build aix + +package load + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Cross-platform tests using mocked AIX command output +// These tests run on AIX systems, providing verification of parsing logic + +func TestSystemCallsWithContextMock(t *testing.T) { + // Setup mock + mock := NewMockInvoker() + mock.SetupSystemMetricsMock() + testInvoker = mock + defer func() { testInvoker = nil }() + + ctx := context.Background() + syscalls, err := SystemCallsWithContext(ctx) + require.NoError(t, err) + + // Should extract cumulative syscalls from vmstat -s output + assert.Equal(t, 12918944607, syscalls) +} + +func TestInterruptsWithContextMock(t *testing.T) { + // Setup mock + mock := NewMockInvoker() + mock.SetupSystemMetricsMock() + testInvoker = mock + defer func() { testInvoker = nil }() + + ctx := context.Background() + interrupts, err := InterruptsWithContext(ctx) + require.NoError(t, err) + + // Should extract cumulative device interrupts from vmstat -s output + assert.Equal(t, 33412179, interrupts) +} + +func TestMiscWithContextMock(t *testing.T) { + // Setup mock + mock := NewMockInvoker() + mock.SetupSystemMetricsMock() + testInvoker = mock + defer func() { testInvoker = nil }() + + ctx := context.Background() + misc, err := MiscWithContext(ctx) + require.NoError(t, err) + require.NotNil(t, misc) + + // Mock data has: 7 A, 1 W, 1 I, 1 Z = 10 total + // Running: 7 (A) + 1 (I) = 8 + // Blocked: 1 (W) + 1 (Z) = 2 + assert.Equal(t, 10, misc.ProcsTotal) + assert.Equal(t, 8, misc.ProcsRunning) + assert.Equal(t, 2, misc.ProcsBlocked) + + // Should extract cumulative cpu context switches from vmstat -s output + assert.Equal(t, 5842393706, misc.Ctxt) +} + +func TestSystemCallsMock(t *testing.T) { + // Setup mock + mock := NewMockInvoker() + mock.SetupSystemMetricsMock() + testInvoker = mock + defer func() { testInvoker = nil }() + + syscalls, err := SystemCalls() + require.NoError(t, err) + assert.Equal(t, 12918944607, syscalls) +} + +func TestInterruptsMock(t *testing.T) { + // Setup mock + mock := NewMockInvoker() + mock.SetupSystemMetricsMock() + testInvoker = mock + defer func() { testInvoker = nil }() + + interrupts, err := Interrupts() + require.NoError(t, err) + assert.Equal(t, 33412179, interrupts) +} + +func TestMiscMock(t *testing.T) { + // Setup mock + mock := NewMockInvoker() + mock.SetupSystemMetricsMock() + testInvoker = mock + defer func() { testInvoker = nil }() + + misc, err := Misc() + require.NoError(t, err) + assert.NotNil(t, misc) + assert.Equal(t, 10, misc.ProcsTotal) + assert.Equal(t, 8, misc.ProcsRunning) + assert.Equal(t, 2, misc.ProcsBlocked) + assert.Equal(t, 5842393706, misc.Ctxt) +} diff --git a/net/net_aix_nocgo.go b/net/net_aix_nocgo.go index 834534d34c..41fb407ccd 100644 --- a/net/net_aix_nocgo.go +++ b/net/net_aix_nocgo.go @@ -78,6 +78,33 @@ func parseNetstatI(output string) ([]IOCountersStat, error) { return ret, nil } +// parseEntstat extracts BytesSent and BytesRecv from entstat output. +// The output has a two-column layout with Transmit on the left and Receive +// on the right, e.g.: +// +// Bytes: 3509236040 Bytes: 4547812126 +func parseEntstat(output string) (bytesSent, bytesRecv uint64) { + for _, line := range strings.Split(output, "\n") { + if !strings.Contains(line, "Bytes:") { + continue + } + // Split on "Bytes:" to get: ["", " 3509236040 ", " 4547812126"] + parts := strings.Split(line, "Bytes:") + if len(parts) >= 2 { + if v, err := strconv.ParseUint(strings.TrimSpace(parts[1]), 10, 64); err == nil { + bytesSent = v + } + } + if len(parts) >= 3 { + if v, err := strconv.ParseUint(strings.TrimSpace(parts[2]), 10, 64); err == nil { + bytesRecv = v + } + } + return + } + return +} + func IOCountersWithContext(ctx context.Context, pernic bool) ([]IOCountersStat, error) { out, err := invoke.CommandWithContext(ctx, "netstat", "-idn") if err != nil { @@ -88,6 +115,18 @@ func IOCountersWithContext(ctx context.Context, pernic bool) ([]IOCountersStat, if err != nil { return nil, err } + + // Populate BytesSent/BytesRecv via entstat for each interface. + // entstat only works on hardware/virtual ethernet adapters; it fails + // on loopback (lo0) with errno 19, so errors are silently ignored. + for i := range iocounters { + entOut, err := invoke.CommandWithContext(ctx, "entstat", iocounters[i].Name) + if err != nil { + continue + } + iocounters[i].BytesSent, iocounters[i].BytesRecv = parseEntstat(string(entOut)) + } + if !pernic { return getIOCountersAll(iocounters), nil }