Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add option to limit the memory usage of Lua code #212

Merged
merged 57 commits into from
Apr 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
2669b63
add memory limit
Le0Developer May 8, 2022
b58a05e
add panic handler
Le0Developer May 8, 2022
bbfaf16
allow accessing and changing max memory
Le0Developer May 8, 2022
3d5cd61
add docs
Le0Developer May 8, 2022
f574206
add LuaMemoryError
Le0Developer May 8, 2022
b125d1e
fix compiler warning
Le0Developer May 8, 2022
bb69bcc
use 0 to denote unlimited memory and always use our allocator
Le0Developer May 8, 2022
d607896
add tests
Le0Developer May 8, 2022
22e4081
use python mem allocator and fix memory leak
Le0Developer May 8, 2022
b0306de
fix test by using a global so lua doesnt gc it
Le0Developer May 8, 2022
4103220
revert to libc allocator
Le0Developer May 8, 2022
158e922
remove memory_left property
Le0Developer May 8, 2022
007a913
add nogil flag
Le0Developer May 8, 2022
881d754
implement suggestions
Le0Developer May 20, 2022
ef0fb32
use lua allocator; add strict for set_max_memory; add docs
Le0Developer May 22, 2022
6d3463f
add more tests
Le0Developer May 22, 2022
5b4dc02
update docs
Le0Developer May 22, 2022
46e3507
add memory_used
Le0Developer May 22, 2022
68aad66
welp
Le0Developer May 22, 2022
d823152
update test
Le0Developer Aug 3, 2022
00a81fe
revert this change
Le0Developer Aug 3, 2022
9a8d5c1
Merge branch 'master' into feat/max-memory
scoder Aug 7, 2022
3aa4d1d
Minor code cleanups.
scoder Aug 7, 2022
d907edf
Improve docstring.
scoder Aug 7, 2022
bde8543
Fix tests.
scoder Aug 7, 2022
82502a6
ignore luajit
Le0Developer Aug 7, 2022
b1806fc
fix tests
Le0Developer Aug 7, 2022
a7386f3
catch more memory errors
Le0Developer Aug 7, 2022
4143da5
inherit from MemoryError you shall
Le0Developer Aug 7, 2022
c43fbb9
fix python2.x compat & actually skip initializing lua
Le0Developer Aug 8, 2022
aedeb6e
add test for compile and fix memory error assert
Le0Developer Aug 8, 2022
93f61be
possible fix for flaky test on win?
Le0Developer Aug 8, 2022
6d874d5
this should be at the bottom, no?
Le0Developer Aug 8, 2022
4b49057
Merge branch 'master' into feat/max-memory
scoder Aug 14, 2022
7ac2d46
Clean up code and fix a couple of issues.
scoder Aug 14, 2022
b479160
Improve wording in changelog.
scoder Aug 14, 2022
89540d0
Clarify and fix memory calculation logic in allocation function.
scoder Aug 14, 2022
215a262
use struct
Le0Developer Sep 17, 2022
86537ea
dont need these
Le0Developer Sep 17, 2022
17e8c65
update set_max_memory docstring
Le0Developer Sep 23, 2022
437c58d
use keyword arguments for better readability
Le0Developer Sep 23, 2022
95eb2a5
add support for unlimited memory (0)
Le0Developer Sep 23, 2022
4066683
Try to get by without dynamically allocating the memory state.
scoder Sep 26, 2022
f7659e4
count_base -> total
Le0Developer Sep 26, 2022
4287c23
limit fixes
Le0Developer Sep 26, 2022
5e2e673
attempt to document in readme
Le0Developer Sep 26, 2022
48af7ac
Update README.rst
Le0Developer Sep 26, 2022
0fc83c2
Update README.rst
Le0Developer Sep 26, 2022
7b49c69
Move struct declaration before first usage
scoder Sep 26, 2022
396e9dd
Update README.rst
Le0Developer Sep 26, 2022
21f2945
fix issues
Le0Developer Sep 26, 2022
40cf327
Update README.rst
Le0Developer Sep 26, 2022
07a6b84
Remove unnecessary initialisations
scoder Sep 26, 2022
8390d1f
Update README.rst
Le0Developer Sep 26, 2022
5ae6a85
Ignore fully qualified exception name in doctest (Py2/3).
scoder Sep 27, 2022
2b9cd96
size_t cannot be < 0
scoder Nov 16, 2022
79608b8
Prevent usage of our special limit value -1.
scoder Nov 16, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ Lupa change log
Lua 5.4, LuaJIT 2.0 and LuaJIT 2.1 beta. Note that this is build specific
and may depend on the platform. A normal Python import cascade can be used.

* GH#211: A new option `max_memory` allows to limit the memory usage of Lua code.
(patch by Leo Developer)

* GH#171: Python references in Lua are now more safely reference counted
to prevent garbage collection glitches.
(patch by Guilherme Dantas)
Expand Down
46 changes: 46 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,52 @@ setter function implementations for a ``LuaRuntime``:
AttributeError: not allowed to write attribute "noway"


Restricting Lua Memory Usage
----------------------------

Lupa provides a simple mechanism to control the maximum memory
usage of the Lua Runtime since version 2.0.
By default Lupa does not interfere with Lua's memory allocation, to opt-in
you must set the ``max_memory`` when creating the LuaRuntime.

The ``LuaRuntime`` provides three methods for controlling and reading the
memory usage:

1. ``get_memory_used(total=False)`` to get the current memory
usage of the LuaRuntime.

2. ``get_max_memory(total=False)`` to get the current memory limit.
``0`` means there is no memory limitation.

3. ``set_max_memory(max_memory, total=False)`` to change the memory limit.
Values below or equal to 0 mean no limit.

There is always some memory used by the LuaRuntime itself (around ~20KiB,
depending on your lua version and other factors) which is excluded from all
calculations unless you specify ``total=True``.

.. code:: python

>>> lua = LuaRuntime(max_memory=0) # 0 for unlimited, default is None
>>> lua.get_memory_used() # memory used by your code
0
>>> total_lua_memory = lua.get_memory_used(total=True) # includes memory used by the runtime itself
>>> assert total_lua_memory > 0 # exact amount depends on your lua version and other factors


Lua code hitting the memory limit will receive memory errors:

.. code:: python

>>> lua.set_max_memory(100)
>>> lua.eval("string.rep('a', 1000)") # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
lupa.LuaMemoryError: not enough memory

``LuaMemoryError`` inherits from ``LuaError`` and ``MemoryError``.


Importing Lua binary modules
----------------------------

Expand Down
170 changes: 155 additions & 15 deletions lupa/_lupa.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ from __future__ import absolute_import
cimport cython

from libc.string cimport strlen, strchr
from libc.stdlib cimport malloc, free, realloc
from libc.stdio cimport fprintf, stderr, fflush
from . cimport luaapi as lua
from .luaapi cimport lua_State

Expand Down Expand Up @@ -58,7 +60,7 @@ from functools import wraps


__all__ = ['LUA_VERSION', 'LUA_MAXINTEGER', 'LUA_MININTEGER',
'LuaRuntime', 'LuaError', 'LuaSyntaxError',
'LuaRuntime', 'LuaError', 'LuaSyntaxError', 'LuaMemoryError',
'as_itemgetter', 'as_attrgetter', 'lua_type',
'unpacks_lua_table', 'unpacks_lua_table_method']

Expand Down Expand Up @@ -111,6 +113,12 @@ else: # probably not smaller
LUA_MININTEGER, LUA_MAXINTEGER = (CHAR_MIN, CHAR_MAX)


cdef struct MemoryStatus:
size_t used
size_t base_usage
size_t limit


class LuaError(Exception):
"""Base class for errors in the Lua runtime.
"""
Expand All @@ -121,6 +129,11 @@ class LuaSyntaxError(LuaError):
"""


class LuaMemoryError(LuaError, MemoryError):
"""Memory error in Lua code.
"""


def lua_type(obj):
"""
Return the Lua type name of a wrapped object as string, as provided
Expand Down Expand Up @@ -217,6 +230,12 @@ cdef class LuaRuntime:
Normally, it should return the now well-behaved object that can be
converted/wrapped to a Lua type. If the object cannot be precisely
represented in Lua, it should raise an ``OverflowError``.

* ``max_memory``: max memory usage this LuaRuntime can use in bytes.
If max_memory is None, the default lua allocator is used and calls to
``set_max_memory(limit)`` will fail with a ``LuaMemoryError``.
Note: Not supported on 64bit LuaJIT.
(default: None, i.e. no limitation. New in Lupa 2.0)

Example usage::

Expand All @@ -242,14 +261,23 @@ cdef class LuaRuntime:
cdef object _attribute_getter
cdef object _attribute_setter
cdef bint _unpack_returned_tuples
cdef MemoryStatus _memory_status

def __cinit__(self, encoding='UTF-8', source_encoding=None,
attribute_filter=None, attribute_handlers=None,
bint register_eval=True, bint unpack_returned_tuples=False,
bint register_builtins=True, overflow_handler=None):
cdef lua_State* L = lua.luaL_newstate()
bint register_builtins=True, overflow_handler=None,
max_memory=None):
cdef lua_State* L

if max_memory is None:
L = lua.luaL_newstate()
self._memory_status.limit = <size_t> -1
else:
L = lua.lua_newstate(<lua.lua_Alloc>&_lua_alloc_restricted, <void*>&self._memory_status)
if L is NULL:
raise LuaError("Failed to initialise Lua runtime")

self._state = L
self._lock = FastRLock()
self._pyrefs_in_lua = {}
Expand All @@ -276,17 +304,56 @@ cdef class LuaRuntime:
raise ValueError("attribute_filter and attribute_handlers are mutually exclusive")
self._attribute_getter, self._attribute_setter = getter, setter

lua.lua_atpanic(L, &_lua_panic)
lua.luaL_openlibs(L)
self.init_python_lib(register_eval, register_builtins)
lua.lua_atpanic(L, <lua.lua_CFunction>1)

self.set_overflow_handler(overflow_handler)

# lupa init done, set real limit
if max_memory is not None:
self._memory_status.base_usage = self._memory_status.used
if max_memory > 0:
self._memory_status.limit = self._memory_status.base_usage + <size_t>max_memory
# Prevent accidental (or deliberate) usage of our special value.
if self._memory_status.limit == <size_t> -1:
self._memory_status.limit -= 1

def __dealloc__(self):
if self._state is not NULL:
lua.lua_close(self._state)
self._state = NULL

def get_max_memory(self, total=False):
"""
Maximum memory allowed to be used by this LuaRuntime.
0 indicates no limit meanwhile None indicates that the default lua
allocator is being used and ``set_max_memory()`` cannot be used.

If ``total`` is True, the base memory used by the lua runtime
will be included in the limit.
"""
if self._memory_status.limit == <size_t> -1:
return None
elif total:
return self._memory_status.limit
return self._memory_status.limit - self._memory_status.base_usage

def get_memory_used(self, total=False):
"""
Memory currently in use.
This is None if the default lua allocator is used and 0 if
``max_memory`` is 0.

If ``total`` is True, the base memory used by the lua runtime
will be included.
"""
if self._memory_status.limit == <size_t> -1:
return None
elif total:
return self._memory_status.used
return self._memory_status.used - self._memory_status.base_usage

@property
def lua_version(self):
"""
Expand Down Expand Up @@ -360,7 +427,14 @@ cdef class LuaRuntime:
return py_from_lua(self, L, -1)
else:
err = lua.lua_tolstring(L, -1, &size)
error = err[:size] if self._encoding is None else err[:size].decode(self._encoding)
if self._encoding is None:
error = err[:size] # bytes
is_memory_error = b"not enough memory" in error
else:
error = err[:size].decode(self._encoding)
is_memory_error = u"not enough memory" in error
if is_memory_error:
raise LuaMemoryError(error)
raise LuaSyntaxError(error)
finally:
lua.lua_settop(L, old_top)
Expand Down Expand Up @@ -460,6 +534,29 @@ cdef class LuaRuntime:
lua.lua_settop(L, old_top)
unlock_runtime(self)

def set_max_memory(self, size_t max_memory, total=False):
"""Set maximum allowed memory for this LuaRuntime.

If `max_memory` is 0, there will be no limit.
If ``total`` is True, the base memory used by the LuaRuntime itself
will be included in the memory limit.

If max_memory was set to None during creation, this will raise a
RuntimeError.
"""
cdef size_t used
if self._memory_status.limit == <size_t> -1:
raise RuntimeError("max_memory must be set on LuaRuntime creation")
elif max_memory == 0:
self._memory_status.limit = 0
elif total:
self._memory_status.limit = max_memory
else:
self._memory_status.limit = self._memory_status.base_usage + max_memory
# Prevent accidental (or deliberate) usage of our special value.
if self._memory_status.limit == <size_t> -1:
self._memory_status.limit -= 1

def set_overflow_handler(self, overflow_handler):
"""Set the overflow handler function that is called on failures to pass large numbers to Lua.
"""
Expand Down Expand Up @@ -584,7 +681,7 @@ cdef int check_lua_stack(lua_State* L, int extra) except -1:
"""
assert extra >= 0
if not lua.lua_checkstack(L, extra):
raise MemoryError
raise LuaMemoryError
return 0


Expand Down Expand Up @@ -1558,9 +1655,12 @@ cdef int raise_lua_error(LuaRuntime runtime, lua_State* L, int result) except -1
if result == 0:
return 0
elif result == lua.LUA_ERRMEM:
raise MemoryError()
raise LuaMemoryError()
else:
raise LuaError(build_lua_error_message(runtime, L))
error_message = build_lua_error_message(runtime, L)
if u"not enough memory" in error_message:
raise LuaMemoryError(error_message)
raise LuaError(error_message)


cdef bint _looks_like_traceback_line(unicode line):
Expand Down Expand Up @@ -1597,9 +1697,8 @@ cdef unicode _reorder_lua_stack_trace(unicode error_message):
return error_message


cdef build_lua_error_message(LuaRuntime runtime, lua_State* L, unicode err_message=None, int stack_index=-1):
cdef build_lua_error_message(LuaRuntime runtime, lua_State* L, int stack_index=-1):
"""Removes the string at the given stack index ``n`` to build an error message.
If ``err_message`` is provided, it is used as a %-format string to build the error message.
"""
cdef size_t size = 0
cdef const char *s = lua.lua_tolstring(L, stack_index, &size)
Expand All @@ -1615,9 +1714,6 @@ cdef build_lua_error_message(LuaRuntime runtime, lua_State* L, unicode err_messa
if u"stack traceback:" in py_ustring:
py_ustring = _reorder_lua_stack_trace(py_ustring)

if err_message is not None:
py_ustring = err_message % py_ustring

return py_ustring


Expand All @@ -1631,8 +1727,10 @@ cdef run_lua(LuaRuntime runtime, bytes lua_code, tuple args):
try:
check_lua_stack(L, 1)
if lua.luaL_loadbuffer(L, lua_code, len(lua_code), '<python>'):
raise LuaSyntaxError(build_lua_error_message(
runtime, L, err_message=u"error loading code: %s"))
error = build_lua_error_message(runtime, L)
if error.startswith("not enough memory"):
raise LuaMemoryError(error)
raise LuaSyntaxError(u"error loading code: " + error)
return call_lua(runtime, L, args)
finally:
lua.lua_settop(L, old_top)
Expand Down Expand Up @@ -1725,6 +1823,48 @@ cdef tuple unpack_multiple_lua_results(LuaRuntime runtime, lua_State *L, int nar
return args


# bounded memory allocation

cdef void* _lua_alloc_restricted(void* ud, void* ptr, size_t old_size, size_t new_size) nogil:
# adapted from https://stackoverflow.com/a/9672205
# print(<size_t>ud, <size_t>ptr, old_size, new_size)
cdef MemoryStatus* memory_status = <MemoryStatus*>ud
# print(" ", memory_status.used, memory_status.base_usage, memory_status.limit)

if ptr is NULL:
# <http://www.lua.org/manual/5.2/manual.html#lua_Alloc>:
# When ptr is NULL, old_size encodes the kind of object that Lua is allocating.
# Since we don’t care about that, just mark it as 0.
old_size = 0

cdef void* new_ptr
if new_size == 0:
free(ptr)
memory_status.used -= old_size # add deallocated old size to available memory
return NULL
elif new_size == old_size:
return ptr

if memory_status.limit > 0 and new_size > old_size and memory_status.limit <= memory_status.used + new_size - old_size: # reached the limit
# print("REACHED LIMIT")
return NULL
# print(" realloc()...")
new_ptr = realloc(ptr, new_size)
# print(" ", memory_status.used, new_size - old_size, memory_status.used + new_size - old_size)
if new_ptr is not NULL:
memory_status.used += new_size - old_size
return new_ptr

cdef int _lua_panic(lua_State *L) nogil:
cdef const char* msg = lua.lua_tostring(L, -1)
if msg == NULL:
msg = "error object is not a string"
cdef char* message = "PANIC: unprotected error in call to Lua API (%s)\n"
fprintf(stderr, message, msg)
fflush(stderr)
return 0 # return to Lua to abort


################################################################################
# Python support in Lua

Expand Down
Loading