In the docs, Maid::GivePromise() says it 'Gives a promise to the maid for clean'. Although it returns a promise (rather than a taskid), I assumed this method it would behave identically to Maid::GiveTask() - where you give a resource to 1 maid, that maid takes full ownership of it, and handles its destruction.
Instead, I realised that Maid::GivePromise() actually wraps the given promise in a new promise, returns that wrapped promise, and will cancel the wrapped promise on destruction. This means that destroying a maid will cancel the wrapped promise, breaking the chain - but the original promise may still be left pending.
This is a problem beacuse the original promise could be creating or keeping some kind of resource around beyond its usefulness.
local function promiseTimeout()
local promise = Promise.new()
local maid = Maid.new()
promise:Finally(function()
maid:Destroy()
end)
maid:GiveTask(cancellableDelay(1, function()
print("Promise alive - trying to resolve.")
promise:Resolve()
end))
return promise
end
local maid = Maid.new()
-- This example prints 'Promise alive - trying to resolve.' in the console.
-- Because we assumed the given promise is owned (and destroyed) by the maid, we just leaked a cancellableDelay.
-- What if this delay was longer? Could we accumulate unused delays?
maid:GivePromise(promiseTimeout()):Then(function()
print("Timeout!")
end)
maid:Destroy()
As a counter-example, if we used Maid::GiveTask() in the format originally described...
-- ...
-- Nothing ever prints to the console.
-- This is safe. Nothing leaked.
-- :GiveTask() takes ownership of the resource.
local promise = promiseTimeout()
maid:GiveTask(promise)
promise:Then(function()
print("Timeout!")
end)
maid:Destroy()
-- ...
The typical use case of a promise is wrapping something intangible. I.e. creating a BindableEvent, HttpService::RequestAsync, promiseBoundClass. It's easy to miss that these resources are being leaked.
Here's a real-world example where failing to clean up the given promise is quite bad.
-- If we give this promise to a maid with something like 'maid:GivePromise(promiseCharacterTouchTrigger())'...
-- This part will leak until a player touches it.
local function promiseCharacterTouchTrigger()
local promise = Promise.new()
local maid = Maid.new()
promise:Finally(function()
maid:Destroy()
end)
local part = Instance.new("Part")
part.Anchored = true
part.Archivable = false
part.CanCollide = false
part.Transparency = 0.5
part.CanTouch = true
part.Size = Vector3.one * 8
part.Parent = workspace
maid:GiveTask(part.Touched:Connect(function()
promise:Resolve()
end))
maid:GiveTask(part)
return promise
end
I could see this being useful if you wanted to reuse a promise from multiple listeners. For example, classes often have a method like...
function Class:PromiseDataStore()
return self._dataStorePromise
end
Giving that (potentially unresolved) returned promise to a maid, then rejecting, would be really bad! You'd cause a very difficult-to-track-down race condition. I imagine that the current behaviour exists as a band-aid over a situation like this. I don't think this is behaviour people expect, as both Janitor and Trove take ownership and destroy the original promise, rather than just break the chain like Maid does. It's probably too late to change the behaviour of Maid::GivePromise(), but the docs should definitely be updated to reflect the caveat I've described.
In the docs,
Maid::GivePromise()
says it 'Gives a promise to the maid for clean'. Although it returns a promise (rather than a taskid), I assumed this method it would behave identically toMaid::GiveTask()
- where you give a resource to 1 maid, that maid takes full ownership of it, and handles its destruction.Instead, I realised that
Maid::GivePromise()
actually wraps the given promise in a new promise, returns that wrapped promise, and will cancel the wrapped promise on destruction. This means that destroying a maid will cancel the wrapped promise, breaking the chain - but the original promise may still be left pending.This is a problem beacuse the original promise could be creating or keeping some kind of resource around beyond its usefulness.
As a counter-example, if we used
Maid::GiveTask()
in the format originally described...The typical use case of a promise is wrapping something intangible. I.e. creating a
BindableEvent
,HttpService::RequestAsync
,promiseBoundClass
. It's easy to miss that these resources are being leaked.Here's a real-world example where failing to clean up the given promise is quite bad.
I could see this being useful if you wanted to reuse a promise from multiple listeners. For example, classes often have a method like...
Giving that (potentially unresolved) returned promise to a maid, then rejecting, would be really bad! You'd cause a very difficult-to-track-down race condition. I imagine that the current behaviour exists as a band-aid over a situation like this. I don't think this is behaviour people expect, as both Janitor and Trove take ownership and destroy the original promise, rather than just break the chain like Maid does. It's probably too late to change the behaviour of
Maid::GivePromise()
, but the docs should definitely be updated to reflect the caveat I've described.