From fa7ad03c72e8265121fc69b5bbc3b8ee33e421ee Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Mon, 3 Nov 2025 16:46:48 +0000 Subject: [PATCH 1/9] adds guard + test --- fibers/op.lua | 111 ++++++++++++++++++++++++++++++++---- tests/test_op.lua | 139 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 239 insertions(+), 11 deletions(-) diff --git a/fibers/op.lua b/fibers/op.lua index 0592ac26..8c36cd0d 100644 --- a/fibers/op.lua +++ b/fibers/op.lua @@ -14,6 +14,9 @@ local pack = table.pack or function(...) -- luacheck: ignore -- Compatibility fa return { n = select("#", ...), ... } end +local function id_wrap(...) + return ... +end local Suspension = {} Suspension.__index = Suspension @@ -83,7 +86,7 @@ BaseOp.__index = BaseOp -- @tparam function block_fn The block function. -- @treturn BaseOp The created base operation. local function new_base_op(wrap_fn, try_fn, block_fn) - if wrap_fn == nil then wrap_fn = function(...) return ... end end + if wrap_fn == nil then wrap_fn = id_wrap end return setmetatable( { wrap_fn = wrap_fn, try_fn = try_fn, block_fn = block_fn }, BaseOp) @@ -117,10 +120,37 @@ local function choice(...) return new_choice_op(ops) end +--- CML-style guard: delayed event. g() is evaluated once per +--- synchronization (per call to :perform / :perform_alt, or when used +--- inside a ChoiceOp:perform), and the event it returns participates +--- fully in the choice. +local function guard(g) + -- A guard is a delayed op. Its try/block are not used directly; + -- execution always goes through ChoiceOp resolution first. + local base = new_base_op( + nil, + function() return false end, + function() + error("guard block_fn should never be called directly") + end + ) + base._guard_builder = g + return base +end --- Wrap the base operation with the given function. -- @tparam function f The function. -- @treturn BaseOp The created base operation. function BaseOp:wrap(f) + -- Preserve delayed semantics for guards. + if self._guard_builder then + local prev_wrap = self.wrap_fn or id_wrap + local composed = function(...) + return f(prev_wrap(...)) + end + local base = new_base_op(composed, self.try_fn, self.block_fn) + base._guard_builder = self._guard_builder + return base + end local wrap_fn, try_fn, block_fn = self.wrap_fn, self.try_fn, self.block_fn return new_base_op(function(...) return f(wrap_fn(...)) end, try_fn, block_fn) end @@ -138,9 +168,63 @@ local function block_base_op(sched, fib, op) op.block_fn(new_suspension(sched, fib), op.wrap_fn) end +-- Resolve a single event (BaseOp or ChoiceOp) into a list of BaseOps, +-- composing an outer wrap on top if provided. +local function resolve_event(ev, outer_wrap, out) + if not out then out = {} end + outer_wrap = outer_wrap or id_wrap + + if ev.base_ops then + -- ev is a ChoiceOp: resolve each arm with the same outer_wrap. + for _, b in ipairs(ev.base_ops) do + resolve_event(b, outer_wrap, out) + end + elseif ev._guard_builder then + -- ev is a guard: evaluate builder once for this synchronization, + -- then resolve the resulting event. + local inner_ev = ev._guard_builder() + + local guard_wrap = ev.wrap_fn or id_wrap + local composed_outer = function(...) + local mid = pack(guard_wrap(...)) + return outer_wrap(unpack(mid, 1, mid.n)) + end + + resolve_event(inner_ev, composed_outer, out) + else + -- Plain BaseOp: compose inner wrap under outer_wrap. + local inner_wrap = ev.wrap_fn or id_wrap + local composed_wrap = function(...) + local mid = pack(inner_wrap(...)) + return outer_wrap(unpack(mid, 1, mid.n)) + end + + table.insert(out, new_base_op(composed_wrap, ev.try_fn, ev.block_fn)) + end + + return out +end + +local function resolve_choice_ops(base_ops) + local resolved = {} + for _, op in ipairs(base_ops) do + resolve_event(op, nil, resolved) + end + return resolved +end + +-- Treat a delayed event (guard) as a 1-arm choice so guard semantics +-- and choice semantics share the same resolution path. +local function perform_delayed(self) + return new_choice_op({ self }):perform() +end --- Perform the base operation. -- @treturn vararg The value returned by the operation. function BaseOp:perform() + -- Delayed events (guards) go through choice resolution. + if self._guard_builder then + return perform_delayed(self) + end local retval = pack(self.try_fn()) local success = table.remove(retval, 1) if success then return self.wrap_fn(unpack(retval)) end @@ -157,10 +241,12 @@ end --- Perform the choice operation. -- @treturn vararg The value returned by the operation. function ChoiceOp:perform() - local ops = self.base_ops - local base = math.random(#ops) - for i = 1, #ops do - local op = ops[((i + base) % #ops) + 1] + -- Expand guards (and nested choices) once for this synchronization. + local ops = resolve_choice_ops(self.base_ops) + local n = #ops + local base = math.random(n) + for i = 1, n do + local op = ops[((i + base) % n) + 1] local retval = pack(op.try_fn()) local success = table.remove(retval, 1) if success then return op.wrap_fn(unpack(retval)) end @@ -174,6 +260,9 @@ end -- @tparam function f The function. -- @treturn vararg The value returned by the operation or the function. function BaseOp:perform_alt(f) + if self._guard_builder then + return new_choice_op({ self }):perform_alt(f) + end local success, val = self.try_fn() if success then return self.wrap_fn(val) end return f() @@ -183,10 +272,11 @@ end -- @tparam function f The function. -- @treturn vararg The value returned by the operation or the function. function ChoiceOp:perform_alt(f) - local ops = self.base_ops - local base = math.random(#ops) - for i = 1, #ops do - local op = ops[((i + base) % #ops) + 1] + local ops = resolve_choice_ops(self.base_ops) + local n = #ops + local base = math.random(n) + for i = 1, n do + local op = ops[((i + base) % n) + 1] local success, val = op.try_fn() if success then return op.wrap_fn(val) end end @@ -195,5 +285,6 @@ end return { new_base_op = new_base_op, - choice = choice + choice = choice, + guard = guard, } diff --git a/tests/test_op.lua b/tests/test_op.lua index b09f2601..146a9c11 100644 --- a/tests/test_op.lua +++ b/tests/test_op.lua @@ -4,7 +4,7 @@ print('testing: fibers.op') -- look one level up package.path = "../?.lua;" .. package.path -local op = require 'fibers.op' +local op = require 'fibers.op' local fiber = require 'fibers.fiber' local function task(val) @@ -42,4 +42,141 @@ fiber.spawn(function() end) fiber.main() +---------------------------------------------------------------- +-- guard +---------------------------------------------------------------- + +-- 1) Basic guard: behaves like g():perform(), builder called once per sync +fiber.spawn(function() + local calls = 0 + local g = function() + calls = calls + 1 + return task(42) + end + + local ev = op.guard(g) + local v = ev:perform() + assert(v == 42, "guard basic: wrong result") + assert(calls == 1, "guard basic: builder not called exactly once") + + fiber.stop() +end) +fiber.main() + +-- 2) guard inside choice: participates fully, builder once per perform +fiber.spawn(function() + local calls = 0 + local g = function() + calls = calls + 1 + return task(10) + end + + local guarded = op.guard(g) + local choice = op.choice(guarded, task(20)) + + local runs = 5 + for _ = 1, runs do + local v = choice:perform() + assert(v == 10 or v == 20, + "guard in choice: result out of range") + end + + -- g() should have been called once per synchronization + assert(calls == runs, "guard in choice: builder call count mismatch") + + fiber.stop() +end) +fiber.main() + +-- 3) guard + wrap: guard(g):wrap(f):perform() == f(g():perform()) +fiber.spawn(function() + local calls = 0 + local g = function() + calls = calls + 1 + return task(5) + end + + local ev = op.guard(g):wrap(function(x) + return x * 2 + end) + + local v = ev:perform() + assert(v == 10, "guard wrap: wrong result") + assert(calls == 1, "guard wrap: builder not called exactly once") + + fiber.stop() +end) +fiber.main() + +-- helper: an op that always fails its try() so perform_alt fallback is taken +local function never_op() + local wrap_fn = function(x) return x end + local try_fn = function() return false end + local block_fn = function() end + return op.new_base_op(wrap_fn, try_fn, block_fn) +end + +-- 4) guard:perform_alt uses inner event and fallback correctly +fiber.spawn(function() + local calls = 0 + local g = function() + calls = calls + 1 + return never_op() + end + + local ev = op.guard(g) + local v = ev:perform_alt(function() return 99 end) + + assert(v == 99, "guard perform_alt: wrong fallback result") + assert(calls == 1, "guard perform_alt: builder not called exactly once") + + fiber.stop() +end) +fiber.main() + +-- 5) guard whose builder returns a ChoiceOp +fiber.spawn(function() + local calls = 0 + local g = function() + calls = calls + 1 + return op.choice(task(1), task(2)) + end + + local ev = op.guard(g) + local v = ev:perform() + + assert(v == 1 or v == 2, "guard returning choice: result out of range") + assert(calls == 1, "guard returning choice: builder not called exactly once") + + fiber.stop() +end) +fiber.main() + +-- 6) Nested guards: guard(g1) where g1 returns guard(g2) +fiber.spawn(function() + local outer_calls = 0 + local inner_calls = 0 + + local inner_g = function() + inner_calls = inner_calls + 1 + return task(7) + end + + local outer_g = function() + outer_calls = outer_calls + 1 + return op.guard(inner_g) + end + + local ev = op.guard(outer_g):wrap(function(x) + return x + 1 + end) + + local v = ev:perform() + assert(v == 8, "nested guard: wrong result") + assert(outer_calls == 1, "nested guard: outer builder call count mismatch") + assert(inner_calls == 1, "nested guard: inner builder call count mismatch") + + fiber.stop() +end) +fiber.main() print('test: ok') From bd56505f4716257f6210e7c57f9610fd6d8d056c Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Mon, 3 Nov 2025 17:54:10 +0000 Subject: [PATCH 2/9] adds a basic condition variable in op, reuses it in cond and waitgroup --- fibers/cond.lua | 53 +++++++++++++------------------------------- fibers/op.lua | 43 +++++++++++++++++++++++++++++++++++ fibers/waitgroup.lua | 22 +++++++++++++----- 3 files changed, 74 insertions(+), 44 deletions(-) diff --git a/fibers/cond.lua b/fibers/cond.lua index 06d4f7fa..03fc4cc0 100644 --- a/fibers/cond.lua +++ b/fibers/cond.lua @@ -1,58 +1,35 @@ --- Use of this source code is governed by the Apache 2.0 license; see COPYING. - --- fibers.cond module. --- This module implements a condition variable, a rendezvous point for --- fibers waiting for or announcing the occurrence of an event. +-- Thin wrapper around the core condition primitive in fibers.op. +-- A Cond is a one-shot, signal-all rendezvous: once signalled, +-- all current and future waiters complete. -- @module fibers.cond local op = require 'fibers.op' ---- Cond class. --- Represents a condition variable. --- @type Cond local Cond = {} +Cond.__index = Cond --- Create a new condition variable. -- @treturn Cond The new condition variable. local function new() - return setmetatable({ waitq = {} }, { __index = Cond }) + -- op.new_cond() returns a table with wait_op() and signal(). + local prim = op.new_cond() + return setmetatable(prim, Cond) end ---- Create a new operation that will put the fiber into a wait state on the condition variable. +--- Operation that waits on the condition. +-- This is provided by op.new_cond() as prim.wait_op. -- @treturn operation The created operation. -function Cond:wait_op() - local function try() return not self.waitq end - local function gc() - local i = 1 - while i <= #self.waitq do - if self.waitq[i].suspension:waiting() then - i = i + 1 - else - table.remove(self.waitq, i) - end - end - end - local function block(suspension, wrap_fn) - gc() - table.insert(self.waitq, { suspension = suspension, wrap = wrap_fn }) - end - return op.new_base_op(nil, try, block) -end +-- function Cond:wait_op() ... end -- inherited from prim --- Put the fiber into a wait state on the condition variable. -function Cond:wait() return self:wait_op():perform() end +function Cond:wait() + return self:wait_op():perform() +end --- Wake up all fibers that are waiting on this condition variable. -function Cond:signal() - if self.waitq ~= nil then - for _, remote in ipairs(self.waitq) do - if remote.suspension:waiting() then - remote.suspension:complete(remote.wrap) - end - end - self.waitq = nil - end -end +-- This is provided by op.new_cond() as prim.signal(). +-- function Cond:signal() ... end -- inherited from prim return { new = new diff --git a/fibers/op.lua b/fibers/op.lua index 8c36cd0d..0bf69fc7 100644 --- a/fibers/op.lua +++ b/fibers/op.lua @@ -168,6 +168,48 @@ local function block_base_op(sched, fib, op) op.block_fn(new_suspension(sched, fib), op.wrap_fn) end +-- Simple one-shot condition primitive: all current and future waiters +-- wake once signal() is called. Used by fibers.cond as a thin wrapper. +local function new_cond() + local state = { + triggered = false, + waiters = {}, -- array of CompleteTask + } + + local function wait_op() + local function try() + return state.triggered + end + local function block(suspension, wrap_fn) + if state.triggered then + -- Already signalled: complete immediately. + suspension:complete(wrap_fn) + else + -- Store a CompleteTask; we'll schedule it on signal(). + table.insert(state.waiters, suspension:complete_task(wrap_fn)) + end + end + return new_base_op(nil, try, block) + end + + local function signal() + if state.triggered then return end + state.triggered = true + -- Wake all waiters (if still waiting) on their schedulers. + for i = 1, #state.waiters do + local task = state.waiters[i] + state.waiters[i] = nil + if task and task.suspension and task.suspension:waiting() then + task.suspension.sched:schedule(task) + end + end + end + + return { + wait_op = wait_op, + signal = signal, + } +end -- Resolve a single event (BaseOp or ChoiceOp) into a list of BaseOps, -- composing an outer wrap on top if provided. local function resolve_event(ev, outer_wrap, out) @@ -287,4 +329,5 @@ return { new_base_op = new_base_op, choice = choice, guard = guard, + new_cond = new_cond, } diff --git a/fibers/waitgroup.lua b/fibers/waitgroup.lua index bec5bb2b..c01ffb57 100644 --- a/fibers/waitgroup.lua +++ b/fibers/waitgroup.lua @@ -1,12 +1,15 @@ -- waitgroup.lua local op = require 'fibers.op' -local cond = require 'fibers.cond' local Waitgroup = {} Waitgroup.__index = Waitgroup local function new() - local wg = setmetatable({ _counter = 0, _cond = cond.new() }, Waitgroup) + -- Use the core condition primitive directly: { wait_op = ..., signal = ... } + local wg = setmetatable({ + _counter = 0, + _cond = op.new_cond(), + }, Waitgroup) return wg end @@ -15,7 +18,7 @@ function Waitgroup:add(count) if self._counter < 0 then error("waitgroup counter goes negative") elseif self._counter == 0 then - self._cond:signal() + self._cond.signal() end end @@ -24,15 +27,22 @@ function Waitgroup:done() end function Waitgroup:wait_op() + -- Take the underlying cond's wait op (a BaseOp). + local cond_op = self._cond.wait_op() local function try() return self._counter == 0 end + local function block(suspension, wrap_fn) - if self._counter > 0 then - -- Add suspension to the condition variable's wait queue. - self._cond.waitq[#self._cond.waitq + 1] = { suspension = suspension, wrap = wrap_fn } + if self._counter == 0 then + -- Became zero after try() but before block() ran. + suspension:complete(wrap_fn) + else + -- Delegate blocking to the underlying condition's block_fn. + cond_op.block_fn(suspension, wrap_fn) end end + return op.new_base_op(nil, try, block) end From c562db4f7c93bdbeeffdc6ab9415537d0f5ced21 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Mon, 3 Nov 2025 21:19:29 +0000 Subject: [PATCH 3/9] adds nack --- fibers/op.lua | 93 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 81 insertions(+), 12 deletions(-) diff --git a/fibers/op.lua b/fibers/op.lua index 0bf69fc7..2f5100a4 100644 --- a/fibers/op.lua +++ b/fibers/op.lua @@ -77,7 +77,9 @@ end --- BaseOp class -- Represents a base operation. -- @type BaseOp -local BaseOp = {} +local BaseOp = { + wrap_fn = id_wrap, +} BaseOp.__index = BaseOp --- Create a new base operation. @@ -142,13 +144,13 @@ end -- @treturn BaseOp The created base operation. function BaseOp:wrap(f) -- Preserve delayed semantics for guards. - if self._guard_builder then - local prev_wrap = self.wrap_fn or id_wrap + if self._guard_builder or self._with_nack_builder then local composed = function(...) - return f(prev_wrap(...)) + return f(self.wrap_fn(...)) end local base = new_base_op(composed, self.try_fn, self.block_fn) base._guard_builder = self._guard_builder + base._with_nack_builder = self._with_nack_builder return base end local wrap_fn, try_fn, block_fn = self.wrap_fn, self.try_fn, self.block_fn @@ -210,6 +212,20 @@ local function new_cond() signal = signal, } end +-- CML-style withNack: delayed event with a negative-ack event. +-- g(nack_ev) is evaluated once per synchronization. If some *other* +-- event in the same synchronization commits, nack_ev becomes enabled. +local function with_nack(g) + local base = new_base_op( + nil, + function() return false end, + function() + error("with_nack block_fn should never be called directly") + end + ) + base._with_nack_builder = g + return base +end -- Resolve a single event (BaseOp or ChoiceOp) into a list of BaseOps, -- composing an outer wrap on top if provided. local function resolve_event(ev, outer_wrap, out) @@ -226,16 +242,33 @@ local function resolve_event(ev, outer_wrap, out) -- then resolve the resulting event. local inner_ev = ev._guard_builder() - local guard_wrap = ev.wrap_fn or id_wrap local composed_outer = function(...) - local mid = pack(guard_wrap(...)) + local mid = pack(ev.wrap_fn(...)) return outer_wrap(unpack(mid, 1, mid.n)) end resolve_event(inner_ev, composed_outer, out) + elseif ev._with_nack_builder then + -- with_nack: create a per-sync condition and nack event. + local cond = new_cond() + local nack_ev = cond.wait_op() -- this is the nack event g() sees + local inner_ev = ev._with_nack_builder(nack_ev) + + local with_wrap = ev.wrap_fn + local composed_outer = function(...) + local mid = pack(with_wrap(...)) + return outer_wrap(unpack(mid, 1, mid.n)) + end + + -- Flatten the inner event, tagging all resulting ops with this cond. + local start_index = #out + 1 + resolve_event(inner_ev, composed_outer, out) + for i = start_index, #out do + out[i]._nack_cond = cond + end else -- Plain BaseOp: compose inner wrap under outer_wrap. - local inner_wrap = ev.wrap_fn or id_wrap + local inner_wrap = ev.wrap_fn local composed_wrap = function(...) local mid = pack(inner_wrap(...)) return outer_wrap(unpack(mid, 1, mid.n)) @@ -255,6 +288,18 @@ local function resolve_choice_ops(base_ops) return resolved end +-- Trigger nack events for all *losing* with_nack arms in this choice. +local function trigger_nacks(ops, winner) + local winner_cond = winner and winner._nack_cond or nil + local seen = {} + for _, op in ipairs(ops) do + local cond = op._nack_cond + if cond and cond ~= winner_cond and not seen[cond] then + seen[cond] = true + cond.signal() + end + end +end -- Treat a delayed event (guard) as a 1-arm choice so guard semantics -- and choice semantics share the same resolution path. local function perform_delayed(self) @@ -264,7 +309,7 @@ end -- @treturn vararg The value returned by the operation. function BaseOp:perform() -- Delayed events (guards) go through choice resolution. - if self._guard_builder then + if self._guard_builder or self._with_nack_builder then return perform_delayed(self) end local retval = pack(self.try_fn()) @@ -277,7 +322,14 @@ end local function block_choice_op(sched, fib, ops) local suspension = new_suspension(sched, fib) - for _, op in ipairs(ops) do op.block_fn(suspension, op.wrap_fn) end + for _, op in ipairs(ops) do + -- Per-choice unique wrapper simply delegates to the op's own wrap_fn. + local function choice_wrap(...) + return op.wrap_fn(...) + end + op._choice_wrap = choice_wrap + op.block_fn(suspension, choice_wrap) + end end --- Perform the choice operation. @@ -287,14 +339,27 @@ function ChoiceOp:perform() local ops = resolve_choice_ops(self.base_ops) local n = #ops local base = math.random(n) + -- Fast path: use try_fn directly and commit if any arm is ready. for i = 1, n do local op = ops[((i + base) % n) + 1] local retval = pack(op.try_fn()) local success = table.remove(retval, 1) - if success then return op.wrap_fn(unpack(retval)) end + if success then + trigger_nacks(ops, op) + return op.wrap_fn(unpack(retval)) + end end + -- Slow path: block on all ops via block_choice_op. local retval = pack(fiber.suspend(block_choice_op, ops)) local wrap = table.remove(retval, 1) + local winner + for _, op in ipairs(ops) do + if op._choice_wrap == wrap then + winner = op + break + end + end + trigger_nacks(ops, winner) return wrap(unpack(retval)) end @@ -302,7 +367,7 @@ end -- @tparam function f The function. -- @treturn vararg The value returned by the operation or the function. function BaseOp:perform_alt(f) - if self._guard_builder then + if self._guard_builder or self._with_nack_builder then return new_choice_op({ self }):perform_alt(f) end local success, val = self.try_fn() @@ -320,7 +385,10 @@ function ChoiceOp:perform_alt(f) for i = 1, n do local op = ops[((i + base) % n) + 1] local success, val = op.try_fn() - if success then return op.wrap_fn(val) end + if success then + trigger_nacks(ops, op) + return op.wrap_fn(val) + end end return f() end @@ -330,4 +398,5 @@ return { choice = choice, guard = guard, new_cond = new_cond, + with_nack = with_nack, } From 6823d7a0e4c9600c900978da7b6995ceaf1a224b Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Tue, 4 Nov 2025 00:25:01 +0000 Subject: [PATCH 4/9] neatens nack --- fibers/op.lua | 119 +++++++++++++++++++++++--------------------------- 1 file changed, 54 insertions(+), 65 deletions(-) diff --git a/fibers/op.lua b/fibers/op.lua index 2f5100a4..21ad31d7 100644 --- a/fibers/op.lua +++ b/fibers/op.lua @@ -1,8 +1,3 @@ --- (c) Snabb project --- (c) Jangala - --- Use of this source code is governed by the XXXXXXXXX license; see COPYING. - --- fibers.op module -- Provides Concurrent ML style operations for managing concurrency. -- @module fibers.op @@ -17,6 +12,7 @@ end local function id_wrap(...) return ... end + local Suspension = {} Suspension.__index = Suspension @@ -78,7 +74,7 @@ end -- Represents a base operation. -- @type BaseOp local BaseOp = { - wrap_fn = id_wrap, + wrap_fn = id_wrap } BaseOp.__index = BaseOp @@ -88,7 +84,6 @@ BaseOp.__index = BaseOp -- @tparam function block_fn The block function. -- @treturn BaseOp The created base operation. local function new_base_op(wrap_fn, try_fn, block_fn) - if wrap_fn == nil then wrap_fn = id_wrap end return setmetatable( { wrap_fn = wrap_fn, try_fn = try_fn, block_fn = block_fn }, BaseOp) @@ -122,39 +117,21 @@ local function choice(...) return new_choice_op(ops) end ---- CML-style guard: delayed event. g() is evaluated once per ---- synchronization (per call to :perform / :perform_alt, or when used ---- inside a ChoiceOp:perform), and the event it returns participates ---- fully in the choice. -local function guard(g) - -- A guard is a delayed op. Its try/block are not used directly; - -- execution always goes through ChoiceOp resolution first. - local base = new_base_op( - nil, - function() return false end, - function() - error("guard block_fn should never be called directly") - end - ) - base._guard_builder = g - return base -end --- Wrap the base operation with the given function. -- @tparam function f The function. -- @treturn BaseOp The created base operation. function BaseOp:wrap(f) - -- Preserve delayed semantics for guards. - if self._guard_builder or self._with_nack_builder then - local composed = function(...) + local new = new_base_op( + function(...) return f(self.wrap_fn(...)) - end - local base = new_base_op(composed, self.try_fn, self.block_fn) - base._guard_builder = self._guard_builder - base._with_nack_builder = self._with_nack_builder - return base - end - local wrap_fn, try_fn, block_fn = self.wrap_fn, self.try_fn, self.block_fn - return new_base_op(function(...) return f(wrap_fn(...)) end, try_fn, block_fn) + end, + self.try_fn, + self.block_fn + ) + -- Preserve any delayed-event builders on the new base op. + new._guard_builder = self._guard_builder + new._with_nack_builder = self._with_nack_builder + return new end --- Wrap the choice operation with the given function. @@ -212,20 +189,31 @@ local function new_cond() signal = signal, } end --- CML-style withNack: delayed event with a negative-ack event. --- g(nack_ev) is evaluated once per synchronization. If some *other* --- event in the same synchronization commits, nack_ev becomes enabled. -local function with_nack(g) +local function new_delayed_base_op(builder_field, g, label) local base = new_base_op( - nil, + id_wrap, function() return false end, function() - error("with_nack block_fn should never be called directly") + error(label .. " block_fn should never be called directly") end ) - base._with_nack_builder = g + base[builder_field] = g return base end +--- CML-style guard: delayed event. g() is evaluated once per +--- synchronization (per call to :perform / :perform_alt, or when used +--- inside a ChoiceOp:perform), and the event it returns participates +--- fully in the choice. +local function guard(g) + return new_delayed_base_op("_guard_builder", g, "guard") +end + +-- CML-style withNack: delayed event with a negative-ack event. +-- g(nack_ev) is evaluated once per synchronization. If some *other* +-- event in the same synchronization commits, nack_ev becomes enabled. +local function with_nack(g) + return new_delayed_base_op("_with_nack_builder", g, "with_nack") +end -- Resolve a single event (BaseOp or ChoiceOp) into a list of BaseOps, -- composing an outer wrap on top if provided. local function resolve_event(ev, outer_wrap, out) @@ -242,9 +230,9 @@ local function resolve_event(ev, outer_wrap, out) -- then resolve the resulting event. local inner_ev = ev._guard_builder() + local guard_wrap = ev.wrap_fn local composed_outer = function(...) - local mid = pack(ev.wrap_fn(...)) - return outer_wrap(unpack(mid, 1, mid.n)) + return outer_wrap(guard_wrap(...)) end resolve_event(inner_ev, composed_outer, out) @@ -256,8 +244,7 @@ local function resolve_event(ev, outer_wrap, out) local with_wrap = ev.wrap_fn local composed_outer = function(...) - local mid = pack(with_wrap(...)) - return outer_wrap(unpack(mid, 1, mid.n)) + return outer_wrap(with_wrap(...)) end -- Flatten the inner event, tagging all resulting ops with this cond. @@ -270,8 +257,7 @@ local function resolve_event(ev, outer_wrap, out) -- Plain BaseOp: compose inner wrap under outer_wrap. local inner_wrap = ev.wrap_fn local composed_wrap = function(...) - local mid = pack(inner_wrap(...)) - return outer_wrap(unpack(mid, 1, mid.n)) + return outer_wrap(inner_wrap(...)) end table.insert(out, new_base_op(composed_wrap, ev.try_fn, ev.block_fn)) @@ -303,7 +289,7 @@ end -- Treat a delayed event (guard) as a 1-arm choice so guard semantics -- and choice semantics share the same resolution path. local function perform_delayed(self) - return new_choice_op({ self }):perform() + return choice(self):perform() end --- Perform the base operation. -- @treturn vararg The value returned by the operation. @@ -313,20 +299,19 @@ function BaseOp:perform() return perform_delayed(self) end local retval = pack(self.try_fn()) - local success = table.remove(retval, 1) - if success then return self.wrap_fn(unpack(retval)) end + local success = retval[1] + if success then + return self.wrap_fn(unpack(retval, 2, retval.n)) + end local new_retval = pack(fiber.suspend(block_base_op, self)) - local wrap = table.remove(new_retval, 1) - return wrap(unpack(new_retval)) + local wrap = new_retval[1] + return wrap(unpack(new_retval, 2, new_retval.n)) end local function block_choice_op(sched, fib, ops) local suspension = new_suspension(sched, fib) for _, op in ipairs(ops) do - -- Per-choice unique wrapper simply delegates to the op's own wrap_fn. - local function choice_wrap(...) - return op.wrap_fn(...) - end + local choice_wrap = op.wrap_fn op._choice_wrap = choice_wrap op.block_fn(suspension, choice_wrap) end @@ -343,15 +328,15 @@ function ChoiceOp:perform() for i = 1, n do local op = ops[((i + base) % n) + 1] local retval = pack(op.try_fn()) - local success = table.remove(retval, 1) + local success = retval[1] if success then trigger_nacks(ops, op) - return op.wrap_fn(unpack(retval)) + return op.wrap_fn(unpack(retval, 2, retval.n)) end end -- Slow path: block on all ops via block_choice_op. local retval = pack(fiber.suspend(block_choice_op, ops)) - local wrap = table.remove(retval, 1) + local wrap = retval[1] local winner for _, op in ipairs(ops) do if op._choice_wrap == wrap then @@ -360,7 +345,7 @@ function ChoiceOp:perform() end end trigger_nacks(ops, winner) - return wrap(unpack(retval)) + return wrap(unpack(retval, 2, retval.n)) end --- Perform the base operation or return the result of the function if the operation cannot be performed. @@ -370,8 +355,11 @@ function BaseOp:perform_alt(f) if self._guard_builder or self._with_nack_builder then return new_choice_op({ self }):perform_alt(f) end - local success, val = self.try_fn() - if success then return self.wrap_fn(val) end + local retval = pack(self.try_fn()) + local success = retval[1] + if success then + return self.wrap_fn(unpack(retval, 2, retval.n)) + end return f() end @@ -384,10 +372,11 @@ function ChoiceOp:perform_alt(f) local base = math.random(n) for i = 1, n do local op = ops[((i + base) % n) + 1] - local success, val = op.try_fn() + local retval = pack(op.try_fn()) + local success = retval[1] if success then trigger_nacks(ops, op) - return op.wrap_fn(val) + return op.wrap_fn(unpack(retval, 2, retval.n)) end end return f() From 3b63b767ac9bdf507910b8476723170520f9b57b Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Tue, 4 Nov 2025 01:02:37 +0000 Subject: [PATCH 5/9] streamlines op --- fibers/op.lua | 125 +++++++++++++++++++++++--------------------------- 1 file changed, 57 insertions(+), 68 deletions(-) diff --git a/fibers/op.lua b/fibers/op.lua index 21ad31d7..0f485dcc 100644 --- a/fibers/op.lua +++ b/fibers/op.lua @@ -143,10 +143,6 @@ function ChoiceOp:wrap(f) return new_choice_op(ops) end -local function block_base_op(sched, fib, op) - op.block_fn(new_suspension(sched, fib), op.wrap_fn) -end - -- Simple one-shot condition primitive: all current and future waiters -- wake once signal() is called. Used by fibers.cond as a thin wrapper. local function new_cond() @@ -274,6 +270,25 @@ local function resolve_choice_ops(base_ops) return resolved end +-- Try once to commit on this event (BaseOp, ChoiceOp, or delayed event), +-- non-blocking. Returns winner_op, retval_pack, ops | nil. +local function try_once(ev) + -- Normalise to a list of logical events. + local logicals = ev.base_ops and ev.base_ops or { ev } + + -- Expand guards / with_nack / nested choices into plain BaseOps. + local ops = resolve_choice_ops(logicals) + local n = #ops + if n == 0 then return nil, nil, ops end + + local base = math.random(n) + for i = 1, n do + local op = ops[((i + base) % n) + 1] + local retval = pack(op.try_fn()) + if retval[1] then return op, retval, ops end + end + return nil, nil, ops +end -- Trigger nack events for all *losing* with_nack arms in this choice. local function trigger_nacks(ops, winner) local winner_cond = winner and winner._nack_cond or nil @@ -286,26 +301,13 @@ local function trigger_nacks(ops, winner) end end end --- Treat a delayed event (guard) as a 1-arm choice so guard semantics --- and choice semantics share the same resolution path. -local function perform_delayed(self) - return choice(self):perform() -end ---- Perform the base operation. --- @treturn vararg The value returned by the operation. -function BaseOp:perform() - -- Delayed events (guards) go through choice resolution. - if self._guard_builder or self._with_nack_builder then - return perform_delayed(self) - end - local retval = pack(self.try_fn()) - local success = retval[1] - if success then - return self.wrap_fn(unpack(retval, 2, retval.n)) - end - local new_retval = pack(fiber.suspend(block_base_op, self)) - local wrap = new_retval[1] - return wrap(unpack(new_retval, 2, new_retval.n)) +-- Public CML-style poll: non-blocking synchronization attempt. +-- Returns (true, ...results) if some arm commits, or (false) otherwise. +local function poll(ev) + local winner, retval, ops = try_once(ev) + if not winner then return false end + trigger_nacks(ops, winner) + return true, winner.wrap_fn(unpack(retval, 2, retval.n)) end local function block_choice_op(sched, fib, ops) @@ -320,72 +322,59 @@ end --- Perform the choice operation. -- @treturn vararg The value returned by the operation. function ChoiceOp:perform() - -- Expand guards (and nested choices) once for this synchronization. - local ops = resolve_choice_ops(self.base_ops) - local n = #ops - local base = math.random(n) - -- Fast path: use try_fn directly and commit if any arm is ready. - for i = 1, n do - local op = ops[((i + base) % n) + 1] - local retval = pack(op.try_fn()) - local success = retval[1] - if success then - trigger_nacks(ops, op) - return op.wrap_fn(unpack(retval, 2, retval.n)) - end + -- Fast path: non-blocking attempt using the shared core. + local winner, retval, ops = try_once(self) + if winner then + trigger_nacks(ops, winner) + return winner.wrap_fn(unpack(retval, 2, retval.n)) end - -- Slow path: block on all ops via block_choice_op. - local retval = pack(fiber.suspend(block_choice_op, ops)) - local wrap = retval[1] - local winner + + -- Slow path: block on all resolved ops. + local suspended = pack(fiber.suspend(block_choice_op, ops)) + local wrap = suspended[1] + local winner_after for _, op in ipairs(ops) do if op._choice_wrap == wrap then - winner = op + winner_after = op break end end - trigger_nacks(ops, winner) - return wrap(unpack(retval, 2, retval.n)) + trigger_nacks(ops, winner_after) + return wrap(unpack(suspended, 2, suspended.n)) end ---- Perform the base operation or return the result of the function if the operation cannot be performed. --- @tparam function f The function. --- @treturn vararg The value returned by the operation or the function. -function BaseOp:perform_alt(f) - if self._guard_builder or self._with_nack_builder then - return new_choice_op({ self }):perform_alt(f) - end - local retval = pack(self.try_fn()) - local success = retval[1] - if success then - return self.wrap_fn(unpack(retval, 2, retval.n)) - end - return f() +--- Perform the base operation. +-- We treat any single event (including guards/with_nack) as a 1-arm +-- choice so it shares the exact same resolution / blocking path as +-- ChoiceOp:perform. +function BaseOp:perform() + return new_choice_op({ self }):perform() end --- Perform the choice operation or return the result of the function if the operation cannot be performed. -- @tparam function f The function. -- @treturn vararg The value returned by the operation or the function. function ChoiceOp:perform_alt(f) - local ops = resolve_choice_ops(self.base_ops) - local n = #ops - local base = math.random(n) - for i = 1, n do - local op = ops[((i + base) % n) + 1] - local retval = pack(op.try_fn()) - local success = retval[1] - if success then - trigger_nacks(ops, op) - return op.wrap_fn(unpack(retval, 2, retval.n)) - end + local ret = pack(poll(self)) + local ready = ret[1] + if ready then + return unpack(ret, 2, ret.n) end return f() end +--- Perform the base operation or return the result of the function if the operation cannot be performed. +-- @tparam function f The function. +-- @treturn vararg The value returned by the operation or the function. +function BaseOp:perform_alt(f) + return new_choice_op({ self }):perform_alt(f) +end + return { new_base_op = new_base_op, choice = choice, guard = guard, new_cond = new_cond, with_nack = with_nack, + poll = poll, } From 0097c0b973410fb9e01989af0beeada8cc8da559 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Tue, 4 Nov 2025 01:34:01 +0000 Subject: [PATCH 6/9] complete reworking of op.lua --- fibers/op.lua | 406 ++++++++++++++++++++++---------------------------- 1 file changed, 177 insertions(+), 229 deletions(-) diff --git a/fibers/op.lua b/fibers/op.lua index 0f485dcc..7a637799 100644 --- a/fibers/op.lua +++ b/fibers/op.lua @@ -1,17 +1,18 @@ --- fibers.op module -- Provides Concurrent ML style operations for managing concurrency. --- @module fibers.op +-- Events are CML-style: they can be primitive, choices, guards, with_nack, +-- or wrapped. Synchronization compiles an event tree into primitive leaves. -local fiber = require 'fibers.fiber' +local fiber = require 'fibers.fiber' -local unpack = table.unpack or unpack -- luacheck: ignore -- Compatibility fallback -local pack = table.pack or function(...) -- luacheck: ignore -- Compatibility fallback - return { n = select("#", ...), ... } -end +local unpack = table.unpack or unpack -- luacheck: ignore +local pack = table.pack or function(...) return { n = select("#", ...), ... } end -- luacheck: ignore -local function id_wrap(...) - return ... -end +local function id_wrap(...) return ... end + +---------------------------------------------------------------------- +-- Suspensions and completion tasks +---------------------------------------------------------------------- local Suspension = {} Suspension.__index = Suspension @@ -19,13 +20,15 @@ Suspension.__index = Suspension local CompleteTask = {} CompleteTask.__index = CompleteTask -function Suspension:waiting() return self.state == 'waiting' end +function Suspension:waiting() + return self.state == 'waiting' +end function Suspension:complete(wrap, ...) assert(self:waiting()) self.state = 'synchronized' - self.wrap = wrap - self.val = {...} + self.wrap = wrap + self.val = { ... } self.sched:schedule(self) end @@ -36,7 +39,7 @@ function Suspension:complete_and_run(wrap, ...) end function Suspension:complete_task(wrap, ...) - return setmetatable({ suspension = self, wrap = wrap, val = {...} }, CompleteTask) + return setmetatable({ suspension = self, wrap = wrap, val = { ... } }, CompleteTask) end function Suspension:run() @@ -45,106 +48,87 @@ function Suspension:run() end local function new_suspension(sched, fib) - return setmetatable( - { state = 'waiting', sched = sched, fiber = fib }, - Suspension) + return setmetatable({ state = 'waiting', sched = sched, fiber = fib }, Suspension) end ---- A complete task is a task that when run, completes a suspension, if ---- the suspension hasn't been completed already. There can be multiple ---- complete tasks for a given suspension, if the suspension can complete ---- in multiple ways (e.g. via a choice op). +-- A CompleteTask completes a suspension (if still waiting) when run. function CompleteTask:run() if self.suspension:waiting() then - -- Use complete-and-run so that the fiber runs in this turn. self.suspension:complete_and_run(self.wrap, unpack(self.val)) end end ---- A complete task can also be cancelled, which makes it complete with a ---- call to "error". --- @param reason A string describing the reason for the cancellation +-- A CompleteTask can be cancelled, completing with an error. function CompleteTask:cancel(reason) if self.suspension:waiting() then self.suspension:complete(error, reason or 'cancelled') end end ---- BaseOp class --- Represents a base operation. --- @type BaseOp -local BaseOp = { - wrap_fn = id_wrap -} -BaseOp.__index = BaseOp - ---- Create a new base operation. --- @tparam function wrap_fn The wrap function. --- @tparam function try_fn The try function. --- @tparam function block_fn The block function. --- @treturn BaseOp The created base operation. +---------------------------------------------------------------------- +-- Event type (unifies primitive and composite events) +-- +-- kind = 'prim' : { try_fn, block_fn, wrap_fn } +-- kind = 'choice' : { events = { Event, ... } } +-- kind = 'guard' : { builder = function() -> Event } +-- kind = 'with_nack' : { builder = function(nack_ev) -> Event } +-- kind = 'wrap' : { inner = Event, wrap_fn = f } +---------------------------------------------------------------------- + +local Event = {} +Event.__index = Event + +-- Primitive event (leaf). +-- try_fn() -> success:boolean, ... +-- block_fn(suspension, wrap_fn) sets up async completion. local function new_base_op(wrap_fn, try_fn, block_fn) return setmetatable( - { wrap_fn = wrap_fn, try_fn = try_fn, block_fn = block_fn }, - BaseOp) -end - ---- ChoiceOp class --- Represents a choice operation. --- @type ChoiceOp -local ChoiceOp = {} -ChoiceOp.__index = ChoiceOp -local function new_choice_op(base_ops) - return setmetatable( - { base_ops = base_ops }, - ChoiceOp) + { + kind = 'prim', + wrap_fn = wrap_fn or id_wrap, + try_fn = try_fn, + block_fn = block_fn, + }, + Event + ) end ---- Create a choice operation from the given operations. --- @tparam vararg ops The operations. --- @treturn ChoiceOp The created choice operation. +-- Choice event: non-empty list of sub-events. local function choice(...) - local ops = {} - -- Build a flattened list of choices that are all base ops. - for _, op in ipairs({ ... }) do - if op.base_ops then - for _, base_op in ipairs(op.base_ops) do table.insert(ops, base_op) end + local events = {} + for _, ev in ipairs({ ... }) do + if ev.kind == 'choice' then + for _, sub in ipairs(ev.events) do events[#events + 1] = sub end else - table.insert(ops, op) + events[#events + 1] = ev end end - if #ops == 1 then return ops[1] end - return new_choice_op(ops) + if #events == 1 then return events[1] end + return setmetatable({ kind = 'choice', events = events }, Event) end ---- Wrap the base operation with the given function. --- @tparam function f The function. --- @treturn BaseOp The created base operation. -function BaseOp:wrap(f) - local new = new_base_op( - function(...) - return f(self.wrap_fn(...)) - end, - self.try_fn, - self.block_fn - ) - -- Preserve any delayed-event builders on the new base op. - new._guard_builder = self._guard_builder - new._with_nack_builder = self._with_nack_builder - return new +-- guard g: delayed event; g() evaluated once per synchronization. +local function guard(g) + return setmetatable({ kind = 'guard', builder = g }, Event) +end + +-- with_nack g: delayed event; g(nack_ev) evaluated once per synchronization. +local function with_nack(g) + return setmetatable({ kind = 'with_nack', builder = g }, Event) end ---- Wrap the choice operation with the given function. --- @tparam function f The function. --- @treturn ChoiceOp The created choice operation. -function ChoiceOp:wrap(f) - local ops = {} - for _, op in ipairs(self.base_ops) do table.insert(ops, op:wrap(f)) end - return new_choice_op(ops) +-- Wrap event with a post-processing function f. +-- This is another node in the tree; composed at compile time. +function Event:wrap(f) + return setmetatable( + { kind = 'wrap', inner = self, wrap_fn = f }, + Event + ) end --- Simple one-shot condition primitive: all current and future waiters --- wake once signal() is called. Used by fibers.cond as a thin wrapper. +---------------------------------------------------------------------- +-- Simple one-shot condition primitive (used for with_nack; also exported) +---------------------------------------------------------------------- local function new_cond() local state = { triggered = false, @@ -157,11 +141,9 @@ local function new_cond() end local function block(suspension, wrap_fn) if state.triggered then - -- Already signalled: complete immediately. suspension:complete(wrap_fn) else - -- Store a CompleteTask; we'll schedule it on signal(). - table.insert(state.waiters, suspension:complete_task(wrap_fn)) + state.waiters[#state.waiters + 1] = suspension:complete_task(wrap_fn) end end return new_base_op(nil, try, block) @@ -170,7 +152,6 @@ local function new_cond() local function signal() if state.triggered then return end state.triggered = true - -- Wake all waiters (if still waiting) on their schedulers. for i = 1, #state.waiters do local task = state.waiters[i] state.waiters[i] = nil @@ -185,196 +166,163 @@ local function new_cond() signal = signal, } end -local function new_delayed_base_op(builder_field, g, label) - local base = new_base_op( - id_wrap, - function() return false end, - function() - error(label .. " block_fn should never be called directly") - end - ) - base[builder_field] = g - return base -end ---- CML-style guard: delayed event. g() is evaluated once per ---- synchronization (per call to :perform / :perform_alt, or when used ---- inside a ChoiceOp:perform), and the event it returns participates ---- fully in the choice. -local function guard(g) - return new_delayed_base_op("_guard_builder", g, "guard") -end --- CML-style withNack: delayed event with a negative-ack event. --- g(nack_ev) is evaluated once per synchronization. If some *other* --- event in the same synchronization commits, nack_ev becomes enabled. -local function with_nack(g) - return new_delayed_base_op("_with_nack_builder", g, "with_nack") -end --- Resolve a single event (BaseOp or ChoiceOp) into a list of BaseOps, --- composing an outer wrap on top if provided. -local function resolve_event(ev, outer_wrap, out) - if not out then out = {} end +---------------------------------------------------------------------- +-- Compile an event tree into primitive leaves +-- +-- A compiled leaf has: +-- { try_fn, block_fn, wrap, nack_cond } +-- where wrap(...) is the final post-processing function for this leaf. +---------------------------------------------------------------------- + +local function compile_event(ev, outer_wrap, out, nack_cond) + out = out or {} outer_wrap = outer_wrap or id_wrap - if ev.base_ops then - -- ev is a ChoiceOp: resolve each arm with the same outer_wrap. - for _, b in ipairs(ev.base_ops) do - resolve_event(b, outer_wrap, out) - end - elseif ev._guard_builder then - -- ev is a guard: evaluate builder once for this synchronization, - -- then resolve the resulting event. - local inner_ev = ev._guard_builder() - - local guard_wrap = ev.wrap_fn - local composed_outer = function(...) - return outer_wrap(guard_wrap(...)) + local kind = ev.kind + + if kind == 'choice' then + for _, sub in ipairs(ev.events) do + compile_event(sub, outer_wrap, out, nack_cond) end - resolve_event(inner_ev, composed_outer, out) - elseif ev._with_nack_builder then - -- with_nack: create a per-sync condition and nack event. - local cond = new_cond() - local nack_ev = cond.wait_op() -- this is the nack event g() sees - local inner_ev = ev._with_nack_builder(nack_ev) + elseif kind == 'guard' then + local inner = ev.builder() + compile_event(inner, outer_wrap, out, nack_cond) - local with_wrap = ev.wrap_fn - local composed_outer = function(...) - return outer_wrap(with_wrap(...)) - end + elseif kind == 'with_nack' then + local cond = new_cond() + local nack_ev = cond.wait_op() -- Event + local inner = ev.builder(nack_ev) + compile_event(inner, outer_wrap, out, cond) - -- Flatten the inner event, tagging all resulting ops with this cond. - local start_index = #out + 1 - resolve_event(inner_ev, composed_outer, out) - for i = start_index, #out do - out[i]._nack_cond = cond - end - else - -- Plain BaseOp: compose inner wrap under outer_wrap. - local inner_wrap = ev.wrap_fn - local composed_wrap = function(...) - return outer_wrap(inner_wrap(...)) + elseif kind == 'wrap' then + local f = ev.wrap_fn + local new_outer = function(...) + return outer_wrap(f(...)) end + compile_event(ev.inner, new_outer, out, nack_cond) - table.insert(out, new_base_op(composed_wrap, ev.try_fn, ev.block_fn)) + else -- 'prim' + local final_wrap = function(...) + return outer_wrap(ev.wrap_fn(...)) + end + out[#out + 1] = { + try_fn = ev.try_fn, + block_fn = ev.block_fn, + wrap = final_wrap, + nack_cond = nack_cond, + } end return out end -local function resolve_choice_ops(base_ops) - local resolved = {} - for _, op in ipairs(base_ops) do - resolve_event(op, nil, resolved) +---------------------------------------------------------------------- +-- Nack triggering and non-blocking attempt +---------------------------------------------------------------------- + +local function trigger_nacks(ops, winner_index) + local winner_cond = winner_index and ops[winner_index].nack_cond or nil + local seen = {} + for _, op in ipairs(ops) do + local cond = op.nack_cond + if cond and cond ~= winner_cond and not seen[cond] then + seen[cond] = true + cond.signal() + end end - return resolved end --- Try once to commit on this event (BaseOp, ChoiceOp, or delayed event), --- non-blocking. Returns winner_op, retval_pack, ops | nil. -local function try_once(ev) - -- Normalise to a list of logical events. - local logicals = ev.base_ops and ev.base_ops or { ev } - - -- Expand guards / with_nack / nested choices into plain BaseOps. - local ops = resolve_choice_ops(logicals) +-- Try once to find a ready leaf in ops (random probe order). +-- Returns winner_index, retval_pack | nil. +local function try_ready(ops) local n = #ops - if n == 0 then return nil, nil, ops end - + if n == 0 then return nil end local base = math.random(n) for i = 1, n do - local op = ops[((i + base) % n) + 1] + local idx = ((i + base) % n) + 1 + local op = ops[idx] local retval = pack(op.try_fn()) - if retval[1] then return op, retval, ops end - end - return nil, nil, ops -end --- Trigger nack events for all *losing* with_nack arms in this choice. -local function trigger_nacks(ops, winner) - local winner_cond = winner and winner._nack_cond or nil - local seen = {} - for _, op in ipairs(ops) do - local cond = op._nack_cond - if cond and cond ~= winner_cond and not seen[cond] then - seen[cond] = true - cond.signal() + if retval[1] then + return idx, retval end end + return nil end --- Public CML-style poll: non-blocking synchronization attempt. --- Returns (true, ...results) if some arm commits, or (false) otherwise. -local function poll(ev) - local winner, retval, ops = try_once(ev) - if not winner then return false end - trigger_nacks(ops, winner) - return true, winner.wrap_fn(unpack(retval, 2, retval.n)) +-- Apply a leaf's wrap to its retval_pack. +local function apply_wrap(wrap, retval) + return wrap(unpack(retval, 2, retval.n)) end +---------------------------------------------------------------------- +-- Blocking choice path +---------------------------------------------------------------------- local function block_choice_op(sched, fib, ops) local suspension = new_suspension(sched, fib) for _, op in ipairs(ops) do - local choice_wrap = op.wrap_fn - op._choice_wrap = choice_wrap - op.block_fn(suspension, choice_wrap) + op.block_fn(suspension, op.wrap) end end ---- Perform the choice operation. --- @treturn vararg The value returned by the operation. -function ChoiceOp:perform() - -- Fast path: non-blocking attempt using the shared core. - local winner, retval, ops = try_once(self) - if winner then - trigger_nacks(ops, winner) - return winner.wrap_fn(unpack(retval, 2, retval.n)) +---------------------------------------------------------------------- +-- Event methods: perform & perform_alt +---------------------------------------------------------------------- + +-- Perform this event (primitive or composite), possibly blocking. +function Event:perform() + local ops = compile_event(self) + + -- Fast path: non-blocking attempt. + local idx, retval = try_ready(ops) + if idx then + trigger_nacks(ops, idx) + return apply_wrap(ops[idx].wrap, retval) end - -- Slow path: block on all resolved ops. + -- Slow path: block on all compiled leaves. local suspended = pack(fiber.suspend(block_choice_op, ops)) - local wrap = suspended[1] - local winner_after - for _, op in ipairs(ops) do - if op._choice_wrap == wrap then - winner_after = op + local wrap = suspended[1] + + -- Find the winning leaf by matching wrap function. + local winner_index + for i, op in ipairs(ops) do + if op.wrap == wrap then + winner_index = i break end end - trigger_nacks(ops, winner_after) + trigger_nacks(ops, winner_index) return wrap(unpack(suspended, 2, suspended.n)) end ---- Perform the base operation. --- We treat any single event (including guards/with_nack) as a 1-arm --- choice so it shares the exact same resolution / blocking path as --- ChoiceOp:perform. -function BaseOp:perform() - return new_choice_op({ self }):perform() +-- poll: non-blocking synchronization attempt. +-- Returns (true, ...results) if some arm commits, or (false) otherwise. +function Event:poll() + local ops = compile_event(self) + local idx, retval = try_ready(ops) + if not idx then return false end + trigger_nacks(ops, idx) + return true, apply_wrap(ops[idx].wrap, retval) end ---- Perform the choice operation or return the result of the function if the operation cannot be performed. --- @tparam function f The function. --- @treturn vararg The value returned by the operation or the function. -function ChoiceOp:perform_alt(f) - local ret = pack(poll(self)) - local ready = ret[1] - if ready then - return unpack(ret, 2, ret.n) +-- perform_alt: non-blocking; if no arm ready, call f(). +function Event:perform_alt(f) + local res = pack(self:poll()) + if res[1] then + return unpack(res, 2, res.n) end return f() end ---- Perform the base operation or return the result of the function if the operation cannot be performed. --- @tparam function f The function. --- @treturn vararg The value returned by the operation or the function. -function BaseOp:perform_alt(f) - return new_choice_op({ self }):perform_alt(f) -end +---------------------------------------------------------------------- +-- Public API +---------------------------------------------------------------------- return { - new_base_op = new_base_op, + new_base_op = new_base_op, -- primitive event constructor choice = choice, guard = guard, - new_cond = new_cond, with_nack = with_nack, - poll = poll, + new_cond = new_cond, } From 541698f75d236f6cef9989702ab3567bb37199e7 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Tue, 4 Nov 2025 01:40:36 +0000 Subject: [PATCH 7/9] slightly neatens trigger_nacks --- fibers/op.lua | 89 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 63 insertions(+), 26 deletions(-) diff --git a/fibers/op.lua b/fibers/op.lua index 7a637799..2f3a93bc 100644 --- a/fibers/op.lua +++ b/fibers/op.lua @@ -1,7 +1,11 @@ +-- (c) Snabb project +-- (c) Jangala + +-- Use of this source code is governed by the XXXXXXXXX license; see COPYING. --- fibers.op module -- Provides Concurrent ML style operations for managing concurrency. --- Events are CML-style: they can be primitive, choices, guards, with_nack, --- or wrapped. Synchronization compiles an event tree into primitive leaves. +-- Events are CML-style: primitive leaves, choices, guards, with_nack, +-- and wraps. Synchronization compiles an event tree into primitive leaves. local fiber = require 'fibers.fiber' @@ -79,8 +83,8 @@ local Event = {} Event.__index = Event -- Primitive event (leaf). --- try_fn() -> success:boolean, ... --- block_fn(suspension, wrap_fn) sets up async completion. +-- try_fn() -> success:boolean, ... +-- block_fn(suspension, wrap_fn) sets up async completion. local function new_base_op(wrap_fn, try_fn, block_fn) return setmetatable( { @@ -98,7 +102,9 @@ local function choice(...) local events = {} for _, ev in ipairs({ ... }) do if ev.kind == 'choice' then - for _, sub in ipairs(ev.events) do events[#events + 1] = sub end + for _, sub in ipairs(ev.events) do + events[#events + 1] = sub + end else events[#events + 1] = ev end @@ -113,6 +119,7 @@ local function guard(g) end -- with_nack g: delayed event; g(nack_ev) evaluated once per synchronization. +-- nack_ev is an Event that becomes ready iff this with_nack is *not* chosen. local function with_nack(g) return setmetatable({ kind = 'with_nack', builder = g }, Event) end @@ -129,6 +136,7 @@ end ---------------------------------------------------------------------- -- Simple one-shot condition primitive (used for with_nack; also exported) ---------------------------------------------------------------------- + local function new_cond() local state = { triggered = false, @@ -171,47 +179,56 @@ end -- Compile an event tree into primitive leaves -- -- A compiled leaf has: --- { try_fn, block_fn, wrap, nack_cond } --- where wrap(...) is the final post-processing function for this leaf. +-- { +-- try_fn, +-- block_fn, +-- wrap, -- final wrap function for this leaf +-- nacks = {...}, -- list of all active with_nack conds on this path +-- } ---------------------------------------------------------------------- -local function compile_event(ev, outer_wrap, out, nack_cond) +local function compile_event(ev, outer_wrap, out, nacks) out = out or {} outer_wrap = outer_wrap or id_wrap + nacks = nacks or {} - local kind = ev.kind + local kind = ev.kind or 'prim' if kind == 'choice' then for _, sub in ipairs(ev.events) do - compile_event(sub, outer_wrap, out, nack_cond) + compile_event(sub, outer_wrap, out, nacks) end elseif kind == 'guard' then local inner = ev.builder() - compile_event(inner, outer_wrap, out, nack_cond) + compile_event(inner, outer_wrap, out, nacks) elseif kind == 'with_nack' then local cond = new_cond() local nack_ev = cond.wait_op() -- Event local inner = ev.builder(nack_ev) - compile_event(inner, outer_wrap, out, cond) + -- Extend the current nack list for this subtree + local child_nacks = { unpack(nacks) } + child_nacks[#child_nacks + 1] = cond + + compile_event(inner, outer_wrap, out, child_nacks) elseif kind == 'wrap' then local f = ev.wrap_fn local new_outer = function(...) return outer_wrap(f(...)) end - compile_event(ev.inner, new_outer, out, nack_cond) + compile_event(ev.inner, new_outer, out, nacks) else -- 'prim' local final_wrap = function(...) return outer_wrap(ev.wrap_fn(...)) end out[#out + 1] = { - try_fn = ev.try_fn, - block_fn = ev.block_fn, - wrap = final_wrap, - nack_cond = nack_cond, + try_fn = ev.try_fn, + block_fn = ev.block_fn, + wrap = final_wrap, + nacks = nacks, } end @@ -222,14 +239,31 @@ end -- Nack triggering and non-blocking attempt ---------------------------------------------------------------------- +-- Signal all with_nack conds that belong exclusively to losing arms. local function trigger_nacks(ops, winner_index) - local winner_cond = winner_index and ops[winner_index].nack_cond or nil - local seen = {} - for _, op in ipairs(ops) do - local cond = op.nack_cond - if cond and cond ~= winner_cond and not seen[cond] then - seen[cond] = true - cond.signal() + -- Build a set of conds to *skip* (all on the winner path). + local winner = {} + if winner_index then + local wnacks = ops[winner_index].nacks + if wnacks then + for i = 1, #wnacks do + winner[wnacks[i]] = true + end + end + end + + -- Signal each losing cond once. + local signaled = {} + for i = 1, #ops do + local nacks = ops[i].nacks + if nacks then + for j = 1, #nacks do + local cond = nacks[j] + if cond and not winner[cond] and not signaled[cond] then + signaled[cond] = true + cond.signal() + end + end end end end @@ -250,6 +284,7 @@ local function try_ready(ops) end return nil end + -- Apply a leaf's wrap to its retval_pack. local function apply_wrap(wrap, retval) return wrap(unpack(retval, 2, retval.n)) @@ -258,6 +293,7 @@ end ---------------------------------------------------------------------- -- Blocking choice path ---------------------------------------------------------------------- + local function block_choice_op(sched, fib, ops) local suspension = new_suspension(sched, fib) for _, op in ipairs(ops) do @@ -266,7 +302,7 @@ local function block_choice_op(sched, fib, ops) end ---------------------------------------------------------------------- --- Event methods: perform & perform_alt +-- Event methods: perform, poll, perform_alt ---------------------------------------------------------------------- -- Perform this event (primitive or composite), possibly blocking. @@ -284,7 +320,7 @@ function Event:perform() local suspended = pack(fiber.suspend(block_choice_op, ops)) local wrap = suspended[1] - -- Find the winning leaf by matching wrap function. + -- Find the winning leaf by matching its wrap function. local winner_index for i, op in ipairs(ops) do if op.wrap == wrap then @@ -293,6 +329,7 @@ function Event:perform() end end trigger_nacks(ops, winner_index) + return wrap(unpack(suspended, 2, suspended.n)) end From 06c196cb71b93314a03c8afe71cfbac00ddfa4b5 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Tue, 4 Nov 2025 01:51:31 +0000 Subject: [PATCH 8/9] linter happiness --- fibers/op.lua | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/fibers/op.lua b/fibers/op.lua index 2f3a93bc..67e924da 100644 --- a/fibers/op.lua +++ b/fibers/op.lua @@ -1,7 +1,3 @@ --- (c) Snabb project --- (c) Jangala - --- Use of this source code is governed by the XXXXXXXXX license; see COPYING. --- fibers.op module -- Provides Concurrent ML style operations for managing concurrency. -- Events are CML-style: primitive leaves, choices, guards, with_nack, @@ -9,8 +5,10 @@ local fiber = require 'fibers.fiber' -local unpack = table.unpack or unpack -- luacheck: ignore -local pack = table.pack or function(...) return { n = select("#", ...), ... } end -- luacheck: ignore +local unpack = rawget(table, "unpack") or _G.unpack +local pack = rawget(table, "pack") or function(...) + return { n = select("#", ...), ... } +end local function id_wrap(...) return ... end @@ -192,7 +190,7 @@ local function compile_event(ev, outer_wrap, out, nacks) outer_wrap = outer_wrap or id_wrap nacks = nacks or {} - local kind = ev.kind or 'prim' + local kind = ev.kind if kind == 'choice' then for _, sub in ipairs(ev.events) do From 398afb5908e3e5544cbf9786ab69aa58c3b7b1a2 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Tue, 4 Nov 2025 02:07:36 +0000 Subject: [PATCH 9/9] fiendish new op tests --- tests/test_op.lua | 541 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 397 insertions(+), 144 deletions(-) diff --git a/tests/test_op.lua b/tests/test_op.lua index 146a9c11..3d5a03e8 100644 --- a/tests/test_op.lua +++ b/tests/test_op.lua @@ -1,5 +1,5 @@ ---- Tests the Op implementation. -print('testing: fibers.op') +-- fibers/op comprehensive test +print("testing: fibers.op (CML events)") -- look one level up package.path = "../?.lua;" .. package.path @@ -7,108 +7,19 @@ package.path = "../?.lua;" .. package.path local op = require 'fibers.op' local fiber = require 'fibers.fiber' +------------------------------------------------------------ +-- Helpers +------------------------------------------------------------ + +-- Ready primitive event that returns val. local function task(val) - local wrap_fn = function(x) return x end - local try_fn = function() return true, val end + local wrap_fn = function(x) return x end + local try_fn = function() return true, val end local block_fn = function() end return op.new_base_op(wrap_fn, try_fn, block_fn) end --- Test base op -fiber.spawn(function() - local baseOp = task(1) - assert(baseOp:perform() == 1, "Base operation failed") - fiber.stop() -end) -fiber.main() - --- Test choice op -fiber.spawn(function() - local choiceOp = op.choice(task(1), task(2), task(3)) - assert(choiceOp:perform() >= 1 and choiceOp:perform() <= 3, "Choice operation failed") - fiber.stop() -end) -fiber.main() - --- Test perform_alt -fiber.spawn(function() - local baseOp = task(1) - assert(baseOp:perform_alt(function() return 2 end) == 1, "perform_alt operation failed") - - local choiceOp = op.choice(task(1), task(2), task(3)) - assert(choiceOp:perform_alt(function() return 4 end) >= 1 and choiceOp:perform_alt(function() return 4 end) <= 3, - "Choice operation perform_alt failed") - fiber.stop() -end) -fiber.main() - ----------------------------------------------------------------- --- guard ----------------------------------------------------------------- - --- 1) Basic guard: behaves like g():perform(), builder called once per sync -fiber.spawn(function() - local calls = 0 - local g = function() - calls = calls + 1 - return task(42) - end - - local ev = op.guard(g) - local v = ev:perform() - assert(v == 42, "guard basic: wrong result") - assert(calls == 1, "guard basic: builder not called exactly once") - - fiber.stop() -end) -fiber.main() - --- 2) guard inside choice: participates fully, builder once per perform -fiber.spawn(function() - local calls = 0 - local g = function() - calls = calls + 1 - return task(10) - end - - local guarded = op.guard(g) - local choice = op.choice(guarded, task(20)) - - local runs = 5 - for _ = 1, runs do - local v = choice:perform() - assert(v == 10 or v == 20, - "guard in choice: result out of range") - end - - -- g() should have been called once per synchronization - assert(calls == runs, "guard in choice: builder call count mismatch") - - fiber.stop() -end) -fiber.main() - --- 3) guard + wrap: guard(g):wrap(f):perform() == f(g():perform()) -fiber.spawn(function() - local calls = 0 - local g = function() - calls = calls + 1 - return task(5) - end - - local ev = op.guard(g):wrap(function(x) - return x * 2 - end) - - local v = ev:perform() - assert(v == 10, "guard wrap: wrong result") - assert(calls == 1, "guard wrap: builder not called exactly once") - - fiber.stop() -end) -fiber.main() - --- helper: an op that always fails its try() so perform_alt fallback is taken +-- Primitive that never becomes ready (try=false, no block). local function never_op() local wrap_fn = function(x) return x end local try_fn = function() return false end @@ -116,67 +27,409 @@ local function never_op() return op.new_base_op(wrap_fn, try_fn, block_fn) end --- 4) guard:perform_alt uses inner event and fallback correctly -fiber.spawn(function() - local calls = 0 - local g = function() - calls = calls + 1 - return never_op() +-- Primitive that *forces* the blocking path then completes once. +local function async_task(val) + local tries = 0 + local function try_fn() + tries = tries + 1 + return false + end + local function block_fn(suspension, wrap_fn) + -- Complete on next scheduler turn. + local t = suspension:complete_task(wrap_fn, val) + suspension.sched:schedule(t) end + local ev = op.new_base_op(nil, try_fn, block_fn) + return ev, function() return tries end +end - local ev = op.guard(g) - local v = ev:perform_alt(function() return 99 end) +------------------------------------------------------------ +-- Run all tests inside a single top-level fiber +------------------------------------------------------------ - assert(v == 99, "guard perform_alt: wrong fallback result") - assert(calls == 1, "guard perform_alt: builder not called exactly once") +fiber.spawn(function() - fiber.stop() -end) -fiber.main() + -------------------------------------------------------- + -- 1) Base op: perform, perform_alt + -------------------------------------------------------- + do + local base = task(1) + assert(base:perform() == 1, "base: perform failed") --- 5) guard whose builder returns a ChoiceOp -fiber.spawn(function() - local calls = 0 - local g = function() - calls = calls + 1 - return op.choice(task(1), task(2)) - end + local base2 = task(2) + assert(base2:perform_alt(function() return 9 end) == 2, + "base: perform_alt should use event result") - local ev = op.guard(g) - local v = ev:perform() + local never = never_op() + assert(never:perform_alt(function() return 99 end) == 99, + "base: perform_alt should use fallback when not ready") + end - assert(v == 1 or v == 2, "guard returning choice: result out of range") - assert(calls == 1, "guard returning choice: builder not called exactly once") + -------------------------------------------------------- + -- 2) Blocking path: async_task + -------------------------------------------------------- + do + local ev, tries = async_task(42) + local v = ev:perform() + assert(v == 42, "async_task: wrong result") + assert(tries() == 1, "async_task: try_fn not called exactly once") + end - fiber.stop() -end) -fiber.main() + -------------------------------------------------------- + -- 3) Choice over multiple ready events + -- (we don't assume fairness, only that results are valid) + -------------------------------------------------------- + do + local choice_ev = op.choice(task(1), task(2), task(3)) + for _ = 1, 5 do + local v = choice_ev:perform() + assert(v == 1 or v == 2 or v == 3, + "choice(ready): result not in {1,2,3}") + end + end --- 6) Nested guards: guard(g1) where g1 returns guard(g2) -fiber.spawn(function() - local outer_calls = 0 - local inner_calls = 0 + -------------------------------------------------------- + -- 4) Wrap (including nested and on choice) + -------------------------------------------------------- + do + -- wrap on choice + local ev = op.choice(task(1), task(2)):wrap(function(x) + return x * 10 + end) + local v = ev:perform() + assert(v == 10 or v == 20, "wrap(choice): wrong result") + + -- nested wrap: ((x + 1) * 2) + local ev2 = task(5) + :wrap(function(x) return x + 1 end) + :wrap(function(y) return y * 2 end) + local v2 = ev2:perform() + assert(v2 == 12, "nested wrap: wrong result") + end - local inner_g = function() - inner_calls = inner_calls + 1 - return task(7) + -------------------------------------------------------- + -- 5) Guard: basic, in choice, nested, perform_alt + -------------------------------------------------------- + do + -- basic + local calls = 0 + local g = function() + calls = calls + 1 + return task(42) + end + local ev = op.guard(g) + local v = ev:perform() + assert(v == 42, "guard basic: wrong result") + assert(calls == 1, "guard basic: builder not called once") + + -- guard inside choice + local calls2 = 0 + local g2 = function() + calls2 = calls2 + 1 + return task(10) + end + local guarded = op.guard(g2) + local choice_ev = op.choice(guarded, task(20)) + local runs = 5 + for _ = 1, runs do + local r = choice_ev:perform() + assert(r == 10 or r == 20, + "guard in choice: result not in {10,20}") + end + assert(calls2 == runs, "guard in choice: builder call mismatch") + + -- guard + wrap + local calls3 = 0 + local g3 = function() + calls3 = calls3 + 1 + return task(5) + end + local ev3 = op.guard(g3):wrap(function(x) return x * 2 end) + local v3 = ev3:perform() + assert(v3 == 10, "guard wrap: wrong result") + assert(calls3 == 1, "guard wrap: builder not called once") + + -- guard + perform_alt (inner not ready) + local calls4 = 0 + local g4 = function() + calls4 = calls4 + 1 + return never_op() + end + local ev4 = op.guard(g4) + local v4 = ev4:perform_alt(function() return 99 end) + assert(v4 == 99, "guard perform_alt: wrong fallback") + assert(calls4 == 1, "guard perform_alt: builder not called once") + + -- nested guards + local outer_calls, inner_calls = 0, 0 + local inner_g = function() + inner_calls = inner_calls + 1 + return task(7) + end + local outer_g = function() + outer_calls = outer_calls + 1 + return op.guard(inner_g) + end + local ev5 = op.guard(outer_g):wrap(function(x) return x + 1 end) + local v5 = ev5:perform() + assert(v5 == 8, "nested guard: wrong result") + assert(outer_calls == 1, "nested guard: outer builder count") + assert(inner_calls == 1, "nested guard: inner builder count") end - local outer_g = function() - outer_calls = outer_calls + 1 - return op.guard(inner_g) + -------------------------------------------------------- + -- 6) poll() and perform_alt() on composite events + -------------------------------------------------------- + do + -- poll on ready event + local ok, v = task(123):poll() + assert(ok and v == 123, "poll(ready): wrong result") + + -- poll on never-ready + local ok2, v2 = never_op():poll() + assert(ok2 == false and v2 == nil, "poll(never): expected (false)") + + -- poll on composite none-ready + local comp = op.choice(never_op(), never_op()) + local ok3, v3 = comp:poll() + assert(ok3 == false and v3 == nil, "poll(composite none): expected (false)") + + -- perform_alt on composite (ready) + local comp_ready = op.choice(task(1), task(2)) + local r1 = comp_ready:perform_alt(function() return 99 end) + assert(r1 == 1 or r1 == 2, + "perform_alt(composite ready): wrong result") + + -- perform_alt on composite (none ready) + local comp_never = op.choice(never_op(), never_op()) + local r2 = comp_never:perform_alt(function() return 42 end) + assert(r2 == 42, "perform_alt(composite none): fallback not used") end - local ev = op.guard(outer_g):wrap(function(x) - return x + 1 - end) + -------------------------------------------------------- + -- 7) with_nack: winner vs loser vs poll, plus guard+with_nack + -------------------------------------------------------- + do + -- 7.1 with_nack branch wins: nack must NOT fire + do + local cancelled = false + local with_nack_ev = op.with_nack(function(nack_ev) + fiber.spawn(function() + nack_ev:perform() + cancelled = true + end) + return task("WIN") + end) + + -- opposing arm never ready + local ev = op.choice(with_nack_ev, never_op()) + local v = ev:perform() + assert(v == "WIN", "with_nack win: wrong winner") + + fiber.yield() + assert(cancelled == false, "with_nack win: nack fired unexpectedly") + end + + -- 7.2 with_nack branch loses: nack MUST fire + do + local cancelled = false + local with_nack_ev = op.with_nack(function(nack_ev) + fiber.spawn(function() + nack_ev:perform() + cancelled = true + end) + return never_op() + end) + + local ev = op.choice(with_nack_ev, task("OTHER")) + local v = ev:perform() + assert(v == "OTHER", "with_nack loss: wrong winner") + + fiber.yield() + assert(cancelled == true, "with_nack loss: nack did not fire") + end + + -- 7.3 with_nack + poll(): some arm ready → nack fires for loser + do + local cancelled = false + local with_nack_ev = op.with_nack(function(nack_ev) + fiber.spawn(function() + nack_ev:perform() + cancelled = true + end) + return never_op() + end) + + local ev = op.choice(with_nack_ev, task("WINNER")) + local ok, v = ev:poll() + assert(ok and v == "WINNER", "with_nack+poll: wrong result") + + fiber.yield() + assert(cancelled == true, "with_nack+poll: nack not fired") + end + + -- 7.4 with_nack + poll(): no arm ready → NO commit, NO nack + do + local cancelled = false + local with_nack_ev = op.with_nack(function(nack_ev) + fiber.spawn(function() + nack_ev:perform() + cancelled = true + end) + return never_op() + end) + + local ev = op.choice(with_nack_ev, never_op()) + local ok, v = ev:poll() + assert(ok == false and v == nil, + "with_nack+poll(no ready): expected no commit") + + fiber.yield() + assert(cancelled == false, + "with_nack+poll(no ready): nack fired unexpectedly") + end + + -- 7.5 guard + with_nack interaction + do + local guard_calls = 0 + local cancelled = false + + local guarded = op.guard(function() + guard_calls = guard_calls + 1 + return op.with_nack(function(nack_ev) + fiber.spawn(function() + nack_ev:perform() + cancelled = true + end) + return never_op() + end) + end) + + local ev = op.choice(guarded, task("OK")) + local v = ev:perform() + assert(v == "OK", "guard+with_nack: wrong winner") + + fiber.yield() + assert(cancelled == true, "guard+with_nack: nack not fired") + assert(guard_calls == 1, "guard+with_nack: builder not once") + end + end - local v = ev:perform() - assert(v == 8, "nested guard: wrong result") - assert(outer_calls == 1, "nested guard: outer builder call count mismatch") - assert(inner_calls == 1, "nested guard: inner builder call count mismatch") + -------------------------------------------------------- + -- 8) Nested with_nack trees + -------------------------------------------------------- + do + -- 8.1 winner has BOTH outer and inner with_nack: + -- neither outer nor inner nack should fire. + do + local outer_cancelled, inner_cancelled = false, false + + local outer = op.with_nack(function(outer_nack_ev) + -- watcher for outer nack + fiber.spawn(function() + outer_nack_ev:perform() + outer_cancelled = true + end) + + -- inner with_nack subtree + return op.with_nack(function(inner_nack_ev) + -- watcher for inner nack + fiber.spawn(function() + inner_nack_ev:perform() + inner_cancelled = true + end) + -- this leaf *wins*, so both nacks are on the winner path + return task("INNER_WIN") + end) + end) + + -- Only competitor is never_ready, so outer subtree wins. + local ev = op.choice(outer, never_op()) + local v = ev:perform() + assert(v == "INNER_WIN", + "nested with_nack (inner winner): wrong result") + + fiber.yield() + assert(outer_cancelled == false, + "nested with_nack (inner winner): outer nack fired unexpectedly") + assert(inner_cancelled == false, + "nested with_nack (inner winner): inner nack fired unexpectedly") + end + + -- 8.2 winner is in OUTER subtree but NOT in inner subtree: + -- outer nack must NOT fire, inner nack MUST fire. + do + local outer_cancelled, inner_cancelled = false, false + + local outer = op.with_nack(function(outer_nack_ev) + fiber.spawn(function() + outer_nack_ev:perform() + outer_cancelled = true + end) + + return op.choice( + -- This leaf wins: path has ONLY outer's nack. + task("OUTER_ONLY"), + -- This leaf loses: path has OUTER and INNER nacks. + op.with_nack(function(inner_nack_ev) + fiber.spawn(function() + inner_nack_ev:perform() + inner_cancelled = true + end) + return never_op() + end) + ) + end) + + local ev = op.choice(outer, never_op()) + local v = ev:perform() + assert(v == "OUTER_ONLY", + "nested with_nack (outer-only winner): wrong result") + + fiber.yield() + assert(outer_cancelled == false, + "nested with_nack (outer-only winner): outer nack fired unexpectedly") + assert(inner_cancelled == true, + "nested with_nack (outer-only winner): inner nack did not fire") + end + + -- 8.3 winner is OUTSIDE the outer with_nack subtree: + -- both outer and inner nacks MUST fire. + do + local outer_cancelled, inner_cancelled = false, false + + local outer = op.with_nack(function(outer_nack_ev) + fiber.spawn(function() + outer_nack_ev:perform() + outer_cancelled = true + end) + + -- Entire outer subtree never becomes ready. + return op.with_nack(function(inner_nack_ev) + fiber.spawn(function() + inner_nack_ev:perform() + inner_cancelled = true + end) + return never_op() + end) + end) + + -- Top-level winner is outside the outer subtree. + local ev = op.choice(outer, task("TOP_WIN")) + local v = ev:perform() + assert(v == "TOP_WIN", + "nested with_nack (outer loses): wrong winner") + + fiber.yield() + assert(outer_cancelled == true, + "nested with_nack (outer loses): outer nack did not fire") + assert(inner_cancelled == true, + "nested with_nack (outer loses): inner nack did not fire") + end + end + print("fibers.op tests: ok") fiber.stop() end) + fiber.main() -print('test: ok')