Page MenuHomePhabricator

Run Lua garbage collector prior to LuaSandbox OOM
Closed, ResolvedPublic

Description

LuaSandbox memory limits should limit the amount of memory actually in use.

@Surjection proposes running a full GC from the allocation hook as a last resort prior to reporting an out-of-memory error.

LuaSandbox implements its own wrapper for the memory allocator that is passed to Lua explicitly for keeping track of the memory limit (you've allocated 64 bytes after already allocating 50 MB - 32? no allocation for you). Right now, it's clear that LuaSandbox holds to an invariant that there is only lua_State per php_luasandbox_alloc. Because of that, you could just solve it like this:

  1. Add two int flags: enable_gc and in_gc to struct php_luasandbox_alloc. Initialize the first one to 1 when creating a new struct php_luasandbox_alloc and the latter to 0.
  2. Add a lua_State * field (let's call it state) to struct php_luasandbox_alloc. Initialize this in luasandbox_alloc_new_state and set it to NULL in luasandbox_alloc_delete_state.
    1. If it isn't already NULL in luasandbox_alloc_new_state, that means you have multiple Lua states per alloc. In this case, set enable_gc to 0.
  3. In luasandbox_update_memory_accounting: if we exceed the memory limit. check enable_gc and in_gc. if the first one is 1, the second one is 0 and state != NULL, do
    1. set alloc->in_gc to 1
    2. call lua_gc(alloc->state, LUA_GCCOLLECT, 0)
    3. if allocation now succeeds, set alloc->in_gc to 0. if it fails, do that anyway. the check whether we hit the memory limit should happen BEFORE in_gc is set to 0, else we will call GC in an infinite loop.

Calling lua_gc from a lua_Alloc is completely safe given you don't end up calling it again while it's already running (hence the in_gc). GC shouldn't allocate any memory but only free it, so the GC should never fail (but even if it does, all that happens is that the GC fails and then we can return NULL from lua_Alloc safely).

Note that it's only safe to run the GC from the allocation hook if the reference tree is in a well-defined state at that time, i.e. contains no dangling pointers or the like. After reviewing the code for a while, I'm about 80% convinced. It would be safer if we zero-filled newly-allocated memory.

An example of the kind of thing I'm looking for:

void luaS_resize (lua_State *L, int newsize) {
  ...
  if (G(L)->gcstate == GCSsweepstring)
    return;  /* cannot resize during GC traverse */

The converse is also true — you can't GC during a resize of the global string table. But it doesn't allocate memory during the critical section.

We don't allow users to catch a memory allocation failure with pcall(), although that would be possible in base Lua. So there is a motive for upstream to ensure that the global state is consistent when allocation is done.

LUA_API lua_State *lua_newstate (lua_Alloc f, void *ud) {
  ...
  if (luaD_rawrunprotected(L, f_luaopen, NULL) != 0) {
    /* memory allocation error: free partial state */
    close_state(L);
    L = NULL;
  }

This is a concerning form of protection, and it indicates that it's unsafe for us to do a GC during lua_newstate(). This needs special handling since the custom allocator is active at this point.

There are three other calls to luaD_rawrunprotected(), which all look fine.

The libraries should be fine. The lexer looks fine. A problem would look like:

  1. Write garbage to global state or a GC object
  2. Allocate memory
  3. Fix the garbage

Maybe we have that in lua_newstate(). It's hard to be sure since an actual crash would depend on one of about 50 properties not being initialised in the lua_State. Zero-filling would make it much harder to turn such an issue into code execution.

Event Timeline

As a proof of concept, I tried

diff --git a/alloc.c b/alloc.c
index 8122396..f0a0093 100644
--- a/alloc.c
+++ b/alloc.c
@@ -69,8 +69,16 @@ static void *luasandbox_php_alloc(void *ud, void *ptr, size_t osize, size_t nsiz
        void * nptr;
        obj->in_php ++;
        if (!luasandbox_update_memory_accounting(&obj->alloc, osize, nsize)) {
-               obj->in_php --;
-               return NULL;
+               size_t before = obj->alloc.memory_usage;
+               lua_gc(obj->state, LUA_GCCOLLECT, 0);
+               size_t after = obj->alloc.memory_usage;
+               FILE * f = fopen("/tmp/emergency-gc", "a");
+               fprintf(f, "Emergency GC reduced memory usage from %zu to %zu\n", before, after);
+               fclose(f);
+               if (!luasandbox_update_memory_accounting(&obj->alloc, osize, nsize)) {
+                       obj->in_php --;
+                       return NULL;
+               }
        }
 
        if (nsize == 0) {

I used wiktionary:sol, but with most of the -lite templates replaced with their regular versions. I made a special test wiki and imported the required templates and modules. I reduced the Lua memory limit to 40 million bytes.

Emergency GC reduced memory usage from 39999992 to 19482214
Emergency GC reduced memory usage from 39999658 to 20181286
Emergency GC reduced memory usage from 39998557 to 20824444
Emergency GC reduced memory usage from 39998267 to 22138428
Emergency GC reduced memory usage from 39999156 to 19424262
Emergency GC reduced memory usage from 39998842 to 20084935
Emergency GC reduced memory usage from 39999619 to 20879959
Emergency GC reduced memory usage from 39998313 to 22116057

So I would say that was successful. It's confirmed that this page, failing in production with a 50MB limit, really needs <27MB.

CPU time benchmarks were initially misleading, because it's fast to return OOM errors. Comparing an unpatched and unlimited parse with a patched and limited one, the difference was small, increasing from 3.5 to 3.6s.

I'm seeing a crash in remarkupvals(), apparently due to corruption of the global upval list during a prior GC call.

Also a crash due to an invalid pointer passed to efree(). An array with garbage in it.

So I think it is not feasible to call the GC from the allocation hook.

But I think it's possible to tune the GC to achieve the effect we want.

By default, the GC starts when the total memory usage exceeds the estimated live memory usage by a factor of 2. So in production, after the estimate passes 25MB, there will be no further GC execution until an OOM is reached. So it makes sense that we are seeing errors in production for a page that needs ~27MB of live memory.

The number of steps required to complete a collection increases as the number of live objects increases. Late in the execution of the current test case, it takes 6552 steps to complete a collection. Total memory usage increases from 28549 KB to 33801 KB during the propagate stage. Sweeping reduces usage to 19062 KB. Another collection starts when total memory usage reaches 35526 KB, and then the work finishes before the GC, with total memory usage being 39061 KB at termination.

The default step multiplier is 200, "twice the speed of memory allocation". I think we could increase that to 2000.

In the allocation hook, we can set the pause size so that collection will always start before an OOM occurs. That scaling can continue until the pause size is zero, since the step multiplier throttles GC activity, preventing excessive CPU usage.

Change 968011 had a related patch set uploaded (by Tim Starling; author: Tim Starling):

[mediawiki/php/luasandbox@master] Run the GC more aggressively, especially as usage approaches the limit

https://gerrit.wikimedia.org/r/968011

Change 968011 merged by jenkins-bot:

[mediawiki/php/luasandbox@master] Run the GC more aggressively, especially as usage approaches the limit

https://gerrit.wikimedia.org/r/968011

tstarling claimed this task.

Anything left to do here?

No. I was just waiting for T353414 but that was closed three weeks ago.