sys-cpu 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5cdd1159dbf95e9dfdddb43c3fc76182d4b6cc01c9b968fbfa3408375a003e31
4
- data.tar.gz: 67ec3a4ec93472020c8253430e9eaa523fad7655f61968cb10cc03a6ee39ea49
3
+ metadata.gz: 56f093dfd8f11c6ed9da97cc8020e687a76001dabef4596299041e6b875a2d3c
4
+ data.tar.gz: ac4b1d0ef5430c4d5078d9f4cbf12320f2900beab58d815202b32191209631ed
5
5
  SHA512:
6
- metadata.gz: f7f24be4c56b6459f6ad665a1b13130f6d662f751a724bc8cf64d94e82421cfdc64f1660b2a2cc6e11eb5aa97064dab82f6b9cc7e2d313645940a8a6ab23faff
7
- data.tar.gz: 1710410533c1139f9ad07cbe032f3041990d448ef37698b5e8285d807fe91f6eb05b7b55a5303fcbd86ad648cdcf4f6e4893d9cb24d8de5976a7da15499dba47
6
+ metadata.gz: 24a439eec1f866b22d52bd75665f284e8cd27351f474708b85f14900a3a77f4b5563b2961a3a5df7853fe10542048b7ff5c70eff1def7848da7eefa2964f28a2
7
+ data.tar.gz: e366fd2da77cf922abd384bb16e1c6bff84d4e3c11dc69d1732065971f80a2b5484db0cef28ae0d490492d4efb6173dea77a24b746302273eea7cf52d761716d
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGES.md CHANGED
@@ -1,3 +1,6 @@
1
+ ## 1.3.0 - 24-Mar-2026
2
+ * Added the cpu_usage method.
3
+
1
4
  ## 1.2.0 - 17-Feb-2026
2
5
  * The win32ole gem is now a dependency since Ruby 4.x no longer bundles it.
3
6
  * The freq method was updated for BSD platforms on aarch64. It now defaults
data/lib/sys/cpu.rb CHANGED
@@ -10,7 +10,7 @@ module Sys
10
10
  # This class is reopened for each of the supported platforms/operating systems.
11
11
  class CPU
12
12
  # The version of the sys-cpu gem.
13
- VERSION = '1.2.0'
13
+ VERSION = '1.3.0'
14
14
 
15
15
  private_class_method :new
16
16
  end
@@ -208,5 +208,119 @@ module Sys
208
208
 
209
209
  loadavg.get_array_of_double(0, 3)
210
210
  end
211
+
212
+ # Returns CPU usage as a percentage.
213
+ #
214
+ # If +sample_time+ is positive, samples CPU times and calculates an average
215
+ # over that interval. You can also specify +samples+ to average multiple
216
+ # consecutive measurements.
217
+ #
218
+ # If +sample_time+ is 0 (default), uses a 1-second sample window by default.
219
+ # Default value for +samples+ is 2 (averages two measurements).
220
+ #
221
+ HOST_CPU_LOAD_INFO = 3
222
+ HOST_CPU_LOAD_INFO_COUNT = 4
223
+
224
+ private_constant :HOST_CPU_LOAD_INFO, :HOST_CPU_LOAD_INFO_COUNT
225
+
226
+ attach_function :mach_host_self, [], :uint
227
+ attach_function :host_statistics, %i[uint int pointer pointer], :int
228
+
229
+ private_class_method :mach_host_self, :host_statistics
230
+
231
+ # Returns the current CPU usage as a percentage, averaged over a sampling interval.
232
+ #
233
+ # By default, this method samples CPU usage over a 1-second interval and averages two measurements.
234
+ # You can customize the interval and number of samples by passing the +sample_time+ (in seconds)
235
+ # and +samples+ keyword arguments. For example, +cpu_usage(sample_time: 0.5, samples: 4)+ will take four
236
+ # samples, each 0.5 seconds apart, and return the average CPU usage over that period.
237
+ #
238
+ # Passing nil, 0, or a negative value for either argument falls back to the defaults (1.0 seconds
239
+ # and 2 samples) to keep behavior consistent across platforms.
240
+ #
241
+ # Returns a Float (percentage), rounded to one decimal place, or nil if CPU usage cannot be determined.
242
+ #
243
+ # Example usage:
244
+ # Sys::CPU.cpu_usage #=> 12.3
245
+ # Sys::CPU.cpu_usage(sample_time: 2, samples: 3) #=> 10.7
246
+ # Sys::CPU.cpu_usage(sample_time: 0, samples: 0) #=> 12.3 # zeros fall back to defaults
247
+ #
248
+ def self.cpu_usage(sample_time: 1.0, samples: 2)
249
+ sample_time = 1.0 if sample_time.nil? || sample_time <= 0
250
+ samples = 2 if samples.nil? || samples <= 0
251
+
252
+ usages = []
253
+
254
+ samples.times do
255
+ t1 = current_ticks
256
+ sleep(sample_time)
257
+ t2 = current_ticks
258
+ next unless t1 && t2
259
+
260
+ if (u = usage_between_ticks(t1, t2))
261
+ usages << u
262
+ end
263
+ end
264
+
265
+ return nil if usages.empty?
266
+
267
+ (usages.sum / usages.size.to_f).round(1)
268
+ rescue StandardError
269
+ nil
270
+ end
271
+
272
+ def self.current_ticks
273
+ cpu_ticks_sysctl || cpu_ticks_host
274
+ end
275
+
276
+ private_class_method :current_ticks
277
+
278
+ def self.usage_between_ticks(t1, t2)
279
+ diff = t2.map.with_index { |v, i| v - t1[i] }
280
+ total = diff.sum
281
+ return nil if total <= 0
282
+
283
+ # host_statistics returns [user, system, idle, nice]
284
+ idle = diff[2] || 0
285
+ (1.0 - (idle.to_f / total)) * 100
286
+ end
287
+
288
+ private_class_method :usage_between_ticks
289
+
290
+ def self.cpu_ticks_sysctl
291
+ cp_time = proc { |ptr|
292
+ len = 5
293
+ size = FFI::MemoryPointer.new(:size_t)
294
+ size.write_ulong(ptr.size)
295
+
296
+ if sysctlbyname('kern.cp_time', ptr, size, nil, 0) < 0
297
+ raise Error, 'sysctlbyname failed'
298
+ end
299
+
300
+ ptr.read_array_of_ulong(len)
301
+ }
302
+
303
+ cp_time.call(FFI::MemoryPointer.new(:ulong, 5))
304
+ rescue StandardError
305
+ nil
306
+ end
307
+
308
+ private_class_method :cpu_ticks_sysctl
309
+
310
+ def self.cpu_ticks_host
311
+ host = mach_host_self
312
+ info = FFI::MemoryPointer.new(:uint, HOST_CPU_LOAD_INFO_COUNT)
313
+ count = FFI::MemoryPointer.new(:uint)
314
+ count.write_uint(HOST_CPU_LOAD_INFO_COUNT)
315
+
316
+ kr = host_statistics(host, HOST_CPU_LOAD_INFO, info, count)
317
+ return nil unless kr == 0
318
+
319
+ info.read_array_of_uint(HOST_CPU_LOAD_INFO_COUNT)
320
+ rescue StandardError
321
+ nil
322
+ end
323
+
324
+ private_class_method :cpu_ticks_host
211
325
  end
212
326
  end
@@ -100,8 +100,15 @@ module Sys
100
100
 
101
101
  # Returns a string indicating the CPU model.
102
102
  #
103
+ # Some systems may use slightly different keys in /proc/cpuinfo, so
104
+ # we fall back to other common names and ensure we always return a
105
+ # String.
103
106
  def self.model
104
- CPU_ARRAY.first['model_name']
107
+ CPU_ARRAY.first['model_name'] ||
108
+ CPU_ARRAY.first['model'] ||
109
+ CPU_ARRAY.first['cpu'] ||
110
+ CPU_ARRAY.first['processor'] ||
111
+ ''.dup
105
112
  end
106
113
 
107
114
  # Returns an integer indicating the speed of the CPU.
@@ -110,6 +117,62 @@ module Sys
110
117
  CPU_ARRAY.first['cpu_mhz'].to_f.round
111
118
  end
112
119
 
120
+ # Returns the current CPU usage as a percentage, averaged over a sampling interval.
121
+ #
122
+ # By default, this method samples CPU usage over a 1-second interval and averages two measurements.
123
+ # You can customize the interval and number of samples by passing the +sample_time+ (in seconds)
124
+ # and +samples+ keyword arguments. For example, +cpu_usage(sample_time: 0.5, samples: 4)+ will take four
125
+ # samples, each 0.5 seconds apart,
126
+ # and return the average CPU usage over that period.
127
+ #
128
+ # Passing nil, 0, or a negative value for either argument falls back to the defaults (1.0 seconds and
129
+ # 2 samples) for cross-platform consistency.
130
+ #
131
+ # Returns a Float (percentage), rounded to one decimal place, or nil if CPU usage cannot be determined.
132
+ #
133
+ # Example usage:
134
+ # Sys::CPU.cpu_usage #=> 12.3
135
+ # Sys::CPU.cpu_usage(sample_time: 2, samples: 3) #=> 10.7
136
+ # Sys::CPU.cpu_usage(sample_time: 0, samples: 0) #=> 12.3 # zeros fall back to defaults
137
+ #
138
+ def self.cpu_usage(sample_time: 1.0, samples: 2)
139
+ sample_time = 1.0 if sample_time.nil? || sample_time <= 0
140
+ samples = 2 if samples.nil? || samples <= 0
141
+
142
+ usages = []
143
+
144
+ samples.times do
145
+ stats1 = cpu_stats
146
+ sleep(sample_time)
147
+ stats2 = cpu_stats
148
+
149
+ total_diff = 0.0
150
+ idle_diff = 0.0
151
+
152
+ keys = stats1.key?('cpu') ? ['cpu'] : stats1.keys
153
+ keys.each do |key|
154
+ arr1 = stats1[key]
155
+ arr2 = stats2[key]
156
+ next unless arr1 && arr2
157
+ t1 = arr1.sum
158
+ t2 = arr2.sum
159
+ total = t2 - t1
160
+ idle = (arr2[3] || 0) - (arr1[3] || 0)
161
+ total_diff += total
162
+ idle_diff += idle
163
+ end
164
+
165
+ if total_diff > 0
166
+ usages << ((1.0 - (idle_diff / total_diff)) * 100)
167
+ end
168
+ end
169
+
170
+ return nil if usages.empty?
171
+ (usages.sum / usages.size.to_f).round(1)
172
+ rescue StandardError
173
+ nil
174
+ end
175
+
113
176
  # Create singleton methods for each of the attributes.
114
177
  #
115
178
  def self.method_missing(id, arg = 0)
@@ -168,7 +231,9 @@ module Sys
168
231
  next
169
232
  end
170
233
 
171
- vals = array[1..-1].map{ |e| e.to_i / 100 } # 100 jiffies/sec.
234
+ # Keep raw jiffies counts (do not scale by hz) so deltas over short
235
+ # intervals still produce meaningful values.
236
+ vals = array[1..-1].map{ |e| e.to_i }
172
237
  hash[array[0]] = vals
173
238
  end
174
239
 
@@ -309,6 +309,52 @@ module Sys
309
309
  loadavg.get_array_of_double(0, 3)
310
310
  end
311
311
 
312
+ # Returns CPU usage as a percentage, averaged over a sampling interval.
313
+ #
314
+ # By default, samples CPU times twice, 1 second apart. Arguments are keyword-based
315
+ # (+sample_time:+, +samples:+). Passing nil, 0, or a negative value for either
316
+ # falls back to these defaults for cross-platform consistency.
317
+ #
318
+ def self.cpu_usage(sample_time: 1.0, samples: 2)
319
+ cp_time = proc { |ptr|
320
+ len = 5
321
+ size = FFI::MemoryPointer.new(:size_t)
322
+ size.write_ulong(ptr.size)
323
+
324
+ if sysctlbyname('kern.cp_time', ptr, size, nil, 0) < 0
325
+ raise Error, 'sysctlbyname failed'
326
+ end
327
+
328
+ ptr.read_array_of_ulong(len)
329
+ }
330
+
331
+ sample_time = 1.0 if sample_time.nil? || sample_time <= 0
332
+ samples = 2 if samples.nil? || samples <= 0
333
+
334
+ usages = []
335
+
336
+ samples.times do
337
+ t1 = cp_time.call(FFI::MemoryPointer.new(:ulong, 5))
338
+ sleep(sample_time)
339
+ t2 = cp_time.call(FFI::MemoryPointer.new(:ulong, 5))
340
+
341
+ total1 = t1.sum
342
+ total2 = t2.sum
343
+ idle1 = t1[4] || 0
344
+ idle2 = t2[4] || 0
345
+
346
+ total_diff = total2 - total1
347
+ idle_diff = idle2 - idle1
348
+
349
+ usages << ((1.0 - (idle_diff.to_f / total_diff)) * 100) if total_diff > 0
350
+ end
351
+
352
+ return nil if usages.empty?
353
+ (usages.sum / usages.size.to_f).round(1)
354
+ rescue StandardError
355
+ nil
356
+ end
357
+
312
358
  # Returns the floating point processor type.
313
359
  #
314
360
  # Not supported on all platforms.
@@ -117,6 +117,46 @@ module Sys
117
117
  end
118
118
  end
119
119
 
120
+ # Returns CPU usage as a percentage, averaged over multiple samples.
121
+ #
122
+ # The +sample_time+ keyword specifies the interval (in seconds) between samples.
123
+ # The +samples+ keyword specifies how many samples to take and average.
124
+ # The +cpu_num+ keyword selects which CPU to query (0 for total).
125
+ # The +host+ keyword specifies the target machine (defaults to local).
126
+ #
127
+ #--
128
+ # This method uses the _Total Win32_PerfFormattedData_PerfOS_Processor instance
129
+ # (unless a specific +cpu_num+ is requested) to better match Task Manager's total view.
130
+ #
131
+ # Note: Task Manager reports total CPU usage across all cores. Win32_Processor.LoadPercentage
132
+ # is per-processor (usually per physical socket), so it can differ from Task Manager if it falls back.
133
+ #
134
+ def self.cpu_usage(sample_time: 1.0, samples: 2, cpu_num: 0, host: Socket.gethostname)
135
+ sample_time = 1.0 if sample_time.nil? || sample_time <= 0
136
+ samples = 2 if samples.nil? || samples <= 0
137
+ cpu_num = cpu_num.to_i if cpu_num.respond_to?(:to_i)
138
+ instance = cpu_num == 0 ? '_Total' : cpu_num.to_s
139
+ cs = BASE_CS + "//#{host}/root/cimv2:Win32_PerfFormattedData_PerfOS_Processor='#{instance}'"
140
+
141
+ usages = []
142
+
143
+ samples.times do
144
+ begin
145
+ wmi = WIN32OLE.connect(cs)
146
+ rescue WIN32OLERuntimeError
147
+ usages << load_avg(cpu_num, host)
148
+ else
149
+ result = wmi.PercentProcessorTime
150
+ usages << result.to_i if result
151
+ end
152
+ sleep(sample_time)
153
+ end
154
+
155
+ usages.compact!
156
+ return nil if usages.empty?
157
+ (usages.sum / usages.size.to_f).round(1)
158
+ end
159
+
120
160
  # Returns a string indicating the cpu model, e.g. Intel Pentium 4.
121
161
  #
122
162
  def self.model(host = Socket.gethostname)
@@ -52,6 +52,26 @@ RSpec.describe Sys::CPU, :bsd do
52
52
  expect{ described_class.load_avg(0) }.to raise_error(ArgumentError)
53
53
  end
54
54
 
55
+ example 'cpu_usage works as expected' do
56
+ expect(described_class).to respond_to(:cpu_usage)
57
+ expect{ described_class.cpu_usage }.not_to raise_error
58
+ expect{ described_class.cpu_usage(sample_time: 0.1) }.not_to raise_error
59
+ expect(described_class.cpu_usage).to be_a(Numeric).or be_nil
60
+ end
61
+
62
+ example 'cpu_usage falls back on non-positive values' do
63
+ expect{ described_class.cpu_usage(sample_time: 0, samples: 0) }.not_to raise_error
64
+ expect{ described_class.cpu_usage(sample_time: -0.5, samples: -1) }.not_to raise_error
65
+ expect(described_class.cpu_usage(sample_time: 0, samples: 0)).to be_a(Numeric).or be_nil
66
+ end
67
+
68
+ example 'cpu_usage sampling produces a valid range' do
69
+ result = described_class.cpu_usage(sample_time: 0.1)
70
+ expect(result).to be_a(Numeric).or be_nil
71
+ expect(result).to be >= 0 if result
72
+ expect(result).to be <= 100 if result
73
+ end
74
+
55
75
  example 'machine method basic functionality' do
56
76
  expect(described_class).to respond_to(:machine)
57
77
  expect{ described_class.machine }.not_to raise_error
@@ -52,4 +52,24 @@ RSpec.describe Sys::CPU, :hpux do
52
52
  expect(described_class.load_avg.length).to eq(3)
53
53
  expect(described_class.load_avg(0).length).to eq(3)
54
54
  end
55
+
56
+ example 'cpu_usage works as expected' do
57
+ expect(described_class).to respond_to(:cpu_usage)
58
+ expect{ described_class.cpu_usage }.not_to raise_error
59
+ expect{ described_class.cpu_usage(sample_time: 0.1) }.not_to raise_error
60
+ expect(described_class.cpu_usage).to be_a(Numeric).or be_nil
61
+ end
62
+
63
+ example 'cpu_usage falls back on non-positive values' do
64
+ expect{ described_class.cpu_usage(sample_time: 0, samples: 0) }.not_to raise_error
65
+ expect{ described_class.cpu_usage(sample_time: -1, samples: -1) }.not_to raise_error
66
+ expect(described_class.cpu_usage(sample_time: 0, samples: 0)).to be_a(Numeric).or be_nil
67
+ end
68
+
69
+ example 'cpu_usage sampling produces a valid range' do
70
+ result = described_class.cpu_usage(sample_time: 0.1)
71
+ expect(result).to be_a(Numeric).or be_nil
72
+ expect(result).to be >= 0 if result
73
+ expect(result).to be <= 100 if result
74
+ end
55
75
  end
@@ -49,6 +49,25 @@ RSpec.describe Sys::CPU, :linux do
49
49
  expect(described_class.num_cpu).to be_a(Numeric)
50
50
  end
51
51
 
52
+ example 'cpu_usage works as expected' do
53
+ expect{ described_class.cpu_usage }.not_to raise_error
54
+ expect(described_class.cpu_usage).to be_a(Numeric)
55
+ end
56
+
57
+ example 'cpu_usage falls back on non-positive values' do
58
+ expect{ described_class.cpu_usage(sample_time: 0, samples: 0) }.not_to raise_error
59
+ expect{ described_class.cpu_usage(sample_time: -1, samples: -2) }.not_to raise_error
60
+ expect(described_class.cpu_usage(sample_time: 0, samples: 0)).to be_a(Numeric)
61
+ end
62
+
63
+ example 'cpu_usage sampling produces a valid range' do
64
+ # Sampled usage should be a number between 0 and 100.
65
+ result = described_class.cpu_usage(sample_time: 0.1)
66
+ expect(result).to be_a(Numeric)
67
+ expect(result).to be >= 0
68
+ expect(result).to be <= 100
69
+ end
70
+
52
71
  example 'bogus methods are not picked up by method_missing' do
53
72
  expect{ described_class.bogus }.to raise_error(NoMethodError)
54
73
  end
@@ -11,7 +11,7 @@ require 'rspec'
11
11
 
12
12
  RSpec.shared_examples Sys::CPU do
13
13
  example 'version number is set to the expected value' do
14
- expect(Sys::CPU::VERSION).to eq('1.2.0')
14
+ expect(Sys::CPU::VERSION).to eq('1.3.0')
15
15
  end
16
16
 
17
17
  example 'version number is frozen' do
@@ -59,6 +59,27 @@ RSpec.describe Sys::CPU, :windows do
59
59
  expect(described_class.load_avg).to be_a(Integer).or be_a(NilClass)
60
60
  end
61
61
 
62
+ example 'cpu_usage works as expected' do
63
+ expect(described_class).to respond_to(:cpu_usage)
64
+ expect{ described_class.cpu_usage }.not_to raise_error
65
+ expect{ described_class.cpu_usage(sample_time: 0.1, samples: 0, host: host) }.not_to raise_error
66
+ expect(described_class.cpu_usage).to be_a(Numeric).or be_a(NilClass)
67
+ end
68
+
69
+ example 'cpu_usage falls back on non-positive values' do
70
+ expect{ described_class.cpu_usage(sample_time: 0, samples: 0, host: host) }.not_to raise_error
71
+ expect{ described_class.cpu_usage(sample_time: -1, samples: -1, host: host) }.not_to raise_error
72
+ expect(described_class.cpu_usage(sample_time: 0, samples: 0, host: host)).to be_a(Numeric).or be_a(NilClass)
73
+ end
74
+
75
+ example 'cpu_usage sampling produces a valid range' do
76
+ # Sampled usage should be a number between 0 and 100.
77
+ result = described_class.cpu_usage(sample_time: 0.1)
78
+ expect(result).to be_a(Numeric).or be_nil
79
+ expect(result).to be >= 0 if result
80
+ expect(result).to be <= 100 if result
81
+ end
82
+
62
83
  example 'processors' do
63
84
  expect(described_class).to respond_to(:processors)
64
85
  expect{ described_class.processors{} }.not_to raise_error
data/sys-cpu.gemspec CHANGED
@@ -2,7 +2,7 @@ require 'rubygems'
2
2
 
3
3
  Gem::Specification.new do |spec|
4
4
  spec.name = 'sys-cpu'
5
- spec.version = '1.2.0'
5
+ spec.version = '1.3.0'
6
6
  spec.author = 'Daniel J. Berger'
7
7
  spec.email = 'djberg96@gmail.com'
8
8
  spec.license = 'Apache-2.0'
@@ -18,6 +18,7 @@ Gem::Specification.new do |spec|
18
18
  spec.add_dependency('ffi', '~> 1.1')
19
19
 
20
20
  if Gem.win_platform?
21
+ spec.platform = Gem::Platform.new(['universal', 'mingw32'])
21
22
  spec.add_dependency('win32ole')
22
23
  end
23
24
 
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sys-cpu
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel J. Berger
metadata.gz.sig CHANGED
Binary file