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

Agnostic scheduler #277

Merged
merged 5 commits into from
Sep 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
21 changes: 21 additions & 0 deletions docs/api-reference/errors/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -891,6 +891,27 @@ end, nil)

-----

<div class="fusiondoc-error-api-section" markdown>
<p class="fusiondoc-api-pills">
<span class="fusiondoc-api-pill-since">since v0.3</span>
</p>

## noTaskScheduler

```
Fusion is not connected to an external task scheduler.
```

This message shows when Fusion attempts to schedule something for execution
without first setting a task scheduler for the library to use.

For users of Fusion on Roblox, this generally shouldn't occur as Fusion should
automatically be bound to Roblox's task scheduler. However, when using Fusion
in other environments, the fix is to provide Fusion with all the task scheduler
callbacks necessary to schedule tasks for execution in the future.

-----

<div class="fusiondoc-error-api-section" markdown>
<p class="fusiondoc-api-pills">
<span class="fusiondoc-api-pill-since">since v0.2</span>
Expand Down
11 changes: 8 additions & 3 deletions src/Animation/SpringScheduler.lua
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

local Package = script.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)
local packType = require(Package.Animation.packType)
local springCoefficients = require(Package.Animation.springCoefficients)
local updateAll = require(Package.State.updateAll)
Expand All @@ -17,7 +18,7 @@ local SpringScheduler = {}

local EPSILON = 0.0001
local activeSprings: Set<Spring> = {}
local lastUpdateTime = os.clock()
local lastUpdateTime = External.lastUpdateStep()

function SpringScheduler.add(spring: Spring)
-- we don't necessarily want to use the most accurate time - here we snap to
Expand All @@ -38,9 +39,11 @@ function SpringScheduler.remove(spring: Spring)
activeSprings[spring] = nil
end

function SpringScheduler.updateAllSprings()
local function updateAllSprings(
now: number
)
local springsToSleep: Set<Spring> = {}
lastUpdateTime = os.clock()
lastUpdateTime = now

for spring in pairs(activeSprings) do
local posPos, posVel, velPos, velVel = springCoefficients(lastUpdateTime - spring._lastSchedule, spring._currentDamping, spring._currentSpeed)
Expand Down Expand Up @@ -82,4 +85,6 @@ function SpringScheduler.updateAllSprings()
end
end

External.bindToUpdateStep(updateAllSprings)

return SpringScheduler
3 changes: 2 additions & 1 deletion src/Animation/Tween.lua
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

local Package = script.Parent.Parent
local PubTypes = require(Package.PubTypes)
local External = require(Package.External)
local Types = require(Package.Types)
local TweenScheduler = require(Package.Animation.TweenScheduler)
local logError = require(Package.Logging.logError)
Expand Down Expand Up @@ -43,7 +44,7 @@ function class:update(): boolean
self._prevValue = self._currentValue
self._nextValue = goalValue

self._currentTweenStartTime = os.clock()
self._currentTweenStartTime = External.lastUpdateStep()
self._currentTweenInfo = tweenInfo

local tweenDuration = tweenInfo.DelayTime + tweenInfo.Time
Expand Down
8 changes: 6 additions & 2 deletions src/Animation/TweenScheduler.lua
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

local Package = script.Parent.Parent
local Types = require(Package.Types)
local External = require(Package.External)
local lerpType = require(Package.Animation.lerpType)
local getTweenRatio = require(Package.Animation.getTweenRatio)
local updateAll = require(Package.State.updateAll)
Expand Down Expand Up @@ -38,8 +39,9 @@ end
--[[
Updates all Tween objects.
]]
function TweenScheduler.updateAllTweens()
local now = os.clock()
local function updateAllTweens(
now: number
)
-- FIXME: Typed Luau doesn't understand this loop yet
for tween: Tween in pairs(allTweens :: any) do
local currentTime = now - tween._currentTweenStartTime
Expand All @@ -63,4 +65,6 @@ function TweenScheduler.updateAllTweens()
end
end

External.bindToUpdateStep(updateAllTweens)

return TweenScheduler
114 changes: 114 additions & 0 deletions src/External.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
--!strict
--[[
Abstraction layer between Fusion internals and external environments,
allowing for flexible integration with schedulers and test mocks.
]]

local Package = script.Parent
local logError = require(Package.Logging.logError)

local External = {}

export type Scheduler = {
doTaskImmediate: (
resume: () -> ()
) -> (),
doTaskDeferred: (
resume: () -> ()
) -> (),
startScheduler: () -> (),
stopScheduler: () -> ()
}

local updateStepCallbacks = {}
local currentScheduler: Scheduler? = nil
local lastUpdateStep = 0

--[[
Sets the external scheduler that Fusion will use for queuing async tasks.
Returns the previous scheduler so it can be reset later.
]]
function External.setExternalScheduler(
newScheduler: Scheduler?
): Scheduler?
local oldScheduler = currentScheduler
if oldScheduler ~= nil then
oldScheduler.stopScheduler()
end
currentScheduler = newScheduler
if newScheduler ~= nil then
newScheduler.startScheduler()
end
return oldScheduler
end

--[[
Sends an immediate task to the external scheduler. Throws if none is set.
]]
function External.doTaskImmediate(
resume: () -> ()
)
if currentScheduler == nil then
logError("noTaskScheduler")
else
currentScheduler.doTaskImmediate(resume)
end
end

--[[
Sends a deferred task to the external scheduler. Throws if none is set.
]]
function External.doTaskDeferred(
resume: () -> ()
)
if currentScheduler == nil then
logError("noTaskScheduler")
else
currentScheduler.doTaskDeferred(resume)
end
end

--[[
Registers a callback to the update step of the external scheduler.
Returns a function that can be used to disconnect later.

Callbacks are given the current number of seconds since an arbitrary epoch.

TODO: This epoch may change between schedulers. We could investigate ways
of allowing schedulers to co-operate to keep the epoch the same, so that
monotonicity can be better preserved.
]]
function External.bindToUpdateStep(
callback: (
now: number
) -> ()
): () -> ()
local uniqueIdentifier = {}
updateStepCallbacks[uniqueIdentifier] = callback
return function()
updateStepCallbacks[uniqueIdentifier] = nil
end
end

--[[
Steps time-dependent systems with the current number of seconds since an
arbitrary epoch. This should be called as early as possible in the external
scheduler's update cycle.
]]
function External.performUpdateStep(
now: number
)
lastUpdateStep = now
for _, callback in updateStepCallbacks do
callback(now)
end
end

--[[
Returns the timestamp of the last update step.
]]
function External.lastUpdateStep()
return lastUpdateStep
end

return External
3 changes: 2 additions & 1 deletion src/Instances/Attribute.lua
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

local Package = script.Parent.Parent
local PubTypes = require(Package.PubTypes)
local External = require(Package.External)
local logError = require(Package.Logging.logError)
local xtypeof = require(Package.Utility.xtypeof)
local Observer = require(Package.State.Observer)
Expand All @@ -22,7 +23,7 @@ local function bindAttribute(instance: Instance, attribute: string, value: any,
local function update()
if not didDefer then
didDefer = true
task.defer(function()
External.doTaskDeferred(function()
didDefer = false
setAttribute(instance, attribute, peek(value))
end)
Expand Down
3 changes: 2 additions & 1 deletion src/Instances/Children.lua
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

local Package = script.Parent.Parent
local PubTypes = require(Package.PubTypes)
local External = require(Package.External)
local logWarn = require(Package.Logging.logWarn)
local Observer = require(Package.State.Observer)
local peek = require(Package.State.peek)
Expand Down Expand Up @@ -131,7 +132,7 @@ function Children:apply(propValue: any, applyTo: Instance, cleanupTasks: {PubTyp
queueUpdate = function()
if not updateQueued then
updateQueued = true
task.defer(updateChildren)
External.doTaskDeferred(updateChildren)
end
end

Expand Down
3 changes: 2 additions & 1 deletion src/Instances/applyInstanceProps.lua
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

local Package = script.Parent.Parent
local PubTypes = require(Package.PubTypes)
local External = require(Package.External)
local cleanup = require(Package.Utility.cleanup)
local xtypeof = require(Package.Utility.xtypeof)
local logError = require(Package.Logging.logError)
Expand Down Expand Up @@ -56,7 +57,7 @@ local function bindProperty(instance: Instance, property: string, value: PubType
local function updateLater()
if not willUpdate then
willUpdate = true
task.defer(function()
External.doTaskDeferred(function()
willUpdate = false
setProperty(instance, property, peek(value))
end)
Expand Down
4 changes: 2 additions & 2 deletions src/Logging/logErrorNonFatal.lua
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ local function logErrorNonFatal(messageID: string, errObj: Types.Error?, ...)
errorString = string.format("[Fusion] " .. formatString .. "\n(ID: " .. messageID .. ")\n---- Stack trace ----\n" .. errObj.trace, ...)
end

task.spawn(function(...)
coroutine.wrap(function()
error(errorString:gsub("\n", "\n "), 0)
end, ...)
end)()
end

return logErrorNonFatal
1 change: 1 addition & 0 deletions src/Logging/messages.lua
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ return {
mistypedSpringDamping = "The damping ratio for a spring must be a number. (got a %s)",
mistypedSpringSpeed = "The speed of a spring must be a number. (got a %s)",
mistypedTweenInfo = "The tween info of a tween must be a TweenInfo. (got a %s)",
noTaskScheduler = "Fusion is not connected to an external task scheduler.",
springTypeMismatch = "The type '%s' doesn't match the spring's type '%s'.",
stateGetWasRemoved = "`StateObject:get()` has been replaced by `use()` and `peek()` - see discussion #217 on GitHub.",
strictReadError = "'%s' is not a valid member of '%s'.",
Expand Down
77 changes: 77 additions & 0 deletions src/RobloxExternal.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
--!strict
--[[
Roblox implementation for Fusion's abstract scheduler layer.
]]

local RunService = game:GetService("RunService")
local HttpService = game:GetService("HttpService")

local Package = script.Parent
local External = require(Package.External)

local RobloxExternal = {}

--[[
Sends an immediate task to the external scheduler. Throws if none is set.
]]
function RobloxExternal.doTaskImmediate(
resume: () -> ()
)
task.spawn(resume)
end

--[[
Sends a deferred task to the external scheduler. Throws if none is set.
]]
function RobloxExternal.doTaskDeferred(
resume: () -> ()
)
task.defer(resume)
end

--[[
Sends an update step to Fusion using the Roblox clock time.
]]
local function performUpdateStep()
External.performUpdateStep(os.clock())
end

--[[
Binds Fusion's update step to RunService step events.
]]
local stopSchedulerFunc = nil
function RobloxExternal.startScheduler()
if stopSchedulerFunc ~= nil then
return
end
if RunService:IsClient() then
-- In cases where multiple Fusion modules are running simultaneously,
-- this prevents collisions.
local id = "FusionUpdateStep_" .. HttpService:GenerateGUID()
RunService:BindToRenderStep(
id,
Enum.RenderPriority.First.Value,
performUpdateStep
)
stopSchedulerFunc = function()
RunService:UnbindFromRenderStep(id)
end
else
local connection = RunService.Heartbeat:Connect(performUpdateStep)
stopSchedulerFunc = function()
connection:Disconnect()
end
end
end

--[[
Unbinds Fusion's update step from RunService step events.
]]
function RobloxExternal.stopScheduler()
if stopSchedulerFunc ~= nil then
stopSchedulerFunc()
stopSchedulerFunc = nil
end
end

return RobloxExternal
Loading