memory-leak 0.4.0 → 0.5.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 +4 -4
- checksums.yaml.gz.sig +0 -0
- data/lib/memory/leak/cluster.rb +35 -18
- data/lib/memory/leak/monitor.rb +75 -56
- data/lib/memory/leak/system.rb +23 -2
- data/lib/memory/leak/version.rb +1 -1
- data/readme.md +5 -0
- data/releases.md +5 -0
- data.tar.gz.sig +0 -0
- metadata +2 -2
- metadata.gz.sig +0 -0
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 14b3c3712d827c78c74132d04b0488e5572ede9a4ff1451e5df6990c8931007f
|
4
|
+
data.tar.gz: f055ae13339a94c15fe4ee52ec264cde3e6ab764e84e3f358e94477c3f3e5753
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: cd92451ab8a82bc932279eff5d76c8a2b9e9b792bcc77e663b867a9ab8cbb9640dbe0b343841b1fdb74e770e1bf53abddbcd060bcfccecf47679c69cba5ddd57
|
7
|
+
data.tar.gz: de5bd472ca16f2819c991c9d5b542da3d5a62c161bd1d4b64098f14a956f980cbb5db247a749512f35a982eeae0a265f2ceffbbaf60485adfeb17a060530eb55
|
checksums.yaml.gz.sig
CHANGED
Binary file
|
data/lib/memory/leak/cluster.rb
CHANGED
@@ -14,9 +14,10 @@ module Memory
|
|
14
14
|
class Cluster
|
15
15
|
# Create a new cluster.
|
16
16
|
#
|
17
|
-
# @parameter
|
18
|
-
def initialize(
|
19
|
-
@
|
17
|
+
# @parameter total_size_limit [Numeric | Nil] The total memory limit for the cluster.
|
18
|
+
def initialize(total_size_limit: nil)
|
19
|
+
@total_size = nil
|
20
|
+
@total_size_limit = total_size_limit
|
20
21
|
|
21
22
|
@processes = {}
|
22
23
|
end
|
@@ -24,7 +25,8 @@ module Memory
|
|
24
25
|
# @returns [Hash] A serializable representation of the cluster.
|
25
26
|
def as_json(...)
|
26
27
|
{
|
27
|
-
|
28
|
+
total_size: @total_size,
|
29
|
+
total_size_limit: @total_size_limit,
|
28
30
|
processes: @processes.transform_values(&:as_json),
|
29
31
|
}
|
30
32
|
end
|
@@ -34,8 +36,11 @@ module Memory
|
|
34
36
|
as_json.to_json(...)
|
35
37
|
end
|
36
38
|
|
37
|
-
# @attribute [Numeric | Nil] The
|
38
|
-
|
39
|
+
# @attribute [Numeric | Nil] The total size of the cluster.
|
40
|
+
attr :total_size
|
41
|
+
|
42
|
+
# @attribute [Numeric | Nil] The total size limit for the cluster, in bytes, if which is exceeded, the cluster will terminate processes.
|
43
|
+
attr_accessor :total_size_limit
|
39
44
|
|
40
45
|
# @attribute [Hash(Integer, Monitor)] The process IDs and monitors in the cluster.
|
41
46
|
attr :processes
|
@@ -53,37 +58,49 @@ module Memory
|
|
53
58
|
# Apply the memory limit to the cluster. If the total memory usage exceeds the limit, yields each process ID and monitor in order of maximum memory usage, so that they could be terminated and/or removed.
|
54
59
|
#
|
55
60
|
# @yields {|process_id, monitor| ...} each process ID and monitor in order of maximum memory usage, return true if it was terminated to adjust memory usage.
|
56
|
-
def apply_limit!(
|
57
|
-
|
61
|
+
def apply_limit!(total_size_limit = @total_size_limit)
|
62
|
+
@total_size = @processes.values.map(&:current_size).sum
|
58
63
|
|
59
|
-
if
|
60
|
-
Console.warn(self, "Total memory usage exceeded limit.",
|
64
|
+
if @total_size > total_size_limit
|
65
|
+
Console.warn(self, "Total memory usage exceeded limit.", total_size: @total_size, total_size_limit: total_size_limit)
|
66
|
+
else
|
67
|
+
return false
|
61
68
|
end
|
62
69
|
|
63
70
|
sorted = @processes.sort_by do |process_id, monitor|
|
64
|
-
-monitor.
|
71
|
+
-monitor.current_size
|
65
72
|
end
|
66
73
|
|
67
74
|
sorted.each do |process_id, monitor|
|
68
|
-
if
|
69
|
-
|
70
|
-
|
71
|
-
|
75
|
+
if @total_size > total_size_limit
|
76
|
+
yield(process_id, monitor, @total_size)
|
77
|
+
|
78
|
+
# For the sake of the calculation, we assume that the process has been terminated:
|
79
|
+
@total_size -= monitor.current_size
|
72
80
|
else
|
73
81
|
break
|
74
82
|
end
|
75
83
|
end
|
76
84
|
end
|
77
85
|
|
86
|
+
# Sample the memory usage of all processes in the cluster.
|
87
|
+
def sample!
|
88
|
+
System.memory_usages(@processes.keys).each do |process_id, memory_usage|
|
89
|
+
@processes[process_id].current_size = memory_usage
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
78
93
|
# Check all processes in the cluster for memory leaks.
|
79
94
|
#
|
80
95
|
# @yields {|process_id, monitor| ...} each process ID and monitor that is leaking or exceeds the memory limit.
|
81
96
|
def check!(&block)
|
97
|
+
return to_enum(__method__) unless block_given?
|
98
|
+
|
99
|
+
self.sample!
|
100
|
+
|
82
101
|
leaking = []
|
83
102
|
|
84
103
|
@processes.each do |process_id, monitor|
|
85
|
-
monitor.sample!
|
86
|
-
|
87
104
|
if monitor.leaking?
|
88
105
|
Console.debug(self, "Memory Leak Detected!", process_id: process_id, monitor: monitor)
|
89
106
|
|
@@ -94,7 +111,7 @@ module Memory
|
|
94
111
|
leaking.each(&block)
|
95
112
|
|
96
113
|
# Finally, apply any per-cluster memory limits:
|
97
|
-
apply_limit!(@
|
114
|
+
apply_limit!(@total_size_limit, &block) if @total_size_limit
|
98
115
|
end
|
99
116
|
end
|
100
117
|
end
|
data/lib/memory/leak/monitor.rb
CHANGED
@@ -4,51 +4,54 @@
|
|
4
4
|
# Copyright, 2025, by Samuel Williams.
|
5
5
|
|
6
6
|
require "console"
|
7
|
+
require_relative "system"
|
7
8
|
|
8
9
|
module Memory
|
9
10
|
module Leak
|
10
|
-
# Detects memory leaks by tracking
|
11
|
+
# Detects memory leaks by tracking process size increases.
|
11
12
|
#
|
12
13
|
# A memory leak is characterised by the memory usage of the application continuing to rise over time. We can detect this by sampling memory usage and comparing it to the previous sample. If the memory usage is higher than the previous sample, we can say that the application has allocated more memory. Eventually we expect to see this stabilize, but if it continues to rise, we can say that the application has a memory leak.
|
13
14
|
#
|
14
15
|
# We should be careful not to filter historical data, as some memory leaks may only become apparent after a long period of time. Any kind of filtering may prevent us from detecting such a leak.
|
15
16
|
class Monitor
|
16
|
-
# We only track
|
17
|
-
# True memory leaks will eventually hit this
|
18
|
-
|
17
|
+
# We only track process size changes greater than this threshold_size, across the DEFAULT_INTERVAL.
|
18
|
+
# True memory leaks will eventually hit this threshold_size, while small fluctuations will not.
|
19
|
+
DEFAULT_THRESHOLD_SIZE = 1024*1024*10
|
19
20
|
|
20
|
-
# We track the last N
|
21
|
-
# If the
|
22
|
-
# With a default interval of 10 seconds, this will track the last ~3 minutes of
|
23
|
-
|
21
|
+
# We track the last N process size increases.
|
22
|
+
# If the process size is not stabilizing within the specified increase_limit, we can assume there is a leak.
|
23
|
+
# With a default interval of 10 seconds, this will track the last ~3 minutes of process size increases.
|
24
|
+
DEFAULT_INCREASE_LIMIT = 20
|
24
25
|
|
25
26
|
# Create a new monitor.
|
26
27
|
#
|
27
|
-
# @parameter
|
28
|
-
# @parameter
|
29
|
-
# @parameter
|
30
|
-
# @parameter [
|
31
|
-
|
28
|
+
# @parameter process_id [Integer] The process ID to monitor.
|
29
|
+
# @parameter maximum_size [Numeric] The initial process size, from which we willl track increases, in bytes.
|
30
|
+
# @parameter maximum_size_limit [Numeric | Nil] The maximum process size allowed, in bytes, before we assume a memory leak.
|
31
|
+
# @parameter threshold_size [Numeric] The threshold for process size increases, in bytes.
|
32
|
+
# @parameter increase_limit [Numeric] The limit for the number of process size increases, before we assume a memory leak.
|
33
|
+
def initialize(process_id = Process.pid, maximum_size: nil, maximum_size_limit: nil, threshold_size: DEFAULT_THRESHOLD_SIZE, increase_limit: DEFAULT_INCREASE_LIMIT)
|
32
34
|
@process_id = process_id
|
33
35
|
|
34
|
-
@
|
35
|
-
@
|
36
|
-
@
|
36
|
+
@current_size = nil
|
37
|
+
@maximum_size = maximum_size
|
38
|
+
@maximum_size_limit = maximum_size_limit
|
37
39
|
|
38
|
-
|
39
|
-
@
|
40
|
-
@
|
40
|
+
@threshold_size = threshold_size
|
41
|
+
@increase_count = 0
|
42
|
+
@increase_limit = increase_limit
|
41
43
|
end
|
42
44
|
|
43
45
|
# @returns [Hash] A serializable representation of the cluster.
|
44
46
|
def as_json(...)
|
45
47
|
{
|
46
48
|
process_id: @process_id,
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
49
|
+
current_size: @current_size,
|
50
|
+
maximum_size: @maximum_size,
|
51
|
+
maximum_size_limit: @maximum_size_limit,
|
52
|
+
threshold_size: @threshold_size,
|
53
|
+
increase_count: @increase_count,
|
54
|
+
increase_limit: @increase_limit,
|
52
55
|
}
|
53
56
|
end
|
54
57
|
|
@@ -60,65 +63,81 @@ module Memory
|
|
60
63
|
# @attribute [Integer] The process ID to monitor.
|
61
64
|
attr :process_id
|
62
65
|
|
63
|
-
# @attribute [Numeric] The
|
64
|
-
|
66
|
+
# @attribute [Numeric] The maximum process size observed.
|
67
|
+
attr_accessor :maximum_size
|
65
68
|
|
66
|
-
# @attribute [Numeric] The
|
67
|
-
|
69
|
+
# @attribute [Numeric | Nil] The maximum process size allowed, before we assume a memory leak.
|
70
|
+
attr_accessor :maximum_size_limit
|
68
71
|
|
69
|
-
# @attribute [Numeric] The
|
70
|
-
|
72
|
+
# @attribute [Numeric] The threshold_size for process size increases.
|
73
|
+
attr_accessor :threshold_size
|
71
74
|
|
72
|
-
# @attribute [Integer] The number of increasing
|
73
|
-
|
75
|
+
# @attribute [Integer] The number of increasing process size samples.
|
76
|
+
attr_accessor :increase_count
|
74
77
|
|
75
|
-
# The
|
76
|
-
|
77
|
-
|
78
|
-
#
|
79
|
-
|
80
|
-
|
81
|
-
IO.popen(["ps", "-o", "rss=", @process_id.to_s]) do |io|
|
82
|
-
return Integer(io.readlines.last) * 1024
|
83
|
-
end
|
78
|
+
# @attribute [Numeric] The limit for the number of process size increases, before we assume a memory leak.
|
79
|
+
attr_accessor :increase_limit
|
80
|
+
|
81
|
+
# @returns [Integer] Ask the system for the current memory usage.
|
82
|
+
def memory_usage
|
83
|
+
System.memory_usage(@process_id)
|
84
84
|
end
|
85
85
|
|
86
86
|
# @returns [Integer] The last sampled memory usage.
|
87
|
-
def
|
88
|
-
@
|
87
|
+
def current_size
|
88
|
+
@current_size ||= memory_usage
|
89
|
+
end
|
90
|
+
|
91
|
+
# Set the current memory usage, rather than sampling it.
|
92
|
+
def current_size=(value)
|
93
|
+
@current_size = value
|
89
94
|
end
|
90
95
|
|
91
96
|
# Indicates whether a memory leak has been detected.
|
92
97
|
#
|
93
|
-
# If the number of increasing heap size samples is greater than or equal to the
|
98
|
+
# If the number of increasing heap size samples is greater than or equal to the increase_limit, a memory leak is assumed.
|
99
|
+
#
|
100
|
+
# @returns [Boolean] True if a memory leak has been detected.
|
101
|
+
def increase_limit_exceeded?
|
102
|
+
@increase_count >= @increase_limit
|
103
|
+
end
|
104
|
+
|
105
|
+
# Indicates that the current memory usage has grown beyond the maximum size limit.
|
106
|
+
#
|
107
|
+
# @returns [Boolean] True if the current memory usage has grown beyond the maximum size limit.
|
108
|
+
def maximum_size_limit_exceeded?
|
109
|
+
@maximum_size_limit && self.current_size > @maximum_size_limit
|
110
|
+
end
|
111
|
+
|
112
|
+
# Indicates whether a memory leak has been detected.
|
94
113
|
#
|
95
114
|
# @returns [Boolean] True if a memory leak has been detected.
|
96
115
|
def leaking?
|
97
|
-
|
116
|
+
increase_limit_exceeded? || maximum_size_limit_exceeded?
|
98
117
|
end
|
99
118
|
|
100
119
|
# Capture a memory usage sample and yield if a memory leak is detected.
|
101
120
|
#
|
102
121
|
# @yields {|sample, monitor| ...} If a memory leak is detected.
|
103
122
|
def sample!
|
104
|
-
|
123
|
+
self.current_size = memory_usage
|
105
124
|
|
106
|
-
if @
|
107
|
-
delta = @
|
108
|
-
Console.debug(self, "Heap size captured.",
|
125
|
+
if @maximum_observed_size
|
126
|
+
delta = @current_size - @maximum_observed_size
|
127
|
+
Console.debug(self, "Heap size captured.", current_size: @current_size, delta: delta, threshold_size: @threshold_size, maximum_observed_size: @maximum_observed_size)
|
109
128
|
|
110
|
-
if delta > @
|
111
|
-
@
|
112
|
-
@
|
129
|
+
if delta > @threshold_size
|
130
|
+
@maximum_observed_size = @current_size
|
131
|
+
@increase_count += 1
|
113
132
|
|
114
|
-
Console.debug(self, "Heap size increased.",
|
133
|
+
Console.debug(self, "Heap size increased.", maximum_observed_size: @maximum_observed_size, count: @count)
|
115
134
|
end
|
116
135
|
else
|
117
|
-
Console.debug(self, "Initial heap size captured.",
|
118
|
-
@
|
136
|
+
Console.debug(self, "Initial heap size captured.", current_size: @current_size)
|
137
|
+
@maximum_observed_size = @current_size
|
119
138
|
end
|
120
139
|
|
121
|
-
return @
|
140
|
+
return @current_size
|
122
141
|
end
|
123
142
|
end
|
124
143
|
end
|
data/lib/memory/leak/system.rb
CHANGED
@@ -7,18 +7,19 @@ require "console"
|
|
7
7
|
|
8
8
|
module Memory
|
9
9
|
module Leak
|
10
|
+
# System-specific memory information.
|
10
11
|
module System
|
11
12
|
if File.exist?("/proc/meminfo")
|
13
|
+
# @returns [Integer] The total memory size in bytes.
|
12
14
|
def self.total_memory_size
|
13
15
|
File.foreach("/proc/meminfo") do |line|
|
14
16
|
if /MemTotal:\s*(?<total>\d+)\s*kB/ =~ line
|
15
17
|
return total.to_i * 1024
|
16
18
|
end
|
17
19
|
end
|
18
|
-
|
19
|
-
return nil
|
20
20
|
end
|
21
21
|
elsif RUBY_PLATFORM =~ /darwin/
|
22
|
+
# @returns [Integer] The total memory size in bytes.
|
22
23
|
def self.total_memory_size
|
23
24
|
IO.popen(["sysctl", "hw.memsize"], "r") do |io|
|
24
25
|
io.each_line do |line|
|
@@ -29,6 +30,26 @@ module Memory
|
|
29
30
|
end
|
30
31
|
end
|
31
32
|
end
|
33
|
+
|
34
|
+
# Get the memory usage of the given process IDs.
|
35
|
+
#
|
36
|
+
# @parameter process_ids [Array(Integer)] The process IDs to monitor.
|
37
|
+
# @returns [Array(Tuple(Integer, Integer))] The memory usage of the given process IDs.
|
38
|
+
def self.memory_usages(process_ids)
|
39
|
+
IO.popen(["ps", "-o", "pid=,rss=", *process_ids.map(&:to_s)]) do |io|
|
40
|
+
io.each_line.map(&:split).map{|process_id, size| [process_id.to_i, size.to_i * 1024]}
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
# Get the memory usage of the given process IDs.
|
45
|
+
#
|
46
|
+
# @parameter process_ids [Array(Integer)] The process IDs to monitor.
|
47
|
+
# @returns [Array(Tuple(Integer, Integer))] The memory usage of the given process IDs.
|
48
|
+
def self.memory_usage(process_id)
|
49
|
+
IO.popen(["ps", "-o", "rss=", process_id.to_s]) do |io|
|
50
|
+
return io.read.to_i * 1024
|
51
|
+
end
|
52
|
+
end
|
32
53
|
end
|
33
54
|
end
|
34
55
|
end
|
data/lib/memory/leak/version.rb
CHANGED
data/readme.md
CHANGED
@@ -12,6 +12,11 @@ Please see the [project documentation](https://socketry.github.io/memory-leak/)
|
|
12
12
|
|
13
13
|
Please see the [project releases](https://socketry.github.io/memory-leak/releases/index) for all releases.
|
14
14
|
|
15
|
+
### v0.5.0
|
16
|
+
|
17
|
+
- Improved variable names.
|
18
|
+
- Added `maximum_size_limit` to process monitor.
|
19
|
+
|
15
20
|
### v0.1.0
|
16
21
|
|
17
22
|
- Initial implementation.
|
data/releases.md
CHANGED
data.tar.gz.sig
CHANGED
Binary file
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: memory-leak
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.5.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Samuel Williams
|
@@ -36,7 +36,7 @@ cert_chain:
|
|
36
36
|
Q2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8
|
37
37
|
voD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg=
|
38
38
|
-----END CERTIFICATE-----
|
39
|
-
date: 2025-02-
|
39
|
+
date: 2025-02-28 00:00:00.000000000 Z
|
40
40
|
dependencies: []
|
41
41
|
executables: []
|
42
42
|
extensions: []
|
metadata.gz.sig
CHANGED
Binary file
|