Skip to content

Commit a5c8490

Browse files
committed
test: add configurable memory, cpu and networking limits
1 parent 4f01301 commit a5c8490

File tree

4 files changed

+175
-51
lines changed

4 files changed

+175
-51
lines changed

app/api/base.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,7 @@ async def execute_code(
9797
continue
9898

9999
# Execute code in Docker container
100-
result = await docker_executor.execute(
101-
code=request.code,
102-
session_id=session_id,
103-
lang=request.lang,
104-
files=files,
105-
timeout=settings.SANDBOX_MAX_EXECUTION_TIME,
106-
)
100+
result = await docker_executor.execute(code=request.code, session_id=session_id, lang=request.lang, files=files)
107101

108102
# Add a language-specific error message if the stdout is empty
109103
if not result.get("stdout"):

app/services/docker_executor.py

Lines changed: 48 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ class ContainerMetrics:
3434
@dataclass
3535
class FileState:
3636
"""Tracks the state of a file for change detection."""
37+
3738
path: Path
3839
size: int
3940
mtime: float
@@ -46,21 +47,17 @@ class DockerExecutor:
4647

4748
WORK_DIR = "/mnt/data" # Working directory will be the same as data mount point
4849
DATA_MOUNT = "/mnt/data" # Mount point for session data
49-
50+
5051
# Language-specific execution commands
5152
LANGUAGE_EXECUTORS = {
5253
"py": ["python", "-c"],
5354
"r": ["Rscript", "-e"],
5455
}
55-
56+
5657
# Language-specific messages
5758
LANGUAGE_SPECIFIC_MESSAGES = {
58-
"py": {
59-
"empty_output": "Empty. Make sure to explicitly print() the results in Python"
60-
},
61-
"r": {
62-
"empty_output": "Empty. Make sure to use print() or cat() to display results in R"
63-
}
59+
"py": {"empty_output": "Empty. Make sure to explicitly print() the results in Python"},
60+
"r": {"empty_output": "Empty. Make sure to use print() or cat() to display results in R"},
6461
}
6562

6663
def __init__(self):
@@ -113,63 +110,56 @@ def _scan_directory(self, directory: Path) -> Dict[str, FileState]:
113110
Returns a dictionary mapping relative file paths to their FileState objects.
114111
"""
115112
file_states = {}
116-
113+
117114
if not directory.exists():
118115
logger.warning(f"Directory {directory} does not exist")
119116
return file_states
120-
117+
121118
# Walk through the directory recursively
122119
for root, _, files in os.walk(directory):
123120
root_path = Path(root)
124-
121+
125122
# Compute relative path from the base directory
126123
rel_root = root_path.relative_to(directory)
127-
124+
128125
for filename in files:
129126
# Skip lock files
130-
if filename.endswith('.lock'):
127+
if filename.endswith(".lock"):
131128
continue
132-
129+
133130
file_path = root_path / filename
134-
131+
135132
# Compute relative path for dictionary key
136-
if rel_root == Path('.'):
133+
if rel_root == Path("."):
137134
rel_path = filename
138135
else:
139136
rel_path = str(rel_root / filename)
140-
137+
141138
try:
142139
# Get file stats
143140
stat = file_path.stat()
144141
size = stat.st_size
145142
mtime = stat.st_mtime
146-
143+
147144
# Calculate MD5 hash for content comparison
148145
md5_hash = hashlib.md5(file_path.read_bytes()).hexdigest()
149-
146+
150147
# Store file state
151-
file_states[rel_path] = FileState(
152-
path=file_path,
153-
size=size,
154-
mtime=mtime,
155-
md5_hash=md5_hash
156-
)
148+
file_states[rel_path] = FileState(path=file_path, size=size, mtime=mtime, md5_hash=md5_hash)
157149
logger.debug(f"Scanned file: {rel_path}, size: {size}, hash: {md5_hash}")
158150
except (PermissionError, FileNotFoundError) as e:
159151
logger.warning(f"Error scanning file {file_path}: {str(e)}")
160152
continue
161-
153+
162154
return file_states
163155

164-
def _find_changed_files(self,
165-
before_states: Dict[str, FileState],
166-
after_states: Dict[str, FileState]) -> Set[str]:
156+
def _find_changed_files(self, before_states: Dict[str, FileState], after_states: Dict[str, FileState]) -> Set[str]:
167157
"""
168158
Compare before and after file states to identify new or modified files.
169159
Returns a set of relative paths of changed files.
170160
"""
171161
changed_files = set()
172-
162+
173163
# Find new or modified files
174164
for rel_path, after_state in after_states.items():
175165
if rel_path not in before_states:
@@ -179,20 +169,23 @@ def _find_changed_files(self,
179169
else:
180170
before_state = before_states[rel_path]
181171
# Check if file was modified (size, hash, or timestamp changed)
182-
if (before_state.size != after_state.size or
183-
before_state.md5_hash != after_state.md5_hash):
184-
logger.info(f"Modified file detected: {rel_path}, before={before_state.size}:{before_state.md5_hash}, after={after_state.size}:{after_state.md5_hash}")
172+
if before_state.size != after_state.size or before_state.md5_hash != after_state.md5_hash:
173+
logger.info(
174+
f"Modified file detected: {rel_path}, before={before_state.size}:{before_state.md5_hash}, after={after_state.size}:{after_state.md5_hash}"
175+
)
185176
changed_files.add(rel_path)
186177
else:
187178
logger.info(f"Unchanged file: {rel_path}, size={after_state.size}, hash={after_state.md5_hash}")
188-
179+
189180
# Add debug logs for summarizing scan results
190181
for rel_path in before_states:
191182
if rel_path not in after_states:
192183
logger.info(f"File deleted: {rel_path}")
193-
194-
logger.info(f"Before scan: {len(before_states)} files, After scan: {len(after_states)} files, Changed: {len(changed_files)} files")
195-
184+
185+
logger.info(
186+
f"Before scan: {len(before_states)} files, After scan: {len(after_states)} files, Changed: {len(changed_files)} files"
187+
)
188+
196189
return changed_files
197190

198191
async def _update_container_metrics(self, container) -> None:
@@ -274,10 +267,11 @@ async def execute(
274267
session_id: str,
275268
lang: Literal["py", "r"],
276269
files: Optional[List[Dict[str, Any]]] = None,
277-
timeout: int = 30,
270+
config: Optional[Dict[str, Any]] = None,
278271
) -> Dict[str, Any]:
279272
"""Execute code in a Docker container with file management."""
280273
container = None
274+
config = config or {}
281275

282276
try:
283277
# Ensure Docker client is initialized and valid
@@ -355,14 +349,24 @@ async def execute(
355349
logger.error(f"Error checking for image {image_name}: {str(e)}")
356350
raise
357351

352+
# Get container configuration, with provided config overriding settings
353+
memory_limit_mb = config.get("memory_limit_mb", settings.CONTAINER_MEMORY_LIMIT_MB)
354+
cpu_limit = config.get("cpu_limit", settings.CONTAINER_CPU_LIMIT)
355+
network_enabled = config.get("network_enabled", settings.DOCKER_NETWORK_ENABLED)
356+
357+
logger.info(
358+
f"Container config - Memory: {memory_limit_mb}MB, CPU: {cpu_limit}, Network: {network_enabled}"
359+
)
360+
358361
# Create container config
359362
config = {
360363
"Image": image_name,
361364
"Cmd": ["sleep", "infinity"],
362365
"WorkingDir": self.WORK_DIR,
363-
"NetworkDisabled": True,
366+
"NetworkDisabled": not network_enabled,
364367
"HostConfig": {
365-
"Memory": 512 * 1024 * 1024, # 512MB in bytes
368+
"Memory": memory_limit_mb * 1024 * 1024, # Convert MB to bytes
369+
"NanoCpus": int(cpu_limit * 1e9), # Convert CPU cores to nano CPUs
366370
"Mounts": [
367371
{
368372
"Type": "bind",
@@ -414,11 +418,11 @@ async def execute(
414418
# Execute the code with the appropriate interpreter
415419
logger.info(f"Code to execute: {code}")
416420
logger.info(f"Language: {lang}")
417-
421+
418422
# Get the execution command for the specified language
419423
exec_cmd = self.LANGUAGE_EXECUTORS.get(lang, self.LANGUAGE_EXECUTORS["py"])
420424
logger.info(f"Using execution command: {exec_cmd}")
421-
425+
422426
# Execute the code with the appropriate interpreter
423427
exec = await container.exec(cmd=[*exec_cmd, code], user="jovyan", stdout=True, stderr=True)
424428
# Use raw API call to get output
@@ -450,7 +454,7 @@ async def execute(
450454
output_files = []
451455
existing_filenames = {file["name"] for file in (files or [])}
452456
logger.info(f"Existing filenames: {existing_filenames}")
453-
457+
454458
for rel_path in changed_file_paths:
455459
file_path = session_path / rel_path
456460
if file_path.is_file():
@@ -466,7 +470,7 @@ async def execute(
466470
# Use directory structure in filepath if present
467471
filepath = f"{session_id}/{rel_path}"
468472
filename = Path(rel_path).name
469-
473+
470474
file_data = {
471475
"id": file_id,
472476
"session_id": session_id,

app/shared/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@ def LANGUAGE_CONTAINERS(self) -> Dict[str, str]:
9797

9898
# Docker execution settings
9999
MAX_CONCURRENT_CONTAINERS: int = 10 # Maximum number of concurrent Docker containers
100+
CONTAINER_MEMORY_LIMIT_MB: int = 512 # Memory limit for Docker containers in MB
101+
CONTAINER_CPU_LIMIT: float = 1.0 # CPU limit for Docker containers (number of cores)
102+
103+
# Docker network settings
104+
DOCKER_NETWORK_ENABLED: bool = False # Whether Docker containers have network access
100105

101106

102107
@lru_cache()

tests/main/test_memory_limit.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import pytest
2+
import logging
3+
from app.services.docker_executor import docker_executor
4+
from app.shared.config import get_settings
5+
from app.utils.generate_id import generate_id
6+
7+
# Setup logging
8+
logging.basicConfig(level=logging.INFO)
9+
logger = logging.getLogger(__name__)
10+
settings = get_settings()
11+
12+
13+
@pytest.fixture(scope="function", autouse=True)
14+
async def setup_docker():
15+
"""Setup and teardown Docker for tests."""
16+
# Initialize Docker
17+
await docker_executor.initialize()
18+
19+
# Yield control to tests
20+
yield
21+
22+
# Cleanup
23+
await docker_executor.close()
24+
25+
26+
@pytest.mark.asyncio
27+
async def test_memory_limit_enforced():
28+
"""Test that Docker container memory limits are enforced."""
29+
# Set a low memory limit (50MB)
30+
memory_limit_mb = 50
31+
session_id = generate_id()
32+
33+
# Print test info
34+
print(f"\nRunning memory limit test with limit: {memory_limit_mb}MB")
35+
36+
# Python code that attempts to allocate more memory than the limit
37+
code = """
38+
import numpy as np
39+
# Try to allocate more memory than the limit
40+
# Each float64 is 8 bytes, so 13,000,000 elements is about 100MB
41+
try:
42+
# Create a large array (> 50MB)
43+
large_array = np.ones(13_000_000, dtype=np.float64)
44+
print(f"Array created with shape {large_array.shape} and size {large_array.nbytes / (1024*1024):.2f} MB")
45+
except MemoryError:
46+
print("MemoryError: Memory limit enforced successfully")
47+
except Exception as e:
48+
print(f"Unexpected error: {type(e).__name__}: {e}")
49+
"""
50+
51+
# Call docker_executor directly instead of using the API
52+
result = await docker_executor.execute(
53+
code=code, session_id=session_id, lang="py", config={"memory_limit_mb": memory_limit_mb}
54+
)
55+
56+
# Print the result for debugging
57+
print(f"Memory limit test result: {result}")
58+
59+
# When memory limit is enforced, we could see either:
60+
# 1. A status of 'error' with empty stdout/stderr (container killed by OOM)
61+
# 2. A MemoryError message explicitly caught
62+
# 3. A 'Killed' message in stderr
63+
64+
# Check if memory limit enforcement was detected
65+
memory_error_detected = False
66+
67+
# Case 1: The container was terminated by OOM with status 'error'
68+
if result["status"] == "error" and result["stdout"] == "" and result["stderr"] == "":
69+
memory_error_detected = True
70+
print("Memory limit enforcement detected: Container was terminated with error status")
71+
72+
# Case 2: Explicit MemoryError caught by Python
73+
elif "MemoryError" in result["stdout"] or "MemoryError" in result["stderr"]:
74+
memory_error_detected = True
75+
print("Memory limit enforcement detected: MemoryError in output")
76+
77+
# Case 3: Process was killed due to memory limit
78+
elif "Killed" in result["stderr"]:
79+
memory_error_detected = True
80+
print("Memory limit enforcement detected: Process was killed")
81+
82+
# Container could also be terminated abnormally
83+
elif result["status"] == "error":
84+
memory_error_detected = True
85+
print("Memory limit enforcement detected: Container terminated abnormally")
86+
87+
# Assert that memory limit enforcement was detected in some form
88+
assert memory_error_detected, f"Memory limit enforcement not detected. Output: {result}"
89+
90+
91+
@pytest.mark.asyncio
92+
async def test_memory_limit_adequate():
93+
"""Test that adequate memory limits allow execution."""
94+
# Set a higher memory limit that should be sufficient
95+
memory_limit_mb = 200
96+
session_id = generate_id()
97+
98+
# Print test info
99+
print(f"\nRunning memory limit test with adequate limit: {memory_limit_mb}MB")
100+
101+
# Python code that allocates memory but stays under the limit
102+
code = """
103+
import numpy as np
104+
# Create an array that should fit within memory limits
105+
# Each float64 is 8 bytes, so ~13 million elements is about 100MB
106+
array = np.ones(13_000_000, dtype=np.float64)
107+
print(f"Array created with shape {array.shape} and size {array.nbytes / (1024*1024):.2f} MB")
108+
"""
109+
110+
# Call docker_executor directly instead of using the API
111+
result = await docker_executor.execute(
112+
code=code, session_id=session_id, lang="py", config={"memory_limit_mb": memory_limit_mb}
113+
)
114+
115+
# Print the result for debugging
116+
print(f"Adequate memory test result: {result}")
117+
118+
# The code should execute successfully
119+
assert result["status"] == "ok"
120+
assert "Array created with shape" in result["stdout"]
121+
assert "MB" in result["stdout"]

0 commit comments

Comments
 (0)