diff --git a/src/ExternalDebug.luau b/src/ExternalDebug.luau new file mode 100644 index 000000000..f1aebc69a --- /dev/null +++ b/src/ExternalDebug.luau @@ -0,0 +1,70 @@ +--!strict +--!nolint LocalUnused +--!nolint LocalShadow +local task = nil -- Disable usage of Roblox's task scheduler + +--[[ + Abstraction layer between Fusion internals and external debuggers, allowing + for deep introspection using function hooks. + + Unlike `External`, attaching a debugger is optional, and all debugger + functions are expected to be infallible and non-blocking. +]] + +local Package = script.Parent +local Types = require(Package.Types) + +local currentProvider: Types.ExternalDebugger? = nil +local lastUpdateStep = 0 + +local Debugger = {} + +--[[ + Swaps to a new debugger. + Returns the old debugger, so it can be used again later. +]] +function Debugger.setDebugger( + newProvider: Types.ExternalDebugger? +): Types.ExternalDebugger? + local oldProvider = currentProvider + if oldProvider ~= nil then + oldProvider.stopDebugging() + end + currentProvider = newProvider + if newProvider ~= nil then + newProvider.startDebugging() + end + return oldProvider +end + +--[[ + Called at the earliest moment after a scope is created or removed from the + scope pool, but not before the scope has finished being prepared by the + library, so that debuggers can register its existence and track changes + to the scope over time. +]] +function Debugger.trackScope( + scope: Types.Scope +): () + if currentProvider == nil then + return + end + currentProvider.trackScope(scope) +end + +--[[ + Called at the final moment before a scope is poisoned or added to the scope + pool, after all cleanup tasks have completed, so that debuggers can erase + the scope from internal trackers. Note that, due to scope pooling and user + code, never assume that this correlates with garbage collection events. +]] +function Debugger.untrackScope( + scope: Types.Scope +): () + if currentProvider == nil then + return + end + currentProvider.trackScope(scope) +end + +return Debugger \ No newline at end of file diff --git a/src/Memory/deriveScope.luau b/src/Memory/deriveScope.luau index 67f2f9dbb..46a381110 100644 --- a/src/Memory/deriveScope.luau +++ b/src/Memory/deriveScope.luau @@ -6,28 +6,18 @@ local task = nil -- Disable usage of Roblox's task scheduler --[[ Creates an empty scope with the same metatables as the original scope. Used for preserving access to constructors when creating inner scopes. + + This is the public version of the function, which implements external + debugging hooks. ]] local Package = script.Parent.Parent -local Types = require(Package.Types) -local merge = require(Package.Utility.merge) -local scopePool = require(Package.Memory.scopePool) +local ExternalDebug = require(Package.ExternalDebug) +local deriveScopeImpl = require(Package.Memory.deriveScopeImpl) --- This return type is technically a lie, but it's required for useful type --- checking behaviour. -local function deriveScope( - existing: Types.Scope, - methods: {[unknown]: unknown}?, - ...: {[unknown]: unknown} -): any - local metatable = getmetatable(existing) - if methods ~= nil then - metatable = table.clone(metatable) - metatable.__index = merge("first", table.clone(metatable.__index), methods, ...) - end - return setmetatable( - scopePool.reuseAny() :: any or {}, - metatable - ) +local function deriveScope(...) + local scope = deriveScopeImpl(...) + ExternalDebug.trackScope(scope) + return scope end -return (deriveScope :: any) :: Types.DeriveScopeConstructor \ No newline at end of file +return deriveScope \ No newline at end of file diff --git a/src/Memory/deriveScopeImpl.luau b/src/Memory/deriveScopeImpl.luau new file mode 100644 index 000000000..4705b4124 --- /dev/null +++ b/src/Memory/deriveScopeImpl.luau @@ -0,0 +1,37 @@ +--!strict +--!nolint LocalUnused +--!nolint LocalShadow +local task = nil -- Disable usage of Roblox's task scheduler + +--[[ + Creates an empty scope with the same metatables as the original scope. Used + for preserving access to constructors when creating inner scopes. + + This is the internal version of the function, which does not implement + external debugging hooks. +]] +local Package = script.Parent.Parent +local Types = require(Package.Types) +local merge = require(Package.Utility.merge) +local scopePool = require(Package.Memory.scopePool) + +-- This return type is technically a lie, but it's required for useful type +-- checking behaviour. +local function deriveScopeImpl( + existing: Types.Scope, + methods: {[unknown]: unknown}?, + ...: {[unknown]: unknown} +): any + local metatable = getmetatable(existing) + if methods ~= nil then + metatable = table.clone(metatable) + metatable.__index = merge("first", table.clone(metatable.__index), methods, ...) + end + local scope = setmetatable( + scopePool.reuseAny() :: any or {}, + metatable + ) + return scope +end + +return (deriveScopeImpl :: any) :: Types.DeriveScopeConstructor \ No newline at end of file diff --git a/src/Memory/innerScope.luau b/src/Memory/innerScope.luau index 0ef047ae8..62ffefcbf 100644 --- a/src/Memory/innerScope.luau +++ b/src/Memory/innerScope.luau @@ -9,13 +9,14 @@ local task = nil -- Disable usage of Roblox's task scheduler ]] local Package = script.Parent.Parent local Types = require(Package.Types) -local deriveScope = require(Package.Memory.deriveScope) +local ExternalDebug = require(Package.ExternalDebug) +local deriveScopeImpl = require(Package.Memory.deriveScopeImpl) local function innerScope( existing: Types.Scope, ...: {[unknown]: unknown} ): any - local new = deriveScope(existing, ...) + local new = deriveScopeImpl(existing, ...) table.insert(existing, new) table.insert( new, @@ -26,6 +27,7 @@ local function innerScope( end end ) + ExternalDebug.trackScope(new) return new end diff --git a/src/Memory/scopePool.luau b/src/Memory/scopePool.luau index 928559cb2..7a0da26be 100644 --- a/src/Memory/scopePool.luau +++ b/src/Memory/scopePool.luau @@ -5,6 +5,7 @@ local task = nil -- Disable usage of Roblox's task scheduler local Package = script.Parent.Parent local Types = require(Package.Types) +local ExternalDebug = require(Package.ExternalDebug) local MAX_POOL_SIZE = 16 -- TODO: need to test what an ideal number for this is @@ -16,6 +17,7 @@ return { scope: Types.Scope ): Types.Scope? if next(scope) == nil then + ExternalDebug.untrackScope(scope) if poolSize < MAX_POOL_SIZE then poolSize += 1 pool[poolSize] = scope @@ -28,6 +30,7 @@ return { clearAndGive = function( scope: Types.Scope ) + ExternalDebug.untrackScope(scope) if poolSize < MAX_POOL_SIZE then table.clear(scope) poolSize += 1 diff --git a/src/Memory/scoped.luau b/src/Memory/scoped.luau index d74afbd1d..7e547f91f 100644 --- a/src/Memory/scoped.luau +++ b/src/Memory/scoped.luau @@ -9,16 +9,19 @@ local task = nil -- Disable usage of Roblox's task scheduler local Package = script.Parent.Parent local Types = require(Package.Types) +local ExternalDebug = require(Package.ExternalDebug) local merge = require(Package.Utility.merge) local scopePool = require(Package.Memory.scopePool) local function scoped( ...: {[unknown]: unknown} ): any - return setmetatable( + local scope = setmetatable( scopePool.reuseAny() :: any or {}, {__index = merge("none", {}, ...)} - ) + ) :: any + ExternalDebug.trackScope(scope) + return scope end return (scoped :: any) :: Types.ScopedConstructor \ No newline at end of file diff --git a/src/Types.luau b/src/Types.luau index 418913548..1e2356aab 100644 --- a/src/Types.luau +++ b/src/Types.luau @@ -296,4 +296,16 @@ export type ExternalProvider = { stopScheduler: () -> () } +export type ExternalDebugger = { + startDebugging: () -> (), + stopDebugging: () -> (), + + trackScope: ( + scope: Scope + ) -> (), + untrackScope: ( + scope: Scope + ) -> () +} + return nil