Memory leak: Modulescripts never Garbage Collected after Destroy()'d and all references are removed

Reproduction Steps

I’ve created a test place to demonstrate this memory leak: (2) module GC test - Roblox

Basically, there’s a serverscript in ServerScriptService that is replicating a module over to the client constantly, a localscript in StarterPlayerScripts that is destroying all of the modules it receives.

Join the game and watch the Clientside Script memory (ignore server side, since I’m only deleting the modules on the client) increase at a constant rate as the new modulescripts flow in, but never go down even though the modulescripts are being destroyed and have no references to them that would prevent garbage collection.


^ Screenshot shows script memory rising

I added a button you can click to stop the server from sending over more modulescripts, so you can see that the deleted modulescripts are never cleared from Script memory clientside.


^ Observe the flat line on Script memory after no new modulescripts were replicated to the client, and all previously replicated modulescripts have been Destroy()'d and have no references to them (so they should be garbage collected, but aren’t)

[Sidenote: In my localscript code for deleting the modulescripts, I commented out a line where I require() the modulescript. If you uncomment that line, you can see that not only will Script memory rise without falling, but LuaHeap will also rise without falling. Initially, I encountered this issue through a memory leak in my game where LuaHeap kept rising, I narrowed it down to my system of loading in modulescripts packed with map data (the tables of Vector3’s you see inside the test modulescript I’m using). That’s how I came across this issue of modulescripts not being garbage collected, and I found that you can better observe this behavior through Script memory, since LuaHeap has more variability. But LuaHeap is also important to this issue.]

Expected Behavior

Memory should be cleared for ModuleScripts that are destroyed and have no references to them (they should be garbage collected)

Actual Behavior

Engine memory leak – Script memory (and LuaHeap if module is required) will rise but never fall when modulescripts are sent to the client and later deleted.

Issue Area: Engine
Issue Type: Performance
Impact: Very High
Frequency: Constantly

12 Likes

Hello and thank you for the report. I’m seeing this issue in a similar setup.

4 Likes

We have an issue right now where ModuleScript instances that are only destroyed on the client without a corresponding Destroy call on the server will remain in memory.
We don’t have a timeline for a fix of this issue right now.

As a work-around, try destroying the scripts on the server side when data is no longer required or use RemoteEvent to send data to clients instead.

5 Likes

Any news when the issue will be patched?

I can’t promise when this will be fixed, but we have people who started working on it recently.
It seems that the issue affects more than just scripts, but they hold much more memory than other instance types.

5 Likes

Do you have any ETA when it will be patched?

Just checked, no progress on this yet. I’ll raise the question about this internally one more time.

3 Likes

Still no news about that , sadly

1 Like

Yes, at this point it’s fair to say that there’s no interest for fixing this issue.

I would suggest using a work-around I mentioned earlier.

3 Likes

thanks for the transparency! I’m guessing this is a byproduct of how the engine works and fixing it is complicated

2 Likes

Hello again, got some unfortunate news on this.

After looking more into this issue, we have decided that we are not going to make a fix for this.

There is a problem with client-destroyed server-owned instances and references to them.
It is possible for the server to replicate a future object that references the ModuleScript that was earlier destroyed on the client and client can then use this new reference they got to clone the module and require it again.
For that to continue to work, ModuleScript has to be kept alive.
Similar situations can happen with other instance types as well.

I still recommend the workaround of destroying the instances on the server, so that the replication can take care of it on both sides.

7 Likes

This is leaning towards a feature request, but would it be possible to add a dedicated client-send method specifically for this use case of replicating an object to a specific client? The current setup to do this specifically is extremely cumbersome:

  1. Clone target object server-side, place target object in player’s PlayerGui (other local folders like Backpack should also work for this)
  2. Fire remote to client with reference to object in PlayerGui, delay for a frame and then clone that referenced object client-side (decouples the new cloned instance from server replication).
  3. Fire a remote back to the server to let it know that the client has received the object, server can now delete the original clone in PlayerGui.

The reason why this capability is important is for cases where you want to create custom streaming behavior. The alternatives are 1. just place all your map segments into replicatedstorage and let everything get streamed to all the clients (bad for client memory), or 2. Clone object into PlayerGui server-side and simply do not delete that copy; do not create a separate clone on the client; when the client is done with that instance and wants it deleted, then it tells the server to delete the object in PlayerGui (results in extra instances server-side, bad for server memory)

It’s been a while since I investigated this issue and maybe there’s a better workaround, but having a simple way to selectively replicate to a specified client would be extremely useful for creating custom streaming systems for cases (of which there are plenty) where Roblox’s built-in streaming doesn’t work as desired

5 Likes

Sorry to bring this thread back up, but I’ve once again ran into a situation where I wish to store large quantities of data.

Upon further investigation, I found that modulescripts not only do not get collected when replicated to the client, but also do not get GC’d on the server.

I’ve modified my repro place to now only clone modulescripts on the server. As you can see, the Script memory climbs at a consistent rate while the cloning is happening, and as soon as you click the button to halt the cloning, the script memory graph flattens out (but never drops, even though the modules are destroyed).


^Script memory on the server, notice how it never goes down even after modulescripts are destroyed.

The above test only clones the modules, and when the “halt” signal is fired, the creation is stopped, and all existing modulescripts are destroyed. If you go into the Script in ServerScriptService and uncomment lines 5 and 6, you can observe that when require()ing these modules, LuaHeap does not go down either (even though no references exist to the table returned by the modules.


MY SOLUTION
If we can have some way to store buffer data in place files, such as a BufferValue instance, then instead of storing my data structures in modules, I can store them directly as buffers. Currently, there is no elegant way to store large developer-created data structures, examples including BVH and other types of spatial hierarchy trees (that one may pre-generate in a plugin or command script, store somehow in the place file, and access during gameplay).

2 Likes

The example place is copy-locked, so we cannot look at what exactly the script does there.

But as an overall update on this issue, since the overall report, we fixed some of the internal references, but have found out that a few references remain and so it is true that ModuleScripts are never garbage collected.

We planned to make Destroy clean up the memory if it was no longer referenced in update 596 Release Notes for 596 - #2 by bluebxrrybot
Unfortunately, we have found that many developers rely on re-requiring ModuleScripts that they have destroyed (both in the discussion on the topic and in internal analysis), so we cannot fix the issue without breaking those games.

We recommend calling table.clear(require(module)) before module:Destroy() or to avoid cloning same ModuleScripts altogether.

1 Like

Do you have any suggestions on alternative forms of data storage like in the use case described? ModuleScripts have been my go-to, but because modulescripts (and the heap data) cannot be cleared, it results in redundant memory usage.

I’m anticipating to use about 900MB of memory in the form of buffers (generated ahead of time via plugin), would you recommend splitting it up into the 200k character chunks in StringValues? I assume StringValues can actually be garbage collected unlike ModuleScripts. Kinda hoping for direct buffer storage…

1 Like

I’ve encountered a very similar problem and have a fix for it. The reason why the modules don’t get GC’d (i think) is because modules are essentially loaded in as globals in memory, which is what allows you to seamlessly use them across several scripts (and is also why destroying the module doesn’t fully remove it from memory). In this case, the reference that is keeping them in memory is the function the module returns. In that function’s environment, the script global variable holds a reference to the modulescript. You can remove this reference by setting the function’s environment to an empty table using setfenv and then destroying the module (example: setfenv(function, {})). It’s worth noting that the module doesn’t have to solely return a function, the function can be within a table that’s returned and that’ll have the same effect.

2 Likes

ModuleScript memory should now be collected as long as Destroy is called.
At least in the example you shared, there’s no longer a need to call table.clear.

1 Like