Skip to content

v1.5.0 — Cleanup that always finishes

Back to all updates
v1.5.0August 20, 2026

Twill’s own Bag closes newest first and runs to the end even when a step fails.

Rename the type. Everything else keeps its name.

function MyService.OnPlayerReady(player, data, trove: Scope.Trove)
function MyService.OnPlayerReady(player, data, bag: Scope.Bag)

Add, Connect, Extend, Remove, Clean and Destroy are unchanged. Code that uses only those needs no edit.

BeforeAfter
bag:AttachToInstance(part)bag:AttachTo(part)
bag:Pop(x)bag:Release(x)
bag:Construct(Class, ...)bag:Add(Class.new(...))
bag:WrapClean()function() bag:Destroy() end
bag:BindToRenderStep(n, p, f)bag:Bind(n, p, f)
bag:AddPromise(p)bag:Add(p, "cancel")
Twill.Trove.new()Twill.Bag.new()

Cleanup now runs newest first. Code that relied on the old forward order relied on behaviour that was never documented.

The bundled library closed its entries in a loop that caught nothing, guarded by a flag raised on entry. A cleanup that raised escaped the loop and left the flag raised. Every remaining entry stayed held forever, and the bag cleaned nothing again. Player bags are shared between systems, so one service’s failing Destroy stopped every other service’s teardown.

Twill.Bag closes every entry inside its own pcall:

bag:Add(function() print("one") end)
bag:Add(function() error("something went wrong") end)
bag:Add(function() print("three") end)
bag:Clean()

This prints three, reports the failure through Log, prints one, and leaves the bag empty and usable.

The last entry added is the first closed, so nothing is torn down after the thing it depends on:

bag:Add(connectionPool)
bag:Add(connectionPool:Open()) -- closed first, while the pool still exists

Entries are taken out of the list before they run. This keeps closing linear while still allowing a cleanup to add to the bag it is closing. Measured in Studio:

EntriesRescanningBag
1000.012 ms0.015 ms
10000.284 ms0.105 ms
50009.92 ms0.544 ms

A name keeps one thing at a time. Adding under a name closes whatever held that name before:

local function retarget(model: Model)
bag:Add(makeTween(model), "Cancel", "aim")
end

Anything with a closing method can be held by naming the method, including promises, so there is no promise dependency:

bag:Add(promise, "cancel")
bag:Add(tween, "Cancel")
bag:Add(sound, "Stop")

Something with no way to close is refused at the Add call.

  • A one-shot listener whose signal fired during the connect no longer leaves a dead connection in the bag.

  • A thread that cancels itself is deferred instead of failing silently.

  • Attaching to an instance that is already gone closes the bag at once instead of raising.

  • Count, IsEmpty and IsDestroyed let a test assert that nothing leaked:

    Scope.Close(player)
    assert(bagFor(player):Count() == 0, "something outlived the player")
  • Twill.Trove and the bundled Trove package are removed.

Every change in this release is listed in the changelog.