litellm/tests/code_coverage_tests/memory_test.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

776 lines
34 KiB
Python
Raw Normal View History

"""
Memory Violation Detection Test
Detects bad memory patterns in the LiteLLM codebase that can lead to memory leaks or OOMs.
The detector uses a modular pattern-based system. To add detection for new memory patterns:
1. Create a Pattern subclass implementing get_pattern_name(), visit_assign(), and check_cleanup()
- You can extend the Pattern class with additional methods as needed for your detection logic
2. Add the pattern to MemoryViolationDetector.DEFAULT_PATTERNS
Currently detects:
- queue.get() / queue.get_nowait() operations where variables aren't set to None
- Class-level data structures that have add operations during runtime without size limits:
* Built-in: list, dict, set
* Collections: deque, defaultdict, Counter, OrderedDict, ChainMap
* Queues: queue.Queue, asyncio.Queue (if unbounded, i.e., no maxsize parameter)
* Heap operations: heapq.heappush(), heapq.heapreplace(), heapq.heappushpop() on class-level lists
"""
import ast
import os
from abc import ABC, abstractmethod
from typing import List, Dict, Any, Optional, Sequence
class Pattern(ABC):
"""Base class for memory violation detection patterns"""
@abstractmethod
def get_pattern_name(self) -> str:
"""Return unique identifier for this violation type"""
pass
@abstractmethod
def visit_assign(
self, node: ast.Assign, context: Dict[str, Any]
) -> List[Dict[str, Any]]:
"""Detect memory-sensitive operations in assignment. Returns list of {line, var_name, call} dicts."""
pass
@abstractmethod
def check_cleanup(
self,
operations: List[Dict[str, Any]],
function_body: List[ast.stmt],
context: Dict[str, Any],
) -> List[Dict[str, Any]]:
"""Verify variables are set to None. Returns list of violation dicts."""
pass
class QueueGetPattern(Pattern):
"""Detects queue.get()/get_nowait() operations that aren't cleared"""
def get_pattern_name(self) -> str:
return "queue_reference_not_cleared"
def visit_assign(
self, node: ast.Assign, context: Dict[str, Any]
) -> List[Dict[str, Any]]:
"""Detect queue.get() or queue.get_nowait() calls where object name contains 'queue'"""
operations = []
if isinstance(node.value, ast.Call):
func = node.value.func
if isinstance(func, ast.Attribute) and func.attr in ("get", "get_nowait"):
obj_name = context["get_attr_string"](func.value)
if (
"queue" in obj_name.lower()
and node.targets
and isinstance(node.targets[0], ast.Name)
):
operations.append(
{
"line": node.lineno,
"var_name": node.targets[0].id,
"call": context["get_call_string"](node.value),
}
)
return operations
def check_cleanup(
self,
operations: List[Dict[str, Any]],
function_body: List[ast.stmt],
context: Dict[str, Any],
) -> List[Dict[str, Any]]:
"""Flag queue variables that aren't set to None"""
violations = []
is_var_set_to_none = context["is_var_set_to_none"]
current_function = context["current_function"]
file_path = context["file_path"]
queue_vars = {op["var_name"]: op["line"] for op in operations}
for var_name, line_num in queue_vars.items():
if not is_var_set_to_none(var_name, function_body):
violations.append(
{
"line": line_num,
"type": self.get_pattern_name(),
"var_name": var_name,
"function": current_function,
"file_path": file_path,
"message": (
f"Queue variable '{var_name}' in function "
f"'{current_function}' is not set to None after use. "
f"If the runtime is overwhelmed, this can cause OOM (Out of Memory) errors."
),
}
)
return violations
class UnboundedDataStructurePattern(Pattern):
"""Detects class-level data structures (lists, dicts, sets) that can grow unbounded"""
def get_pattern_name(self) -> str:
return "unbounded_data_structure"
def visit_assign(
self, node: ast.Assign, context: Dict[str, Any]
) -> List[Dict[str, Any]]:
"""Detect list/dict/set creations that are at class level"""
operations = []
# Check if this is a data structure creation
is_data_structure = False
structure_type = None
if isinstance(node.value, (ast.List, ast.Dict, ast.Set)):
is_data_structure = True
if isinstance(node.value, ast.List):
structure_type = "list"
elif isinstance(node.value, ast.Dict):
structure_type = "dict"
elif isinstance(node.value, ast.Set):
structure_type = "set"
elif isinstance(node.value, ast.Call):
# Check for list(), dict(), set() calls
func = node.value.func
if isinstance(func, ast.Name):
if func.id in ("list", "dict", "set"):
is_data_structure = True
structure_type = func.id
elif isinstance(func, ast.Attribute):
# Handle cases like collections.defaultdict(list), collections.deque(), etc.
obj_name = context["get_attr_string"](func.value)
attr_name = func.attr
# Check for collections module data structures
if "collections" in obj_name.lower() or "collections" in str(
func.value
):
if attr_name in (
"deque",
"defaultdict",
"Counter",
"OrderedDict",
"ChainMap",
):
# For deque, we track it and let size checks determine if it's bounded
# (deque with maxlen parameter is bounded, but we detect that via size checks)
is_data_structure = True
structure_type = attr_name
elif attr_name in ("list", "dict", "set"):
# collections.defaultdict(list) pattern
is_data_structure = True
structure_type = (
"defaultdict"
if "defaultdict" in obj_name.lower()
else attr_name
)
# Check for queue.Queue, asyncio.Queue (if unbounded)
elif "queue" in obj_name.lower() or "asyncio" in obj_name.lower():
if attr_name == "Queue":
# Check if maxsize is set (bounded queue)
has_maxsize = False
for keyword in node.value.keywords:
if keyword.arg == "maxsize":
has_maxsize = True
break
if not has_maxsize:
is_data_structure = True
structure_type = "queue"
# Direct attribute access like deque(), Counter(), etc.
elif attr_name in (
"deque",
"defaultdict",
"Counter",
"OrderedDict",
"ChainMap",
):
is_data_structure = True
structure_type = attr_name
if is_data_structure and node.targets and isinstance(node.targets[0], ast.Name):
scope = context.get("current_scope", "function")
# Only track if it's at class level (not module level)
if scope == "class":
operations.append(
{
"line": node.lineno,
"var_name": node.targets[0].id,
"structure_type": structure_type,
"scope": scope,
"call": (
context["get_call_string"](node.value)
if isinstance(node.value, ast.Call)
else f"{structure_type}()"
),
}
)
return operations
def check_cleanup(
self,
operations: List[Dict[str, Any]],
function_body: List[ast.stmt],
context: Dict[str, Any],
) -> List[Dict[str, Any]]:
"""Flag persistent data structures that have add operations without size limits"""
violations = []
current_function = context["current_function"]
current_scope = context.get("current_scope", "function")
file_path = context["file_path"]
get_attr_string = context["get_attr_string"]
# Skip if this is initialization code (module-level, class-level, or __init__ methods)
# Only flag operations in regular methods/functions that can be called during runtime
is_initialization = (
current_scope in ("module", "class")
or current_function in ("__init__", "__new__", "__class_init__")
or current_function is None # Module-level code
)
if is_initialization:
return violations # Don't flag initialization code
# Track which variables have add operations and size checks
var_add_operations = {} # var_name -> list of lines with add operations
var_size_checks = {} # var_name -> has size limit check
# Build a set of variable names to check
tracked_vars = {op["var_name"]: op for op in operations}
# Scan body for operations on these variables
for stmt in function_body:
for node in ast.walk(stmt):
# Check for method calls that add items
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
attr_name = node.func.attr
obj_name = get_attr_string(node.func.value)
# Check if this is an add operation on one of our tracked variables
for var_name, op in tracked_vars.items():
structure_type = op["structure_type"]
# Match variable name (exact or as attribute)
if (
obj_name == var_name
or obj_name.endswith(f".{var_name}")
or obj_name.endswith(f"['{var_name}']")
):
# Check for add operations
add_ops = {
"list": ["append", "extend", "insert"],
"dict": ["update", "setdefault"],
"set": ["add", "update"],
"deque": [
"append",
"appendleft",
"extend",
"extendleft",
"insert",
],
"defaultdict": ["update", "setdefault"],
"Counter": ["update"],
"OrderedDict": ["update", "setdefault"],
"ChainMap": ["new_child"],
"queue": ["put", "put_nowait"],
}
if attr_name in add_ops.get(structure_type, []):
if var_name not in var_add_operations:
var_add_operations[var_name] = []
var_add_operations[var_name].append(node.lineno)
# Check for size limit checks (len() calls, maxsize/maxlen attributes)
if (
attr_name in ("__len__",)
or "maxsize" in attr_name.lower()
or "max_size" in attr_name.lower()
or attr_name == "maxlen"
): # For deque
var_size_checks[var_name] = True
# Check for heapq operations on tracked lists (heapq.heappush, heapq.heappop)
if isinstance(node, ast.Call):
func = node.func
# Check for heapq.heappush(list_var, item) or heapq.heappop(list_var)
if isinstance(func, ast.Attribute):
func_obj = get_attr_string(func.value)
func_name = func.attr
# Check if it's a heapq operation
if func_obj == "heapq" and func_name in (
"heappush",
"heapreplace",
"heappushpop",
):
# First argument should be our tracked variable
if len(node.args) > 0:
arg_name = get_attr_string(node.args[0])
for var_name, op in tracked_vars.items():
if op["structure_type"] == "list" and (
arg_name == var_name
or arg_name.endswith(f".{var_name}")
):
if var_name not in var_add_operations:
var_add_operations[var_name] = []
var_add_operations[var_name].append(node.lineno)
# Check for dict item assignment: dict[key] = value
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Subscript):
target_name = get_attr_string(target.value)
for var_name in tracked_vars:
if target_name == var_name or target_name.endswith(
f".{var_name}"
):
if var_name not in var_add_operations:
var_add_operations[var_name] = []
var_add_operations[var_name].append(node.lineno)
# Check for augmented assignment: list += [...]
if isinstance(node, ast.AugAssign):
target_name = get_attr_string(node.target)
for var_name in tracked_vars:
if target_name == var_name or target_name.endswith(
f".{var_name}"
):
if var_name not in var_add_operations:
var_add_operations[var_name] = []
var_add_operations[var_name].append(node.lineno)
# Check for size comparisons in conditionals
if isinstance(node, (ast.If, ast.While, ast.Assert)):
test = getattr(node, "test", None)
if test:
for comp_node in ast.walk(test):
if isinstance(comp_node, ast.Compare):
left_str = (
get_attr_string(comp_node.left)
if hasattr(comp_node, "left")
else ""
)
# Check for len() calls
if isinstance(comp_node.left, ast.Call):
call_func = comp_node.left.func
if (
isinstance(call_func, ast.Name)
and call_func.id == "len"
):
if len(comp_node.left.args) > 0:
arg_name = get_attr_string(
comp_node.left.args[0]
)
for var_name in tracked_vars:
if (
arg_name == var_name
or arg_name.endswith(f".{var_name}")
):
# Check if comparing to a limit
for (
comparator
) in comp_node.comparators:
if isinstance(
comparator, ast.Constant
):
var_size_checks[
var_name
] = True
elif isinstance(
comparator, ast.Name
):
# Could be a constant like MAX_SIZE
if (
"max"
in comparator.id.lower()
or "limit"
in comparator.id.lower()
):
var_size_checks[
var_name
] = True
# Handle deprecated ast.Num for Python < 3.8
try:
Num = getattr(
ast, "Num", None
)
if Num and isinstance(
comparator, Num
):
var_size_checks[
var_name
] = True
except (
AttributeError,
TypeError,
):
pass
# Check for direct variable comparisons
for var_name in tracked_vars:
if var_name in left_str:
for comparator in comp_node.comparators:
if isinstance(comparator, ast.Constant):
var_size_checks[var_name] = True
# Handle deprecated ast.Num for Python < 3.8
try:
Num = getattr(ast, "Num", None)
if Num and isinstance(comparator, Num):
var_size_checks[var_name] = True
except (AttributeError, TypeError):
pass
# Flag violations: persistent structures with add operations but no size checks
for op in operations:
var_name = op["var_name"]
structure_type = op["structure_type"]
if var_name in var_add_operations and var_name not in var_size_checks:
violations.append(
{
"line": op["line"],
"type": self.get_pattern_name(),
"var_name": var_name,
"function": current_function or "class-level",
"file_path": file_path,
"message": (
f"Class-level {structure_type} '{var_name}' "
f"has add operations (lines {var_add_operations[var_name]}) but no size limit checks. "
f"This can lead to unbounded memory growth and OOM errors during runtime."
),
}
)
return violations
class MemoryViolationDetector(ast.NodeVisitor):
"""AST visitor that detects memory violations using registered patterns"""
DEFAULT_PATTERNS: List[Pattern] = [
QueueGetPattern(),
UnboundedDataStructurePattern(),
]
def __init__(self, file_path: str, patterns: Optional[Sequence[Pattern]] = None):
self.file_path = file_path
self.violations: List[Dict[str, Any]] = []
self.current_function: Optional[str] = None
self.current_scope: str = (
"module" # Track current scope: module, class, function
)
self.patterns = self.DEFAULT_PATTERNS if patterns is None else patterns
self.ast_tree: Optional[ast.Module] = (
None # Store full AST for module-level checks
)
self.pattern_operations: Dict[str, List[Dict[str, Any]]] = {
pattern.get_pattern_name(): [] for pattern in self.patterns
}
# Track class-level operations separately (for checking in functions)
self.class_level_operations: Dict[str, List[Dict[str, Any]]] = {
pattern.get_pattern_name(): [] for pattern in self.patterns
}
self._context = {
"get_call_string": self._get_call_string,
"get_attr_string": self._get_attr_string,
"is_var_set_to_none": self._is_var_set_to_none,
"current_function": None,
"current_scope": "module",
"file_path": file_path,
}
def visit_ClassDef(self, node):
"""Track class scope"""
old_scope = self.current_scope
self.current_scope = "class"
self._context["current_scope"] = "class"
self.generic_visit(node)
self.current_scope = old_scope
self._context["current_scope"] = old_scope
def visit_FunctionDef(self, node):
"""Track function scope and check cleanup after visiting"""
old_function = self.current_function
old_scope = self.current_scope
self.current_function = node.name
self.current_scope = "function"
self._context["current_function"] = node.name
self._context["current_scope"] = "function"
for pattern_name in self.pattern_operations:
self.pattern_operations[pattern_name] = []
self.generic_visit(node)
self._check_function_cleanup(node)
self.current_function = old_function
self.current_scope = old_scope
self._context["current_function"] = old_function
self._context["current_scope"] = old_scope
def visit_AsyncFunctionDef(self, node):
"""Track async function scope and check cleanup after visiting"""
old_function = self.current_function
old_scope = self.current_scope
self.current_function = node.name
self.current_scope = "function"
self._context["current_function"] = node.name
self._context["current_scope"] = "function"
for pattern_name in self.pattern_operations:
self.pattern_operations[pattern_name] = []
self.generic_visit(node)
self._check_function_cleanup(node)
self.current_function = old_function
self.current_scope = old_scope
self._context["current_function"] = old_function
self._context["current_scope"] = old_scope
def visit_Assign(self, node):
"""Detect memory-sensitive operations in assignments"""
for pattern in self.patterns:
operations = pattern.visit_assign(node, self._context)
# Track function-level operations
self.pattern_operations[pattern.get_pattern_name()].extend(operations)
# Track class-level operations separately (for checking in functions)
for op in operations:
if op.get("scope") == "class":
self.class_level_operations[pattern.get_pattern_name()].append(op)
self.generic_visit(node)
def _check_function_cleanup(self, node):
"""Check cleanup for all detected operations"""
for pattern in self.patterns:
operations = self.pattern_operations[pattern.get_pattern_name()]
if operations:
violations = pattern.check_cleanup(operations, node.body, self._context)
self.violations.extend(violations)
# For UnboundedDataStructurePattern, also check if this function modifies class-level structures
if isinstance(pattern, UnboundedDataStructurePattern):
class_ops = self.class_level_operations[pattern.get_pattern_name()]
if class_ops and self.current_function not in (
"__init__",
"__new__",
"__class_init__",
None,
):
# Check if this regular function modifies class-level structures
violations = pattern.check_cleanup(
class_ops, node.body, self._context
)
self.violations.extend(violations)
def _check_module_level_cleanup(self):
"""Check cleanup for module/class level operations"""
# Module-level operations are now checked when visiting functions
# This method is kept for potential future use but doesn't need to do anything
# since we only want to flag runtime modifications in functions, not initialization code
pass
def _is_var_set_to_none(self, var_name: str, body: List[ast.stmt]) -> bool:
"""Check if variable is set to None after its initial assignment"""
assignment_line = None
for stmt in body:
for node in ast.walk(stmt):
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id == var_name:
assignment_line = node.lineno
break
if assignment_line:
break
if assignment_line:
break
if not assignment_line:
return False
for stmt in body:
for node in ast.walk(stmt):
if isinstance(node, ast.Assign):
for target in node.targets:
if (
isinstance(target, ast.Name)
and target.id == var_name
and node.lineno > assignment_line
):
if (
isinstance(node.value, ast.Constant)
and node.value.value is None
):
return True
try:
NameConstant = getattr(ast, "NameConstant", None)
if NameConstant and isinstance(
node.value, NameConstant
):
if getattr(node.value, "value", None) is None:
return True
except (AttributeError, TypeError):
pass
return False
def _get_call_string(self, node: ast.Call) -> str:
"""Get string representation of function call"""
try:
if hasattr(ast, "unparse"):
return ast.unparse(node)
elif isinstance(node.func, ast.Attribute):
return f"{self._get_attr_string(node.func.value)}.{node.func.attr}()"
return str(node)
except Exception:
return str(node)
def _get_attr_string(self, node: ast.AST) -> str:
"""Get string representation of attribute access"""
if isinstance(node, ast.Name):
return node.id
elif isinstance(node, ast.Attribute):
return f"{self._get_attr_string(node.value)}.{node.attr}"
return str(node)
def check_file_for_memory_violations(
file_path: str, patterns: Optional[Sequence[Pattern]] = None
) -> List[Dict[str, Any]]:
"""Check a single file for memory violations"""
try:
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
if "test" in file_path.lower() or "__pycache__" in file_path:
return []
tree = ast.parse(content, filename=file_path)
detector = MemoryViolationDetector(file_path, patterns)
detector.ast_tree = tree # Store AST for potential future use
detector.visit(tree)
# Class-level operations are checked when visiting functions
return detector.violations
except Exception as e:
print(f"Error parsing {file_path}: {e}")
return []
def check_directory_for_memory_violations(
directory_path: str,
ignore_patterns: Optional[List[str]] = None,
patterns: Optional[Sequence[Pattern]] = None,
) -> List[Dict[str, Any]]:
"""Recursively scan directory for memory violations"""
if ignore_patterns is None:
ignore_patterns = [
"__pycache__",
".pyc",
"site-packages",
"venv",
".venv",
"env",
".env",
"node_modules",
"tests",
]
all_violations = []
for root, _dirs, files in os.walk(directory_path):
if any(pattern in root for pattern in ignore_patterns):
continue
for file in files:
if file.endswith(".py"):
violations = check_file_for_memory_violations(
os.path.join(root, file), patterns
)
all_violations.extend(violations)
return all_violations
def main():
"""Run memory violation detection on codebase"""
codebase_path = "./litellm"
print("=" * 80)
print("MEMORY VIOLATION DETECTION TEST")
print("=" * 80)
print(f"Scanning: {codebase_path}")
print(
f"Active patterns: {', '.join(p.get_pattern_name() for p in MemoryViolationDetector.DEFAULT_PATTERNS)}"
)
print()
violations = check_directory_for_memory_violations(codebase_path)
if violations:
by_type = {}
for v in violations:
vtype = v["type"]
if vtype not in by_type:
by_type[vtype] = []
by_type[vtype].append(v)
print("MEMORY VIOLATIONS FOUND:")
print("=" * 80)
total = len(violations)
for vtype, vlist in by_type.items():
print(f"\n{vtype.upper().replace('_', ' ')}: {len(vlist)} violation(s)")
print("-" * 80)
for v in vlist[:10]:
print(
f" [VIOLATION] {v['file_path'] if 'file_path' in v else 'unknown'}:{v['line']}"
)
print(f" Function: {v['function']}")
print(f" Variable: {v['var_name']}")
print(f" {v['message']}")
print()
if len(vlist) > 10:
print(f" ... and {len(vlist) - 10} more violations of this type")
print("=" * 80)
print(f"TOTAL VIOLATIONS: {total}")
print()
print("RECOMMENDATIONS:")
print(
" 1. Set queue variables to None after use: obj = queue.get(); ...; obj = None"
)
print(" 2. Use bounded queues to prevent unbounded accumulation")
print(
" 3. Process items faster than they're added, or drain queues periodically"
)
print(
" 4. For class-level data structures (lists, dicts, sets) that are modified at runtime:"
)
print(" - Add size limit checks: if len(data) >= MAX_SIZE: ...")
print(" - Implement periodic cleanup or use bounded collections")
print(" - Consider using collections.deque with maxlen for lists")
print("=" * 80)
first_v = violations[0]
raise Exception(
f"Found {total} memory violations! "
f"First violation: {first_v.get('file_path', 'unknown')}:{first_v['line']} - "
f"{first_v['message']}"
)
else:
print("OK No memory violations found!")
if __name__ == "__main__":
main()