Enable debug.loadmodule or similar for better environment separation

Problem Statement:

There is currently an ergonomics issue with creating multiple script environments in Roblox, especially when it comes to Actors (which many assume always get their own VM).
Often times when implementing other-player prediction on the client, or when implementing server authoritative behaviors on the server, you want to simulate the reference frame of a player.
Idiomatic Luau is written with Singleton Services which are acquired through a require system like so:

local ModuleLoader = require(game.ReplicatedFirst.ModuleLoader) or shared.ModuleLoader
local Character = ModuleLoader:require("Character")
local Camera = ModuleLoader:require("Camera")
-- ...

Because module results are cached, this poses a problem when trying to simulate the reference frame of multiple players, who each need their own Camera and Character singletons, simultaneously.

Workaround:

The current workaround blessed by Roblox is to create a fresh ModuleLoader for your script environment, and for it to clone each ModuleScript before requiring. This allows it to get a fresh Module instance each time, and for the Module instance to be able to grab a reference to the ModuleLoader:

function ModuleLoader:require(name)
	if not ModuleLoader._cache[name] then
		local moduleTemp = self:findModule(name)
		local moduleCopy = moduleTemp:Clone()
		moduleCopy.Parent = script
		ModuleLoader._cache[name] = require(moduleCopy)
	end

	return ModuleLoader._cache[name]
end

and inside the Modules:

local ModuleLoader = require(script.Parent)
local Character = ModuleLoader:require("Character")
-- ...

Is this really the right way to do it? Up until recently, this has memory leaked several KB every time a module was cloned and required.

Proposal:

In the past, the alternative to avoid this memory leak has been to wrap every ModuleScript in a function which serves as a ModuleBuilder.

Inside the modules:

return function(ModuleLoader)
	local Character = ModuleLoader:require("Character")
	-- ...
	return Module
end

However, internally, Roblox already has a method to do this, it is just not exposed. It would be much more ergonomic to separate/sandbox our different environments if we had access to a global loadmodule function, which works similarly to loadstring:

local moduleBuilder = loadmodule(moduleInstance)
local Module = moduleBuilder(ModuleLoader, etc)

and in the module:

local ModuleLoader, etc = ...
-- ...
return Module

Potential Drawbacks:

There is a fear that people will use setfenv to set the environment of a fresh Module, which de-optimizes everything, and will make client and server performance suffer.

A potential workaround for this is to change the way loadmodule is called so that there is never a chance to call setfenv:

loadmodule(ModuleInstance, args...)
6 Likes

I’ve never seen anyone ever do this, so I wouldn’t called this ‘idiomatic’. I also don’t see the worth in having multiple singletons, since that defeats the entire purpose of a singleton.

This seems like an XY problem. If you want to transform singletons into class-like structures, just represent them with different tables. debug.loadmodule isn’t meant to do that.

There’s a pretty real problem at the core of this feature request, I find. I’ll speak as someone who doesn’t use any frameworks at all ('cept Fusion, of course :cowboy_hat_face:)

Imagine you want to simulate a number of players in parallel. You would have one Actor instance per player. An implementation detail of this system is that VMs are sometimes shared between actors, including sharing the require() cache.

If you use a single-script architecture with Luau as the source of truth (commonly done for complex systems that are hard to represent with data model state), then you’ll be forced to store some global state somewhere in your Luau scripts. In particular, a pattern I use often is to store global state in dedicated modules, which can then be required around the place by other modules.

These two features do not mesh together well. If an actor requires a module outside of itself that stores global state, it will fragment across VM lines - in this way, the implementation detail about VMs is leaking out and causing state to replicate incorrectly.

The feature request here is to allow state to fragment across Actor boundaries reliably, by removing the forced require() cache. This can’t really be done automatically because Luau doesn’t guarantee immutability or freedom from side effects, so I think making a more advanced require() alternative without the forced caching is a fair way of achieving that.

2 Likes

So, the point is really not about transforming singletons into class-like structures.
This is already easy to achieve.

The problem is that, especially when mapping players 1:1 with actors, the ergonomics of environment separation get pretty bad.

The proposal to just convert singletons into classes, while technically possible, results in every class needing to be dependency injected with an environment table as an argument to the .new function, which is a lot of boilerplate that cascades to thousands of places throughout the code, making code reuse harder between projects which require environment separation and projects which do not;

-- requires cannot go up here anymore
local Camera = {}
Camera.__index = Camera

function Camera.new(env) -- a new injection point
    local self = setmetatable({}, Camera)

    self._env = env
    self._dependantService1 = self._env:require("dependantService1")
    self._Spring = self._env:require("Spring")
    -- ...

    self._spring = self._Spring.new(self._env, etc...) -- every instantiation of an object requires injection
    return self
end

-- ...

return Camera

This code is much less ergonomic and portable between different projects, and requires significant onboarding.

Any code that I want to use that other people have developed for Roblox, I must now re-engineer, deeply into the code (not just at the top of the script), to include dependency injection.


While the re-engineering requirement, if loadmodule is enabled, would look more like this:

local require = ...
-- the rest of the camera module is unchanged
local DependantService1 = require("DependantService1")
local Spring = require("Spring")

local Camera = {}
Camera.__index = Camera

function Camera.new()
    local self = setmetatable({}, Camera)

    self._spring = Spring.new(etc...)
    return self
end

-- ...

return Camera
1 Like