claude-evolve 1.8.12 → 1.8.13
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.
- package/bin/claude-evolve-worker +27 -5
- package/lib/memory_limit_wrapper.py +152 -64
- package/package.json +1 -1
package/bin/claude-evolve-worker
CHANGED
|
@@ -407,19 +407,41 @@ with EvolutionCSV('$FULL_CSV_PATH') as csv:
|
|
|
407
407
|
eval_arg=""
|
|
408
408
|
fi
|
|
409
409
|
local eval_cmd=("$PYTHON_CMD" "$FULL_EVALUATOR_PATH" "$eval_arg")
|
|
410
|
-
|
|
410
|
+
|
|
411
411
|
# Add memory limiting if configured
|
|
412
|
+
# CRITICAL: Use multiple layers of protection (ulimit + Python wrapper + monitoring)
|
|
413
|
+
local memory_protection=""
|
|
412
414
|
if [[ -n "$MEMORY_LIMIT_MB" ]] && [[ "$MEMORY_LIMIT_MB" -gt 0 ]]; then
|
|
415
|
+
# Layer 1: ulimit for hard memory limit (kernel-enforced)
|
|
416
|
+
# IMPORTANT: Use -m (RSS) not -v (virtual memory) because:
|
|
417
|
+
# - Neural networks use mmap() which bypasses RLIMIT_AS (-v)
|
|
418
|
+
# - RSS limit is more reliable for actual memory consumption
|
|
419
|
+
# Convert MB to KB for ulimit
|
|
420
|
+
local memory_limit_kb=$((MEMORY_LIMIT_MB * 1024))
|
|
421
|
+
|
|
422
|
+
# Try -m first (RSS limit), fall back to -v if not supported
|
|
423
|
+
if ulimit -m $memory_limit_kb 2>/dev/null; then
|
|
424
|
+
memory_protection="ulimit -m $memory_limit_kb 2>/dev/null; "
|
|
425
|
+
echo "[MEMORY] Layer 1: ulimit -m ${memory_limit_kb}KB (RSS limit - catches neural networks)" >&2
|
|
426
|
+
else
|
|
427
|
+
memory_protection="ulimit -v $memory_limit_kb 2>/dev/null; "
|
|
428
|
+
echo "[MEMORY] Layer 1: ulimit -v ${memory_limit_kb}KB (fallback - may not catch neural networks)" >&2
|
|
429
|
+
fi
|
|
430
|
+
|
|
431
|
+
# Layer 2: Python wrapper with PROCESS TREE monitoring (backup protection)
|
|
413
432
|
eval_cmd=("$PYTHON_CMD" "$SCRIPT_DIR/../lib/memory_limit_wrapper.py" "$MEMORY_LIMIT_MB" "${eval_cmd[@]}")
|
|
433
|
+
|
|
434
|
+
echo "[MEMORY] Layer 2: Python process tree monitoring (kills entire subprocess tree)" >&2
|
|
414
435
|
fi
|
|
415
|
-
|
|
436
|
+
|
|
416
437
|
# Add timeout if configured
|
|
417
438
|
[[ -n "$timeout_seconds" ]] && eval_cmd=(timeout "$timeout_seconds" "${eval_cmd[@]}")
|
|
418
|
-
|
|
419
|
-
# Run evaluation with tee to both display and capture output
|
|
439
|
+
|
|
440
|
+
# Run evaluation with memory protection, tee to both display and capture output
|
|
420
441
|
# Use stdbuf to disable buffering for real-time output
|
|
421
442
|
# IMPORTANT: Use PIPESTATUS to get the exit code of the evaluation command, not tee
|
|
422
|
-
|
|
443
|
+
# The subshell ensures ulimit is applied before the command runs
|
|
444
|
+
stdbuf -o0 -e0 bash -c "${memory_protection}$(printf '%q ' "${eval_cmd[@]}")" 2>&1 | tee "$eval_output_file" >&2
|
|
423
445
|
local eval_exit_code=${PIPESTATUS[0]} # Get exit code of first command in pipe
|
|
424
446
|
|
|
425
447
|
if [[ $eval_exit_code -eq 0 ]]; then
|
|
@@ -4,6 +4,16 @@ Memory-limited execution wrapper for claude-evolve evaluations.
|
|
|
4
4
|
|
|
5
5
|
This script runs a command with memory limits to prevent runaway algorithms
|
|
6
6
|
from consuming all system memory and crashing the machine.
|
|
7
|
+
|
|
8
|
+
CRITICAL: Multi-layer protection approach (both must work together):
|
|
9
|
+
1. ulimit -m (RSS limit) set by calling shell script - kernel-enforced, catches neural networks
|
|
10
|
+
2. This Python wrapper monitors ENTIRE PROCESS TREE every 0.1s and kills if limit exceeded
|
|
11
|
+
|
|
12
|
+
AIDEV-NOTE: Previous bugs fixed:
|
|
13
|
+
- ulimit -v (virtual memory) doesn't catch neural networks that use mmap()
|
|
14
|
+
- Was only monitoring direct child, not entire process tree (missed grandchildren)
|
|
15
|
+
- Monitoring interval was 0.5s - too slow for fast memory allocations
|
|
16
|
+
- Resource limit failures were silently ignored instead of failing fast
|
|
7
17
|
"""
|
|
8
18
|
import sys
|
|
9
19
|
import os
|
|
@@ -11,119 +21,197 @@ import subprocess
|
|
|
11
21
|
import signal
|
|
12
22
|
import time
|
|
13
23
|
import resource
|
|
14
|
-
from typing import Optional
|
|
24
|
+
from typing import Optional, Tuple
|
|
15
25
|
|
|
16
|
-
def
|
|
17
|
-
"""
|
|
26
|
+
def verify_memory_limit_set(limit_mb: int) -> Tuple[bool, str]:
|
|
27
|
+
"""Verify that memory limits are actually enforced."""
|
|
28
|
+
try:
|
|
29
|
+
limit_bytes = limit_mb * 1024 * 1024
|
|
30
|
+
|
|
31
|
+
# Check RLIMIT_AS (virtual memory)
|
|
32
|
+
soft_as, hard_as = resource.getrlimit(resource.RLIMIT_AS)
|
|
33
|
+
if soft_as != resource.RLIM_INFINITY and soft_as <= limit_bytes * 1.1:
|
|
34
|
+
return True, f"RLIMIT_AS set to {soft_as / (1024*1024):.0f}MB"
|
|
35
|
+
|
|
36
|
+
# Check RLIMIT_DATA (data segment)
|
|
37
|
+
try:
|
|
38
|
+
soft_data, hard_data = resource.getrlimit(resource.RLIMIT_DATA)
|
|
39
|
+
if soft_data != resource.RLIM_INFINITY and soft_data <= limit_bytes * 1.1:
|
|
40
|
+
return True, f"RLIMIT_DATA set to {soft_data / (1024*1024):.0f}MB"
|
|
41
|
+
except (OSError, ValueError):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
return False, "No hard memory limits detected"
|
|
45
|
+
except Exception as e:
|
|
46
|
+
return False, f"Error checking limits: {e}"
|
|
47
|
+
|
|
48
|
+
def set_memory_limit(limit_mb: int) -> bool:
|
|
49
|
+
"""
|
|
50
|
+
Set memory limit in MB using resource module.
|
|
51
|
+
Returns True if successful, False otherwise.
|
|
52
|
+
"""
|
|
18
53
|
try:
|
|
19
54
|
# Convert MB to bytes
|
|
20
55
|
limit_bytes = limit_mb * 1024 * 1024
|
|
21
|
-
|
|
56
|
+
|
|
22
57
|
# Set virtual memory limit (address space)
|
|
23
58
|
# On macOS this is the most reliable way to limit memory
|
|
24
59
|
resource.setrlimit(resource.RLIMIT_AS, (limit_bytes, limit_bytes))
|
|
25
|
-
|
|
60
|
+
|
|
26
61
|
# Also try to set data segment limit if available
|
|
27
62
|
try:
|
|
28
63
|
resource.setrlimit(resource.RLIMIT_DATA, (limit_bytes, limit_bytes))
|
|
29
64
|
except (OSError, ValueError):
|
|
30
65
|
# Not available on all systems
|
|
31
66
|
pass
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
67
|
+
|
|
68
|
+
# Verify it was actually set
|
|
69
|
+
is_set, msg = verify_memory_limit_set(limit_mb)
|
|
70
|
+
if is_set:
|
|
71
|
+
print(f"[MEMORY] ✓ Hard limit enforced: {msg}", file=sys.stderr)
|
|
72
|
+
return True
|
|
73
|
+
else:
|
|
74
|
+
print(f"[MEMORY] ✗ Hard limit NOT enforced: {msg}", file=sys.stderr)
|
|
75
|
+
return False
|
|
76
|
+
|
|
35
77
|
except (OSError, ValueError) as e:
|
|
36
|
-
print(f"[MEMORY]
|
|
78
|
+
print(f"[MEMORY] ✗ Could not set memory limit: {e}", file=sys.stderr)
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
def get_process_tree_memory_native(pid: int) -> float:
|
|
82
|
+
"""Get total memory usage of entire process group using native ps command."""
|
|
83
|
+
try:
|
|
84
|
+
# Get the process group ID
|
|
85
|
+
pgid = os.getpgid(pid)
|
|
86
|
+
|
|
87
|
+
# Get all processes in the process group
|
|
88
|
+
ps_result = subprocess.run(
|
|
89
|
+
["ps", "-o", "rss=", "-g", str(pgid)],
|
|
90
|
+
capture_output=True,
|
|
91
|
+
text=True,
|
|
92
|
+
timeout=1
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if ps_result.returncode != 0:
|
|
96
|
+
return 0.0
|
|
97
|
+
|
|
98
|
+
# Sum all RSS values from the process group
|
|
99
|
+
total_rss_kb = 0
|
|
100
|
+
for line in ps_result.stdout.strip().split('\n'):
|
|
101
|
+
line = line.strip()
|
|
102
|
+
if line:
|
|
103
|
+
try:
|
|
104
|
+
total_rss_kb += int(line)
|
|
105
|
+
except ValueError:
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
# Convert KB to MB
|
|
109
|
+
return total_rss_kb / 1024.0
|
|
110
|
+
except Exception:
|
|
111
|
+
return 0.0
|
|
37
112
|
|
|
38
113
|
def monitor_memory_usage_native(process: subprocess.Popen, limit_mb: int) -> Optional[str]:
|
|
39
|
-
"""Monitor
|
|
40
|
-
|
|
41
|
-
|
|
114
|
+
"""Monitor ENTIRE PROCESS TREE memory usage using native tools and kill if it exceeds limits."""
|
|
115
|
+
print(f"[MEMORY] Monitoring process tree from root PID {process.pid} (limit: {limit_mb}MB)", file=sys.stderr)
|
|
116
|
+
|
|
42
117
|
while process.poll() is None:
|
|
43
118
|
try:
|
|
44
|
-
#
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
memory_kb = int(ps_result.stdout.strip())
|
|
55
|
-
memory_mb = memory_kb / 1024
|
|
56
|
-
|
|
57
|
-
# print(f"[MEMORY] PID {process.pid} using {memory_mb:.1f}MB (limit: {limit_mb}MB)", file=sys.stderr)
|
|
58
|
-
|
|
59
|
-
if memory_mb > limit_mb:
|
|
60
|
-
print(f"[MEMORY] Process exceeded {limit_mb}MB limit (using {memory_mb:.1f}MB), terminating", file=sys.stderr)
|
|
61
|
-
# Kill the entire process group - fix race condition
|
|
62
|
-
try:
|
|
63
|
-
pgid = os.getpgid(process.pid)
|
|
64
|
-
os.killpg(pgid, signal.SIGTERM)
|
|
65
|
-
except ProcessLookupError:
|
|
66
|
-
return f"Memory limit exceeded: {memory_mb:.1f}MB > {limit_mb}MB"
|
|
67
|
-
|
|
68
|
-
time.sleep(2) # Give it time to cleanup
|
|
69
|
-
|
|
70
|
-
try:
|
|
71
|
-
if process.poll() is None:
|
|
72
|
-
pgid = os.getpgid(process.pid)
|
|
73
|
-
os.killpg(pgid, signal.SIGKILL)
|
|
74
|
-
except ProcessLookupError:
|
|
75
|
-
pass
|
|
119
|
+
# Get total memory for entire process tree
|
|
120
|
+
memory_mb = get_process_tree_memory_native(process.pid)
|
|
121
|
+
|
|
122
|
+
if memory_mb > limit_mb:
|
|
123
|
+
print(f"[MEMORY] Process tree exceeded {limit_mb}MB limit (using {memory_mb:.1f}MB), terminating entire tree", file=sys.stderr)
|
|
124
|
+
# Kill the entire process group
|
|
125
|
+
try:
|
|
126
|
+
pgid = os.getpgid(process.pid)
|
|
127
|
+
os.killpg(pgid, signal.SIGTERM)
|
|
128
|
+
except ProcessLookupError:
|
|
76
129
|
return f"Memory limit exceeded: {memory_mb:.1f}MB > {limit_mb}MB"
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
130
|
+
|
|
131
|
+
time.sleep(2) # Give it time to cleanup
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
if process.poll() is None:
|
|
135
|
+
pgid = os.getpgid(process.pid)
|
|
136
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
137
|
+
except ProcessLookupError:
|
|
138
|
+
pass
|
|
139
|
+
return f"Memory limit exceeded: {memory_mb:.1f}MB > {limit_mb}MB"
|
|
140
|
+
|
|
141
|
+
time.sleep(0.1) # Check every 100ms for faster response
|
|
142
|
+
|
|
80
143
|
except (subprocess.TimeoutExpired, ValueError, ProcessLookupError):
|
|
81
144
|
# Process might have terminated or ps command failed
|
|
82
|
-
time.sleep(0.
|
|
145
|
+
time.sleep(0.1)
|
|
83
146
|
continue
|
|
84
|
-
|
|
85
|
-
# print(f"[MEMORY] Monitoring stopped for PID {process.pid}", file=sys.stderr)
|
|
147
|
+
|
|
86
148
|
return None
|
|
87
149
|
|
|
150
|
+
def get_process_tree_memory_psutil(ps_process) -> float:
|
|
151
|
+
"""Get total memory usage of entire process tree using psutil."""
|
|
152
|
+
try:
|
|
153
|
+
import psutil
|
|
154
|
+
total_mb = 0.0
|
|
155
|
+
|
|
156
|
+
# Get memory of root process
|
|
157
|
+
try:
|
|
158
|
+
total_mb += ps_process.memory_info().rss / (1024 * 1024)
|
|
159
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
160
|
+
return 0.0
|
|
161
|
+
|
|
162
|
+
# Get memory of all children (recursive)
|
|
163
|
+
try:
|
|
164
|
+
for child in ps_process.children(recursive=True):
|
|
165
|
+
try:
|
|
166
|
+
total_mb += child.memory_info().rss / (1024 * 1024)
|
|
167
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
168
|
+
continue
|
|
169
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
return total_mb
|
|
173
|
+
except ImportError:
|
|
174
|
+
return 0.0
|
|
175
|
+
|
|
88
176
|
def monitor_memory_usage(process: subprocess.Popen, limit_mb: int) -> Optional[str]:
|
|
89
|
-
"""Monitor
|
|
177
|
+
"""Monitor ENTIRE PROCESS TREE memory usage and kill if it exceeds limits."""
|
|
90
178
|
try:
|
|
91
179
|
import psutil
|
|
92
180
|
ps_process = psutil.Process(process.pid)
|
|
93
|
-
|
|
181
|
+
print(f"[MEMORY] Monitoring process tree from root PID {process.pid} (limit: {limit_mb}MB, using psutil)", file=sys.stderr)
|
|
182
|
+
|
|
94
183
|
while process.poll() is None:
|
|
95
184
|
try:
|
|
96
|
-
# Get memory
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
185
|
+
# Get total memory for entire process tree
|
|
186
|
+
memory_mb = get_process_tree_memory_psutil(ps_process)
|
|
187
|
+
|
|
100
188
|
if memory_mb > limit_mb:
|
|
101
|
-
print(f"[MEMORY] Process exceeded {limit_mb}MB limit (using {memory_mb:.1f}MB), terminating", file=sys.stderr)
|
|
102
|
-
# Kill the entire process group
|
|
189
|
+
print(f"[MEMORY] Process tree exceeded {limit_mb}MB limit (using {memory_mb:.1f}MB), terminating entire tree", file=sys.stderr)
|
|
190
|
+
# Kill the entire process group
|
|
103
191
|
try:
|
|
104
192
|
pgid = os.getpgid(process.pid)
|
|
105
193
|
os.killpg(pgid, signal.SIGTERM)
|
|
106
194
|
except ProcessLookupError:
|
|
107
195
|
return f"Memory limit exceeded: {memory_mb:.1f}MB > {limit_mb}MB"
|
|
108
|
-
|
|
196
|
+
|
|
109
197
|
time.sleep(2) # Give it time to cleanup
|
|
110
|
-
|
|
198
|
+
|
|
111
199
|
try:
|
|
112
200
|
if process.poll() is None:
|
|
113
|
-
pgid = os.getpgid(process.pid)
|
|
201
|
+
pgid = os.getpgid(process.pid)
|
|
114
202
|
os.killpg(pgid, signal.SIGKILL)
|
|
115
203
|
except ProcessLookupError:
|
|
116
204
|
pass
|
|
117
205
|
return f"Memory limit exceeded: {memory_mb:.1f}MB > {limit_mb}MB"
|
|
118
|
-
|
|
119
|
-
time.sleep(0.
|
|
206
|
+
|
|
207
|
+
time.sleep(0.1) # Check every 100ms for faster response
|
|
120
208
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
121
209
|
# Process already terminated
|
|
122
210
|
break
|
|
123
211
|
except ImportError:
|
|
124
212
|
# psutil not available, use native monitoring
|
|
125
213
|
return monitor_memory_usage_native(process, limit_mb)
|
|
126
|
-
|
|
214
|
+
|
|
127
215
|
return None
|
|
128
216
|
|
|
129
217
|
def validate_memory_limit(limit_mb: int) -> bool:
|