Concurrency
This page covers fibers (fork, join, interrupt), running many effects at once with Fx.forEach, Fx.all and races, uninterruptible regions, and the shared-state tools: Ref, Deferred, Semaphore, Queue and PubSub. Use it when work has to run in the background, in parallel, or has to coordinate between fibers. Fibers are cooperative coroutines in one Lua state; "concurrent" means interleaved while fibers wait, never two pieces of Lua running at the same instant. See How fibers run.
Examples print values with a --> result comment showing what they print.
Fibers
A fiber runs one effect. It has a numeric id and, once finished, an exit field holding its Exit (nil while running). Fibers start on the next pass of the runner, not at the moment you fork them.
How fibers are tied together:
| Started with | Parent | Interrupted when | Unhandled failure logged |
|---|---|---|---|
eff:fork() |
the forking fiber | the parent finishes (for any reason), or resource stop through the parent | no; join it or handle errors inside it |
eff:forkDaemon() |
none (root fiber) | resource stop, or you interrupt it | yes |
eff:forkScoped() |
none (root fiber) | the current Scope closes, or resource stop |
yes |
Fx.runFork(eff) |
none (root fiber) | resource stop, or you interrupt it | yes, unless onExit is given |
eff:fork()
eff:fork() -> Effect<Fiber, never>
- Returns: an effect that starts
effon a child fiber and succeeds with the fiber right away. - Notes: the child sees the parent's services. It is interrupted when the parent finishes, so a fork that must outlive the current effect needs
forkDaemonorforkScoped. Failures of a child are not logged; observe them withjoinorawait.
local eff = Fx.gen(function()
local fiber = Fx.yield(Fx.succeed('vehicle spawned'):delay(200):fork())
print('forked, waiting')
return Fx.yield(fiber:join())
end)
Fx.runFork(eff, { onExit = function(exit) print(exit.value) end })
--> forked, waiting
--> vehicle spawned
eff:forkDaemon()
eff:forkDaemon() -> Effect<Fiber, never>
- Returns: an effect that starts
effon a root fiber that outlives the effect that forked it. - Notes: it inherits the forking fiber's services. It is interrupted on resource stop. An unhandled failure is logged, like
Fx.runForkwithoutonExit.
local done = false
local handler = Fx.sync(function() done = true end):delay(1000):forkDaemon():asVoid()
Fx.runFork(handler, { onExit = function() print('handler returned', done) end })
Fx.runFork(Fx.sleep(2000):zipRight(Fx.sync(function() print('daemon finished', done) end)))
--> handler returned false
--> daemon finished true
eff:forkScoped()
eff:forkScoped() -> Effect<Fiber, never> -- requires Scope
- Returns: an effect that starts
effas a daemon fiber and interrupts it when the current Scope closes. - Fails with: a defect
Service not found: Scopewhen no scope is provided. Wrap the code inFx.scopedor use it insideLayer.scoped.
local heartbeat = Fx.sync(function() print('beat') end):delay(400):forever()
local session = Fx.scoped(Fx.gen(function()
Fx.yield(heartbeat:forkScoped())
Fx.yield(Fx.sleep(1000)) -- the session lasts one second
end))
Fx.runFork(session, { onExit = function() print('session closed') end })
--> beat
--> beat
--> session closed
fiber:join()
fiber:join() -> Effect<A, E>
- Returns: an effect that waits for the fiber and succeeds with its value.
- Fails with: the fiber's cause, as is. Joining an interrupted fiber fails with its interruption cause, which
catchAllCausecan handle (it is not the joiner's own interruption).
local eff = Fx.gen(function()
local f = Fx.yield(Fx.fail('engine stalled'):delay(10):fork())
return Fx.yield(f:join():catchAll(function(e) return Fx.succeed('caught: ' .. e) end))
end)
Fx.runFork(eff, { onExit = function(exit) print(exit.value) end }) --> caught: engine stalled
fiber:await()
fiber:await() -> Effect<Exit<A>, never>
- Returns: an effect that waits for the fiber and succeeds with its Exit. It never fails.
local eff = Fx.gen(function()
local f = Fx.yield(Fx.fail('no fuel'):fork())
local exit = Fx.yield(f:await())
return exit._tag
end)
Fx.runFork(eff, { onExit = function(exit) print(exit.value) end }) --> Failure
fiber:interrupt()
fiber:interrupt() -> Effect<Exit<A>, never>
- Returns: an effect that interrupts the fiber, waits until it has stopped (finalizers included) and succeeds with its exit.
- Notes: a fiber in an uninterruptible region finishes that region first. If the fiber already finished, it just returns the exit. The interruption cause carries the interrupting fiber's id.
local eff = Fx.gen(function()
local f = Fx.yield(Fx.never:ensuring(Fx.sync(function() print('blip removed') end)):fork())
Fx.yield(Fx.sleep(100))
local exit = Fx.yield(f:interrupt())
return Fx.Cause.isInterruptedOnly(exit.cause)
end)
Fx.runFork(eff, { onExit = function(exit) print(exit.value) end })
--> blip removed
--> true
fiber:interruptAsFork(byId?)
fiber:interruptAsFork(byId?: integer) -> nil
- Parameters:
byId, the id stored in the interruption cause. - Notes: plain function, not an effect. Requests interruption and returns at once without waiting. Calling it again, or on a finished fiber, does nothing. Use it from plain Lua code (an event handler, a command) to stop a fiber started with
Fx.runFork.
local loop = Fx.runFork(Fx.sync(function() end):delay(100):forever(), {
onExit = function(exit) print('stopped', Fx.Cause.isInterruptedOnly(exit.cause)) end,
})
Fx.runFork(Fx.sleep(250):zipRight(Fx.sync(function() loop:interruptAsFork() end)))
--> stopped true
fiber:addObserver(cb)
fiber:addObserver(cb: fun(exit: Exit<A>)) -> fun()
- Returns: a function that removes the observer.
- Notes: plain Lua callback, called once when the fiber ends. Errors raised in
cbat that point are reported, not raised. If the fiber has already ended,cbis called right away and its errors propagate to the caller.
local f = Fx.runFork(Fx.succeed(7):delay(50), { onExit = function() end })
f:addObserver(function(exit) print('done with', exit.value) end) --> done with 7
Fx.interruptAll(fibers)
Fx.interruptAll(fibers: Fiber[]) -> Effect<nil, never>
- Returns: an effect that interrupts every fiber in the list and waits until all have stopped.
local eff = Fx.gen(function()
local a = Fx.yield(Fx.never:fork())
local b = Fx.yield(Fx.never:fork())
Fx.yield(Fx.interruptAll({ a, b }))
return a.exit ~= nil and b.exit ~= nil
end)
Fx.runFork(eff, { onExit = function(exit) print(exit.value) end }) --> true
Running many effects
Fx.forEach(items, f, opts?)
Fx.forEach(items: T[], f: fun(item: T, i: integer): Effect<B, E>, opts?: { concurrency?: integer | 'unbounded', discard?: boolean }) -> Effect<B[], E>
- Parameters:
items, a sequence (#itemsis used).f, called with each item and its index; must return an effect.opts.concurrency, how many run at once: a number or'unbounded'(all at once). Default1(one after another).opts.discard, do not collect results. - Returns: an effect that succeeds with the results in input order, whatever order they finished in.
- Fails with: the first failure. In sequential mode the remaining items do not run. In concurrent mode the other running workers are interrupted, and the effect waits for them to stop before failing.
- Notes:
- With
concurrency > 1, the items run on child fibers (at mostconcurrencyworkers, each taking the next item when done), so interrupting the caller interrupts them all. - With
discard = true, results are not kept and the effect succeeds with an empty table. - A return value of
fthat is not an effect is a defect.
- With
local function refuel(plate) return Fx.succeed(plate .. ' full'):delay(100) end
local eff = Fx.forEach({ 'AAA111', 'BBB222', 'CCC333', 'DDD444' }, refuel, { concurrency = 2 })
Fx.runFork(eff, { onExit = function(exit) print(table.concat(exit.value, ', ')) end })
--> AAA111 full, BBB222 full, CCC333 full, DDD444 full
Fx.all(effects, opts?)
Fx.all(effects: Effect[] | table<string, Effect>, opts?: { concurrency?: integer | 'unbounded' }) -> Effect<table, E>
- Parameters:
effects, a list or a record of effects.opts.concurrency, as forforEach(default1). - Returns: an effect that succeeds with results of the same shape: a list in order, or a table with the same keys.
- Fails with: the first failure, as
forEach. - Notes: a table counts as a list when
#effects > 0or it is empty. Records run inpairsorder, which is not defined; useconcurrencyor a list when order matters.
local profile = Fx.all({
name = Fx.succeed('Ana'):delay(50),
cash = Fx.succeed(1200):delay(80),
job = Fx.succeed('mechanic'):delay(30),
}, { concurrency = 'unbounded' })
Fx.runFork(profile, { onExit = function(exit) print(exit.value.name, exit.value.cash, exit.value.job) end })
--> Ana 1200 mechanic
eff:race(that)
eff:race(that: Effect<A, E>) -> Effect<A, E>
- Returns: an effect that runs both on child fibers and succeeds with the first success. The loser is interrupted and awaited.
- Fails with: only when both fail: a
Parallelcause holding both causes (in the order they finished).
local fromCache = Fx.fail('cache miss')
local fromDb = Fx.succeed({ plate = 'ABC123' }):delay(120)
Fx.runFork(fromCache:race(fromDb), { onExit = function(exit) print(exit.value.plate) end }) --> ABC123
eff:raceFirst(that)
eff:raceFirst(that: Effect<A, E>) -> Effect<A, E>
- Returns: an effect whose result is that of whichever side finishes first, success or failure. The other side is interrupted.
local eff = Fx.fail('player dropped'):delay(10):raceFirst(Fx.succeed('trade done'):delay(50))
Fx.runFork(eff, { onExit = function(exit) print(Fx.Cause.failureOption(exit.cause)) end }) --> player dropped
Fx.raceAll(effects)
Fx.raceAll(effects: Effect<A, E>[]) -> Effect<A, E>
- Returns: the first success among all effects; the rest are interrupted.
- Fails with: a
Parallelcause of all failures when every effect fails.
local servers = {
Fx.fail('eu down'),
Fx.succeed('us-east'):delay(200),
Fx.succeed('us-west'):delay(90),
}
Fx.runFork(Fx.raceAll(servers), { onExit = function(exit) print(exit.value) end }) --> us-west
Interruption
Interruption is how fibers are stopped: fiber:interrupt(), a timeout, a race loser, a parent finishing, a scope closing, resource stop. An interrupted fiber stops at its next step (at once if it is waiting on Fx.sleep, Fx.async, a Queue or a Deferred), runs its finalizers, and ends with an Interrupt cause. Recovery handlers do not run during interruption (see Errors).
eff:uninterruptible()
eff:uninterruptible() -> Effect<A, E>
- Returns:
effrunning in an uninterruptible region. An interruption requested meanwhile waits; it lands as soon as the region ends, so code after the region does not run. - Notes: keep regions short. A
timeoutaround an uninterruptible effect waits for it before failing.Fx.acquireReleaseacquisitions and scope closing are already uninterruptible.
local saved = false
local save = Fx.sleep(300):zipRight(Fx.sync(function() saved = true end)):uninterruptible()
local eff = Fx.gen(function()
local f = Fx.yield(save:fork())
Fx.yield(Fx.sleep(10))
local exit = Fx.yield(f:interrupt()) -- waits for the save to finish
return exit._tag
end)
Fx.runFork(eff, { onExit = function(exit) print(exit.value, saved) end }) --> Failure true
eff:interruptible()
eff:interruptible() -> Effect<A, E>
- Returns:
effrunning interruptibly, even inside an uninterruptible region.
local eff = Fx.gen(function()
local f = Fx.yield(Fx.never:interruptible():uninterruptible():fork())
Fx.yield(Fx.sleep(10))
return Fx.yield(f:interrupt())._tag
end)
Fx.runFork(eff, { onExit = function(exit) print(exit.value) end }) --> Failure
Fx.uninterruptibleMask(f)
Fx.uninterruptibleMask(f: fun(restore: fun(eff: Effect): Effect): Effect<A, E>) -> Effect<A, E>
- Parameters:
f, builds the effect. It runs uninterruptibly;restore(eff)runseffwith the interruptibility from outside the mask. - Notes: the building block of
acquireUseRelease: acquire and release are protected, the use part stays interruptible.
local eff = Fx.uninterruptibleMask(function(restore)
return Fx.sync(function() print('lock taken') end)
:zipRight(restore(Fx.sleep(1000)))
:ensuring(Fx.sync(function() print('lock released') end))
end)
Fx.runFork(eff:timeoutOption(100), { onExit = function(exit) print('result', exit.value) end })
--> lock taken
--> lock released
--> result nil
Ref
A mutable cell shared by fibers. Every operation is an effect and runs without interleaving, so update and modify are atomic with respect to other fibers.
Fx.Ref.make(value) and Fx.Ref.unsafeMake(value)
Fx.Ref.make(value: A) -> Effect<Ref<A>, never>
Fx.Ref.unsafeMake(value: A) -> Ref<A>
- Returns: a new ref.
unsafeMakebuilds it immediately, outside any effect (for module-level state).
local onlineCount = Fx.Ref.unsafeMake(0)
print(Fx.runSync(onlineCount:get())) --> 0
ref:get(), ref:set(value)
ref:get() -> Effect<A, never>
ref:set(value: A) -> Effect<nil, never>
local eff = Fx.gen(function()
local weather = Fx.yield(Fx.Ref.make('CLEAR'))
Fx.yield(weather:set('RAIN'))
return Fx.yield(weather:get())
end)
print(Fx.runSync(eff)) --> RAIN
ref:update(f), ref:updateAndGet(f), ref:getAndSet(value)
ref:update(f: fun(old: A): A) -> Effect<nil, never>
ref:updateAndGet(f: fun(old: A): A) -> Effect<A, never>
ref:getAndSet(value: A) -> Effect<A, never> -- succeeds with the old value
local eff = Fx.gen(function()
local cash = Fx.yield(Fx.Ref.make(100))
Fx.yield(cash:update(function(n) return n + 50 end))
local now = Fx.yield(cash:updateAndGet(function(n) return n * 2 end))
local old = Fx.yield(cash:getAndSet(0))
return { now = now, old = old, after = Fx.yield(cash:get()) }
end)
local r = Fx.runSync(eff)
print(r.now, r.old, r.after) --> 300 300 0
ref:modify(f)
ref:modify(f: fun(old: A): (R, A)) -> Effect<R, never>
- Parameters:
freturns two values: the result, then the new value. - Returns: an effect that stores the new value and succeeds with the result.
local NotEnoughMoney = Fx.TaggedError('NotEnoughMoney')
local wallet = Fx.Ref.unsafeMake(80)
local function pay(amount)
return wallet:modify(function(have)
if have < amount then return false, have end
return true, have - amount
end):flatMap(function(paid)
if not paid then return NotEnoughMoney { need = amount }:fail() end
return Fx.unit
end)
end
print(Fx.runSyncExit(pay(50))._tag, Fx.runSyncExit(pay(50))._tag, Fx.runSync(wallet:get())) --> Success Failure 30
Deferred
A value set once and awaited by any number of fibers.
Fx.Deferred.make() and Fx.Deferred.unsafeMake()
Fx.Deferred.make() -> Effect<Deferred<A, E>, never>
Fx.Deferred.unsafeMake() -> Deferred<A, E>
local d = Fx.Deferred.unsafeMake()
print(d:isDone()) --> false
deferred:await()
deferred:await() -> Effect<A, E>
- Returns: an effect that waits until the deferred is completed and succeeds with its value.
- Fails with: the error it was failed with.
- Notes: returns at once when already completed. An interrupted waiter is removed.
local eff = Fx.gen(function()
local loaded = Fx.yield(Fx.Deferred.make())
Fx.yield(loaded:succeed({ name = 'Ana' }):delay(300):fork())
local char = Fx.yield(loaded:await())
return char.name
end)
Fx.runFork(eff, { onExit = function(exit) print(exit.value) end }) --> Ana
deferred:succeed(value), deferred:fail(err), deferred:done(exit)
deferred:succeed(value: A) -> Effect<boolean, never>
deferred:fail(err: E) -> Effect<boolean, never>
deferred:done(exit: Exit<A>) -> Effect<boolean, never>
deferred:unsafeDone(exit: Exit<A>) -> boolean
- Returns:
truewhen this call completed the deferred,falsewhen it was already completed (the first value wins). - Notes:
unsafeDoneis the plain-function form for callbacks.
local d = Fx.Deferred.unsafeMake()
print(Fx.runSync(d:succeed('first')), Fx.runSync(d:succeed('second'))) --> true false
deferred:isDone()
deferred:isDone() -> boolean
- Notes: plain function, not an effect.
local d = Fx.Deferred.unsafeMake()
d:unsafeDone(Fx.Exit.succeed(1))
print(d:isDone()) --> true
Semaphore
Limits how many fibers run a section at once. Waiters are served in order (FIFO): a request waits while anyone is queued ahead of it, even if enough permits are free.
Fx.Semaphore.make(permits) and Fx.Semaphore.unsafeMake(permits)
Fx.Semaphore.make(permits: integer) -> Effect<Semaphore, never>
Fx.Semaphore.unsafeMake(permits: integer) -> Semaphore
local garageSlots = Fx.Semaphore.unsafeMake(2)
print(garageSlots.permits) --> 2
semaphore:withPermit(eff) and semaphore:withPermits(n, eff)
semaphore:withPermit(eff: Effect<A, E>) -> Effect<A, E>
semaphore:withPermits(n: integer, eff: Effect<A, E>) -> Effect<A, E>
- Returns:
eff, run while holding 1 (orn) permits. The permits are released howevereffends, including interruption. - Notes: asking for more permits than the semaphore has waits forever.
local dbSlots = Fx.Semaphore.unsafeMake(1)
local active, peak = 0, 0
local function saveCharacter(src)
return dbSlots:withPermit(Fx.sync(function() active = active + 1; peak = math.max(peak, active) end)
:zipRight(Fx.sleep(50))
:zipRight(Fx.sync(function() active = active - 1 end)))
end
Fx.runFork(Fx.forEach({ 1, 2, 3 }, saveCharacter, { concurrency = 'unbounded' }), {
onExit = function() print('peak', peak) end,
})
--> peak 1
semaphore:take(n?) and semaphore:release(n?)
semaphore:take(n?: integer) -> Effect<nil, never> -- default 1
semaphore:release(n?: integer) -> Effect<nil, never> -- default 1
semaphore:unsafeRelease(n?: integer) -> nil
- Notes: manual control. Prefer
withPermit, which cannot leak permits.takewaits until the permits are free; an interruptedtakeleaves the queue.
local sem = Fx.Semaphore.unsafeMake(1)
local eff = sem:take():zipRight(Fx.sync(function() print('in', sem.permits) end)):zipRight(sem:release())
Fx.runFork(eff, { onExit = function() print('out', sem.permits) end })
--> in 0
--> out 1
Queue
A FIFO between fibers. take waits while the queue is empty. What offer does on a full queue depends on the strategy:
| Constructor | Capacity | offer when full |
|---|---|---|
Fx.Queue.unbounded() |
unlimited | never full |
Fx.Queue.bounded(n) |
n |
waits for room (strategy 'suspend') |
Fx.Queue.dropping(n) |
n |
drops the new item, succeeds with false |
Fx.Queue.sliding(n) |
n |
drops the oldest item, adds the new one, succeeds with true |
Fx.Queue.unbounded(), bounded(n), dropping(n), sliding(n)
Fx.Queue.unbounded() -> Effect<Queue, never>
Fx.Queue.bounded(n: integer) -> Effect<Queue, never>
Fx.Queue.dropping(n: integer) -> Effect<Queue, never>
Fx.Queue.sliding(n: integer) -> Effect<Queue, never>
local eff = Fx.gen(function()
local chat = Fx.yield(Fx.Queue.sliding(2))
for _, msg in ipairs({ 'hi', 'anyone?', 'hello' }) do Fx.yield(chat:offer(msg)) end
return Fx.yield(chat:takeAll())
end)
Fx.runFork(eff, { onExit = function(exit) print(table.concat(exit.value, ' | ')) end }) --> anyone? | hello
Fx.Queue.unsafeMake(capacity?, strategy?) and Fx.Queue.unsafeUnbounded()
Fx.Queue.unsafeMake(capacity?: integer, strategy?: 'suspend' | 'dropping' | 'sliding') -> Queue
Fx.Queue.unsafeUnbounded() -> Queue
- Notes: build a queue immediately, outside any effect. Defaults: unbounded,
'suspend'.
local jobs = Fx.Queue.unsafeMake(10, 'dropping')
print(jobs.capacity, jobs.strategy) --> 10 dropping
queue:offer(value)
queue:offer(value: any) -> Effect<boolean, never>
- Returns:
truewhen the value was added (or handed straight to a waiting taker);falsewhen a dropping queue was full. - Notes: on a full bounded queue it waits for room, then succeeds with
true. An interrupted waiting offer is withdrawn.offeris an async effect even when there is room, so it cannot run underFx.runSync; useunsafeOfferfrom plain code. Do not offernil: anilitem is indistinguishable from "empty" forpoll.
local eff = Fx.gen(function()
local q = Fx.yield(Fx.Queue.dropping(1))
return { Fx.yield(q:offer('job1')), Fx.yield(q:offer('job2')) }
end)
Fx.runFork(eff, { onExit = function(exit) print(exit.value[1], exit.value[2]) end }) --> true false
queue:take()
queue:take() -> Effect<any, never>
- Returns: an effect that waits for an item and succeeds with it. Waiting takers are served in order.
local eff = Fx.gen(function()
local deliveries = Fx.yield(Fx.Queue.unbounded())
Fx.yield(deliveries:offer('pizza'):delay(500):fork())
return Fx.yield(deliveries:take())
end)
Fx.runFork(eff, { onExit = function(exit) print(exit.value) end }) --> pizza
queue:poll(), queue:takeAll(), queue:size()
queue:poll() -> Effect<any | nil, never> -- nil when empty
queue:takeAll() -> Effect<any[], never> -- everything queued now
queue:size() -> Effect<integer, never>
- Notes: none of these wait.
local eff = Fx.gen(function()
local q = Fx.yield(Fx.Queue.unbounded())
Fx.yield(q:offer('a'))
Fx.yield(q:offer('b'))
local first = Fx.yield(q:poll())
return { first = first, left = Fx.yield(q:size()), none = Fx.yield(Fx.Queue.unsafeUnbounded():poll()) }
end)
Fx.runFork(eff, { onExit = function(exit) print(exit.value.first, exit.value.left, exit.value.none) end }) --> a 1 nil
queue:unsafeOffer(value), queue:unsafePoll(), queue:unsafeSize()
queue:unsafeOffer(value: any) -> boolean
queue:unsafePoll() -> any | nil
queue:unsafeSize() -> integer
- Notes: plain functions for callbacks (event handlers,
AddEventHandler).unsafeOffernever waits: it returnsfalsewhen a bounded or dropping queue is full; a sliding queue drops its oldest item.
local events = Fx.Queue.unsafeMake(1)
print(events:unsafeOffer('enter'), events:unsafeOffer('exit'), events:unsafePoll()) --> true false enter
PubSub
Broadcasts each message to every subscriber that subscribed before it was published.
Fx.PubSub.make() and Fx.PubSub.unsafeMake()
Fx.PubSub.make() -> Effect<PubSub, never>
Fx.PubSub.unsafeMake() -> PubSub
local weatherChanged = Fx.PubSub.unsafeMake()
print(type(weatherChanged.subscribe)) --> function
pubsub:publish(value) and pubsub:unsafePublish(value)
pubsub:publish(value: any) -> Effect<nil, never>
pubsub:unsafePublish(value: any) -> nil
- Notes: publishing never waits. Each subscriber queue gets the value (or drops by its strategy), then each
onlistener is called. A listener that raises is reported and does not stop the others.
local ps = Fx.PubSub.unsafeMake()
ps:on(function(v) print('listener got', v) end)
Fx.runSync(ps:publish('RAIN')) --> listener got RAIN
pubsub:subscribe(opts?)
pubsub:subscribe(opts?: { buffer?: integer, strategy?: 'sliding' | 'dropping' }) -> Effect<Queue, never> -- requires Scope
- Parameters:
opts.buffer, capacity of the subscriber's queue (default unlimited).opts.strategy, what to drop when it is full:'sliding'(default, drops the oldest) or'dropping'(drops the newest). - Returns: a Queue that receives every message published from now until the scope closes.
- Fails with: a defect
Service not found: Scopeoutside a scope.
local alerts = Fx.PubSub.unsafeMake()
local listen = Fx.scoped(Fx.gen(function()
local inbox = Fx.yield(alerts:subscribe({ buffer = 10 }))
return Fx.yield(inbox:take())
end))
Fx.runFork(listen, { onExit = function(exit) print('dispatch:', exit.value) end })
Fx.runFork(alerts:publish('10-50 at Legion Square'):delay(100))
--> dispatch: 10-50 at Legion Square
pubsub:on(fn)
pubsub:on(fn: fun(value: any)) -> fun()
- Returns: a function that removes the listener.
- Notes: a plain callback, called synchronously from
publish. Use it to bridge into non-effect code.
local ps = Fx.PubSub.unsafeMake()
local seen = {}
local off = ps:on(function(v) seen[#seen + 1] = v end)
ps:unsafePublish(1)
off()
ps:unsafePublish(2)
print(#seen) --> 1
For a stream of published values (Fx.Stream.fromPubSub), see Streams.