Scopes, services and Layers
This page covers resource safety (scopes, finalizers, acquireRelease, resource-stop cleanup) and dependency injection (service tags, Layers, Runtimes). Use scopes for anything that must be undone: spawned entities, loaded models, focus, event handlers, background loops. Use tags and layers to give handlers their dependencies (a bank, a database, a framework adapter) without globals, and to swap them in tests.
Examples print values with a --> result comment showing what they print.
Finalizers on one effect
These run a finalizer after an effect however it ends. Finalizers run uninterruptibly. Every callback must return an effect (Fx.unit when there is nothing to do); returning nil is a defect.
If a finalizer fails: when the effect had succeeded, the whole effect fails with the finalizer's cause; when the effect had failed, the cause becomes Sequential(original, finalizer cause), which the expected-error handlers do not catch (it contains a defect or a second failure).
eff:ensuring(finalizer)
eff:ensuring(finalizer: Effect<any, any>) -> Effect<A, E>
- Parameters:
finalizer, an effect run aftereffon success, failure and interruption. - Returns: an effect with the result of
eff.
local frozen = true
local cutscene = Fx.sleep(5000):ensuring(Fx.sync(function() frozen = false end))
Fx.runFork(cutscene:timeoutOption(1000), { onExit = function() print('player frozen:', frozen) end })
--> player frozen: false
eff:onExit(fin)
eff:onExit(fin: fun(exit: Exit<A>): Effect<any, any>) -> Effect<A, E>
- Parameters:
fin, called with the Exit ofeff; must return an effect. - Returns: an effect with the result of
eff.
local function logged(name, eff)
return eff:onExit(function(exit)
print(name, exit._tag)
return Fx.unit
end)
end
Fx.runSync(logged('payday', Fx.succeed(500)))
Fx.runSyncExit(logged('rob bank', Fx.fail('police nearby')))
--> payday Success
--> rob bank Failure
eff:onError(f)
eff:onError(f: fun(cause: Cause): Effect<any, any>) -> Effect<A, E>
- Parameters:
f, called with the cause wheneffdoes not succeed (failure, defect or interruption); must return an effect. - Notes: it observes, it does not recover. The effect still fails.
local eff = Fx.fail('vehicle not found'):onError(function(cause)
print('refunding, reason:', Fx.Cause.squash(cause))
return Fx.unit
end)
print(Fx.runSyncExit(eff)._tag)
--> refunding, reason: vehicle not found
--> Failure
eff:onInterrupt(f)
eff:onInterrupt(f: fun(): Effect<any, any>) -> Effect<A, E>
- Parameters:
f, called with no arguments wheneffends with a cause that contains an interruption; must return an effect.
local search = Fx.never:onInterrupt(function()
return Fx.sync(function() print('search cancelled, removing blips') end)
end)
Fx.runFork(search:timeoutOption(2000))
--> search cancelled, removing blips
Fx.acquireUseRelease(acquire, use, release)
Fx.acquireUseRelease(acquire: Effect<A, E1>, use: fun(a: A): Effect<B, E2>, release: fun(a: A, exit: Exit<B>): Effect<any, any>) -> Effect<B, E1 | E2>
- Parameters:
acquire, gets the resource (runs uninterruptibly).use, uses it; must return an effect (interruptible).release, frees it with the exit ofuse; must return an effect (runs uninterruptibly). - Returns: the result of
use. - Fails with: the errors of
acquire(then nothing is released) or ofuse. - Notes: no scope needed. Use it when the resource lives only for one block of code; use
Fx.acquireReleasewhen its lifetime is a scope.
local function withLockedVehicle(plate, use)
return Fx.acquireUseRelease(
Fx.sync(function() print('lock', plate) return plate end),
use,
function(p, exit) return Fx.sync(function() print('unlock', p, exit._tag) end) end)
end
Fx.runSyncExit(withLockedVehicle('ABC123', function(p) return Fx.fail('no fuel') end))
--> lock ABC123
--> unlock ABC123 Failure
Scope
A scope collects finalizers and runs them, last added first, when it closes. Fx.Scope is both the scope module and the service tag of the current scope: Fx.yield(Fx.Scope) returns the scope your code runs in. Every open scope also closes when the resource stops.
Fx.scoped(eff)
Fx.scoped(eff: Effect<A, E>) -> Effect<A, E>
- Returns:
eff, run with a fresh scope as itsScopeservice. The scope closes wheneffends, witheff's exit, and its finalizers run beforeFx.scopedreturns. - Notes: the usual way to give a block of code its own lifetime.
local function useVehicle(plate)
return Fx.acquireRelease(
Fx.sync(function() print('spawn', plate) return plate end),
function(p) return Fx.sync(function() print('delete', p) end) end)
end
local testDrive = Fx.scoped(Fx.gen(function()
local car = Fx.yield(useVehicle('TEST01'))
print('driving', car)
end))
Fx.runSync(testDrive)
--> spawn TEST01
--> driving TEST01
--> delete TEST01
Fx.acquireRelease(acquire, release)
Fx.acquireRelease(acquire: Effect<A, E>, release: fun(a: A, exit: Exit): Effect<any, any>) -> Effect<A, E> -- requires Scope
- Parameters:
acquire, gets the resource.release, called with the resource and the scope's closing exit; must return an effect. - Returns: an effect that acquires the resource, registers
releaseon the current scope, and succeeds with the resource. - Fails with: the errors of
acquire(then nothing is registered). A defectService not found: Scopewithout a scope. - Notes: acquire and registration run uninterruptibly, so a resource is never acquired without its release. Resources in one scope are released in reverse order of acquisition.
local log = {}
local function res(name)
return Fx.acquireRelease(Fx.sync(function() log[#log + 1] = '+' .. name return name end),
function(n, exit) return Fx.sync(function() log[#log + 1] = '-' .. n .. ':' .. exit._tag end) end)
end
Fx.runSyncExit(Fx.scoped(Fx.gen(function()
Fx.yield(res('model'))
Fx.yield(res('vehicle'))
Fx.yield(Fx.fail('driver left'))
end)))
print(table.concat(log, ' ')) --> +model +vehicle -vehicle:Failure -model:Failure
Fx.addFinalizer(fin)
Fx.addFinalizer(fin: fun(exit: Exit): Effect<any, any>) -> Effect<nil, never> -- requires Scope
- Parameters:
fin, called with the scope's closing exit; must return an effect. - Fails with: a defect
Service not found: Scopewithout a scope.
local eff = Fx.scoped(Fx.gen(function()
Fx.yield(Fx.addFinalizer(function(exit) return Fx.sync(function() print('hud hidden', exit._tag) end) end))
print('hud shown')
end))
Fx.runSync(eff)
--> hud shown
--> hud hidden Success
Fx.Scope.make() and Fx.Scope.unsafeMake()
Fx.Scope.make() -> Effect<ScopeObject, never>
Fx.Scope.unsafeMake() -> ScopeObject
- Returns: a new open scope. You close it yourself (or resource stop does).
- Notes: a scope object has fields
id,closed(boolean) and, once closed,exit.
local s = Fx.Scope.unsafeMake()
print(s.closed) --> false
scope:addFinalizer(fin)
scope:addFinalizer(fin: fun(exit: Exit): Effect<any, any>) -> Effect<nil, never>
scope:unsafeAddFinalizer(fin: fun(exit: Exit): Effect<any, any>) -> fun()
- Returns:
unsafeAddFinalizerreturns a function that removes the finalizer again. - Notes: adding a finalizer to a scope that is already closed runs it right away, on a new fiber, with the scope's exit.
local s = Fx.Scope.unsafeMake()
local remove = s:unsafeAddFinalizer(function() return Fx.sync(function() print('never printed') end) end)
remove()
Fx.runSync(s:addFinalizer(function() return Fx.sync(function() print('closed') end) end))
Fx.runSync(s:close())
--> closed
scope:close(exit?)
scope:close(exit?: Exit) -> Effect<nil, never>
- Parameters:
exit, passed to every finalizer (defaultFx.Exit.unit). - Notes: runs the finalizers in reverse order, uninterruptibly. A failing finalizer is reported (
error in scope finalizer) and does not stop the others. Closing a closed scope does nothing.
local s = Fx.Scope.unsafeMake()
Fx.runSync(s:addFinalizer(function() return Fx.sync(function() print('first added, runs last') end) end))
Fx.runSync(s:addFinalizer(function() return Fx.sync(function() print('last added, runs first') end) end))
Fx.runSync(s:close())
print(s.closed)
--> last added, runs first
--> first added, runs last
--> true
scope:closeSync(exit?)
scope:closeSync(exit?: Exit) -> nil
- Notes: plain function. Closes the scope right now without waiting. A finalizer that needs to wait (
Fx.sleep, an RPC, a database call) cannot finish; it is reported (error in scope finalizer on stop) and skipped. Resource stop closes scopes this way.
local s = Fx.Scope.unsafeMake()
Fx.runSync(s:addFinalizer(function() return Fx.sync(function() print('entity deleted') end) end))
s:closeSync()
--> entity deleted
scope:fork()
scope:fork() -> ScopeObject
- Returns: a child scope that closes when the parent closes (with the parent's exit), or earlier on its own.
local session = Fx.Scope.unsafeMake()
local mission = session:fork()
Fx.runSync(mission:addFinalizer(function() return Fx.sync(function() print('mission cleaned up') end) end))
Fx.runSync(session:close())
print(mission.closed)
--> mission cleaned up
--> true
scope:extend(eff)
scope:extend(eff: Effect<A, E>) -> Effect<A, E>
- Returns:
effwith this scope as itsScopeservice. Resources it acquires live until this scope closes, not untileffends.
local playerScope = Fx.Scope.unsafeMake()
local giveRadio = Fx.acquireRelease(Fx.succeed('radio'), function(item)
return Fx.sync(function() print('removed', item) end)
end)
print(Fx.runSync(playerScope:extend(giveRadio))) -- still held
Fx.runSync(playerScope:close())
--> radio
--> removed radio
scope:unsafeAttachFiber(fiber)
scope:unsafeAttachFiber(fiber: Fiber) -> nil
- Notes: interrupt
fiberwhen the scope closes, unless it has already ended.eff:forkScoped()does this for you.
local s = Fx.Scope.unsafeMake()
local f = Fx.runFork(Fx.never, { onExit = function(exit) print('interrupted', Fx.Cause.isInterruptedOnly(exit.cause)) end })
s:unsafeAttachFiber(f)
Fx.runFork(Fx.sleep(10):zipRight(s:close()))
--> interrupted true
eff:forkScoped() is documented on the Concurrency page.
Resource lifecycle
When this resource stops (onResourceStop for its own name), Rivo FX:
- interrupts every root fiber (
Fx.runFork,runAwait,forkDaemon,forkScopedfibers; children follow their parents), with fiber id0in the cause; - closes every open scope synchronously with
closeSync, newest first, with an interruption exit. This includesFx.resourceScope, player scopes and Runtime scopes.
There is no later tick after a resource stops, so finalizers that need to wait are reported and skipped. Keep stop-time cleanup synchronous (DeleteEntity, SetModelAsNoLongerNeeded, state resets).
Fx.onStop(fin)
Fx.onStop(fin: fun() | Effect<any, any>) -> nil
- Parameters:
fin, a plain function (called with no arguments) or an effect. - Notes: plain function, not an effect. Adds
fintoFx.resourceScope. SeveralonStopcallbacks run last registered first. They run withcloseSync, sofinmust not wait.
local blips = { 101, 102 }
Fx.onStop(function()
for _, b in ipairs(blips) do print('RemoveBlip', b) end
end)
Fx.resourceScope:closeSync() -- what resource stop does
--> RemoveBlip 101
--> RemoveBlip 102
Fx.resourceScope
Fx.resourceScope: ScopeObject
- Notes: a scope that closes when this resource stops. Use
Fx.resourceScope:extend(eff)for resources that live as long as the resource. For per-player lifetimes seeFx.player.scope(src)in FiveM modules.
local loadModel = Fx.acquireRelease(Fx.succeed('prop_atm_01'), function(m)
return Fx.sync(function() print('SetModelAsNoLongerNeeded', m) end)
end)
Fx.runSync(Fx.resourceScope:extend(loadModel))
Fx.resourceScope:closeSync()
--> SetModelAsNoLongerNeeded prop_atm_01
Services
A service is any value (usually a table of functions returning effects) looked up by a tag. Code asks for the service by yielding its tag; whoever runs the code provides it.
Fx.Tag(key)
Fx.Tag(key: string) -> Tag -- Tag is an Effect<Service, never>
- Parameters:
key, a name used in error messages. - Returns: a tag. The tag is itself an effect that succeeds with the service:
Fx.yield(Bank),Bank:flatMap(...). - Fails with: a defect
Service not found: <key>. Provide it with :provide(layer) or :provideService(tag, impl).when the service is not provided. - Notes: tags are matched by identity, not by key. Two
Fx.Tag('Bank')calls make two different tags; define each tag once and share it (for example in a shared file).
local Bank = Fx.Tag('Bank')
local function payday(src)
return Fx.gen(function()
local bank = Fx.yield(Bank)
return Fx.yield(bank.deposit(src, 500))
end)
end
local fakeBank = { deposit = function(src, n) return Fx.succeed(n + 1000) end }
print(Fx.runSync(payday(1):provideService(Bank, fakeBank))) --> 1500
local exit = Fx.runSyncExit(payday(1))
print((Fx.Cause.defects(exit.cause)[1]:match('^[^.]*'))) --> Service not found: Bank
Fx.isTag(value)
Fx.isTag(value: any) -> boolean
print(Fx.isTag(Fx.Tag('Garage')), Fx.isTag(Fx.unit)) --> true false
Fx.serviceOption(tag)
Fx.serviceOption(tag: Tag) -> Effect<Service | nil, never>
- Returns: the service when provided, else
nil. Never fails. - Notes: for optional dependencies, such as a metrics sink.
local Discord = Fx.Tag('Discord')
local notify = Fx.serviceOption(Discord):flatMap(function(d)
if not d then return Fx.succeed('no webhook configured') end
return d.post('robbery started')
end)
print(Fx.runSync(notify)) --> no webhook configured
eff:provideService(tag, service)
eff:provideService(tag: Tag, service: any) -> Effect<A, E>
- Returns:
effwithserviceavailable undertag. Inner provides win over outer ones.
local Config = Fx.Tag('Config')
local eff = Config:map(function(c) return c.maxVehicles end):provideService(Config, { maxVehicles = 3 })
print(Fx.runSync(eff)) --> 3
eff:provideContext(ctx)
eff:provideContext(ctx: table<Tag, any>) -> Effect<A, E>
- Parameters:
ctx, a table from tag to service, merged over the services already provided. - Notes:
eff:provide(ctx)with a plain table does the same.Fx.Context.empty(),Fx.Context.add(ctx, tag, service),Fx.Context.merge(a, b)andFx.Context.get(ctx, tag)build and read these tables without mutating their inputs.
local Bank, Garage = Fx.Tag('Bank'), Fx.Tag('Garage')
local ctx = Fx.Context.add(Fx.Context.add(Fx.Context.empty(), Bank, 'maze'), Garage, 'pillbox')
local eff = Fx.all({ Bank, Garage }):provideContext(ctx)
local r = Fx.runSync(eff)
print(r[1], r[2]) --> maze pillbox
Layer
A layer is a recipe that builds services, possibly using other services and acquiring resources. Within one build, each layer object is built at most once even if it appears several times in the graph. Separate builds (two eff:provide(layer) calls, two runtimes) build it again.
Fx.Layer.succeed(tag, service)
Fx.Layer.succeed(tag: Tag, service: any) -> Layer
- Returns: a layer that provides
serviceas is.
local Bank = Fx.Tag('Bank')
local BankLive = Fx.Layer.succeed(Bank, { name = 'Fleeca' })
print(Fx.runSync(Bank:provide(BankLive)).name) --> Fleeca
Fx.Layer.sync(tag, fn)
Fx.Layer.sync(tag: Tag, fn: fun(): any) -> Layer
- Returns: a layer that calls
fnwhen it builds and provides the result.
local Shops = Fx.Tag('Shops')
local ShopsLive = Fx.Layer.sync(Shops, function() return { count = 12 } end)
print(Fx.runSync(Shops:provide(ShopsLive)).count) --> 12
Fx.Layer.effect(tag, eff)
Fx.Layer.effect(tag: Tag, eff: Effect<any, E>) -> Layer
- Parameters:
eff, builds the service. It may yield other services (provided withlayer:provide(dep)) and may fail. - Fails with: a failing
efffails whatever the layer is provided to. - Notes: for resources use
Layer.scoped.Fx.acquireReleaseinsideLayer.effectuses the caller'sScopeif there is one, otherwise it dies withService not found: Scope.
local Config, Bank = Fx.Tag('Config'), Fx.Tag('Bank')
local ConfigLive = Fx.Layer.succeed(Config, { startingCash = 500 })
local BankLive = Fx.Layer.effect(Bank, Fx.gen(function()
local cfg = Fx.yield(Config)
return { open = function() return Fx.succeed(cfg.startingCash) end }
end)):provide(ConfigLive)
print(Fx.runSync(Fx.gen(function() return Fx.yield(Fx.yield(Bank).open()) end):provide(BankLive))) --> 500
Fx.Layer.scoped(tag, eff)
Fx.Layer.scoped(tag: Tag, eff: Effect<any, E>) -> Layer
- Parameters:
eff, builds the service and may acquire resources withFx.acquireRelease,Fx.addFinalizerorforkScoped. - Notes: those resources are released when the layer's scope closes: when the effect given to
eff:provide(layer)ends, or when a Runtime is disposed or the resource stops.
local Db = Fx.Tag('Db')
local DbLive = Fx.Layer.scoped(Db, Fx.acquireRelease(
Fx.sync(function() print('pool opened') return { query = function() return 42 end } end),
function() return Fx.sync(function() print('pool closed') end) end))
local n = Fx.runSync(Db:map(function(db) return db.query() end):provide(DbLive))
print(n)
--> pool opened
--> pool closed
--> 42
Fx.Layer.effectDiscard(eff)
Fx.Layer.effectDiscard(eff: Effect<any, E>) -> Layer
- Returns: a layer that runs
eff(in the layer's scope) when it builds and provides no service. - Notes: for startup work tied to the layer's lifetime, such as a background loop started with
forkScoped.
local Startup = Fx.Layer.effectDiscard(Fx.sync(function() print('shop blips created') end))
Fx.runSync(Fx.succeed('ok'):provide(Startup))
--> shop blips created
Fx.Layer.empty
Fx.Layer.empty: Layer
- Notes: provides nothing. Useful as a starting value when building layer lists.
print(Fx.runSync(Fx.succeed(1):provide(Fx.Layer.empty))) --> 1
Fx.Layer.merge(...)
Fx.Layer.merge(...: Layer) -> Layer
- Returns: a layer that provides every service of every layer given.
- Notes: the layers build one after another in argument order and do not see each other's services; use
layer:provide(dep)for dependencies. When two layers provide the same tag, the later one wins.
local Bank, Garage = Fx.Tag('Bank'), Fx.Tag('Garage')
local App = Fx.Layer.merge(Fx.Layer.succeed(Bank, 'fleeca'), Fx.Layer.succeed(Garage, 'legion'))
local r = Fx.runSync(Fx.all({ Bank, Garage }):provide(App))
print(r[1], r[2]) --> fleeca legion
layer:provide(dep)
layer:provide(dep: Layer) -> Layer
- Returns: a layer that builds
depfirst and feeds its services intolayer. The result provides onlylayer's services.
local Config, Bank = Fx.Tag('Config'), Fx.Tag('Bank')
local ConfigLive = Fx.Layer.succeed(Config, { tax = 0.1 })
local BankLive = Fx.Layer.effect(Bank, Config:map(function(c) return { tax = c.tax } end)):provide(ConfigLive)
local exit = Fx.runSyncExit(Config:provide(BankLive))
print(Fx.runSync(Bank:provide(BankLive)).tax, exit._tag) --> 0.1 Failure
layer:provideMerge(dep)
layer:provideMerge(dep: Layer) -> Layer
- Returns: like
provide, but the result provides the services of bothdepandlayer.
local Config, Bank = Fx.Tag('Config'), Fx.Tag('Bank')
local ConfigLive = Fx.Layer.succeed(Config, { tax = 0.1 })
local BankLive = Fx.Layer.effect(Bank, Config:map(function(c) return { tax = c.tax } end))
local Both = BankLive:provideMerge(ConfigLive)
local r = Fx.runSync(Fx.all({ Config, Bank }):provide(Both))
print(r[1].tax, r[2].tax) --> 0.1 0.1
layer:build(scope)
layer:build(scope: ScopeObject) -> Effect<table<Tag, any>, E>
- Returns: an effect that builds the layer into
scopeand succeeds with its context table (tag to service). Resources are released whenscopecloses. - Notes: low level;
eff:provideandFx.Runtimecall it for you.
local Bank = Fx.Tag('Bank')
local ctx = Fx.runSync(Fx.Layer.succeed(Bank, 'maze'):build(Fx.Scope.unsafeMake()))
print(ctx[Bank]) --> maze
eff:provide(layerOrContext)
eff:provide(what: Layer | table<Tag, any>) -> Effect<A, E>
- Parameters:
what, a layer, or a plain context table (then it isprovideContext). - Returns:
effwith the services provided. - Fails with: the layer's build errors, plus the errors of
eff. - Notes: with a layer, the layer is built each time the effect runs, in a fresh scope that closes when
effends. For services shared across many runs (event handlers, RPC handlers), build once with a Runtime.
local Counter = Fx.Tag('Counter')
local builds = 0
local CounterLive = Fx.Layer.sync(Counter, function() builds = builds + 1 return {} end)
local eff = Counter:provide(CounterLive)
Fx.runSync(eff)
Fx.runSync(eff)
print(builds) --> 2
Runtime
A runtime builds a layer once and shares its services with every effect it runs. Make one per resource for your services and register it with Fx.setRuntime so Fx.net and Fx.rpc handlers get them.
Fx.Runtime.make(layer)
Fx.Runtime.make(layer: Layer) -> Runtime
- Returns: a runtime. The layer is not built yet: it builds on the first run, on its own root fiber; runs that arrive meanwhile wait for it.
- Notes: the runtime's scope is an ordinary open scope, so scoped resources in the layer are released on resource stop. If the build fails, the failure is logged once and every run of the runtime fails with that cause; the runtime does not rebuild.
local Counter = Fx.Tag('Counter')
local builds = 0
local rt = Fx.Runtime.make(Fx.Layer.sync(Counter, function() builds = builds + 1 return { n = 0 } end))
local bump = Counter:map(function(c) c.n = c.n + 1 return c.n end)
rt:runFork(bump)
rt:runFork(bump)
rt:runFork(bump, { onExit = function(exit) print(exit.value, builds) end })
--> 3 1
rt:runFork(eff, opts?)
rt:runFork(eff: Effect<A, E>, opts?: { onExit?: fun(exit: Exit<A>) }) -> Fiber
- Returns: a root fiber running
effwith the runtime's services, as Fx.runFork.
local Bank = Fx.Tag('Bank')
local rt = Fx.Runtime.make(Fx.Layer.succeed(Bank, { name = 'Pacific Standard' }))
rt:runFork(Bank, { onExit = function(exit) print(exit.value.name) end }) --> Pacific Standard
rt:runCallback(eff, cb)
rt:runCallback(eff: Effect<A, E>, cb: fun(exit: Exit<A>)) -> Fiber
local Bank = Fx.Tag('Bank')
local rt = Fx.Runtime.make(Fx.Layer.succeed(Bank, 'fleeca'))
rt:runCallback(Bank, function(exit) print(exit._tag, exit.value) end) --> Success fleeca
rt:runAwait(eff)
rt:runAwait(eff: Effect<A, E>) -> A
- Returns: the value, blocking the calling Citizen thread. Raises like Fx.runAwait.
local Bank = Fx.Tag('Bank')
local rt = Fx.Runtime.make(Fx.Layer.succeed(Bank, { balance = function(src) return Fx.succeed(src * 100) end }))
CreateThread(function()
local bal = rt:runAwait(Fx.gen(function() return Fx.yield(Fx.yield(Bank).balance(3)) end))
print('balance', bal)
end)
--> balance 300
rt:context() and rt:wrap(eff)
rt:context() -> Effect<table<Tag, any>, E>
rt:wrap(eff: Effect<A, E>) -> Effect<A, E>
- Returns:
contextsucceeds with the built context table (building it on first use).wrapreturnseffwith the runtime's services provided, to run it some other way.
local Bank = Fx.Tag('Bank')
local rt = Fx.Runtime.make(Fx.Layer.succeed(Bank, 'maze'))
Fx.runFork(rt:wrap(Bank), { onExit = function(exit) print(exit.value) end }) --> maze
rt:dispose()
rt:dispose() -> Effect<nil, never>
- Returns: an effect that closes the runtime's scope, releasing everything its layer acquired.
- Notes: disposing does not reset the runtime. Later runs still receive the services that were built before, even though their resources have been released. Make a new runtime instead of reusing a disposed one.
local Db = Fx.Tag('Db')
local rt = Fx.Runtime.make(Fx.Layer.scoped(Db, Fx.acquireRelease(Fx.succeed('pool'), function()
return Fx.sync(function() print('pool closed') end)
end)))
rt:runFork(Db, { onExit = function(exit)
print('got', exit.value)
Fx.runFork(rt:dispose())
end })
--> got pool
--> pool closed