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 0592ac26..67e924da 100644 --- a/fibers/op.lua +++ b/fibers/op.lua @@ -1,32 +1,36 @@ --- (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 +-- 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' +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 +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 + +---------------------------------------------------------------------- +-- Suspensions and completion tasks +---------------------------------------------------------------------- + local Suspension = {} 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 @@ -37,7 +41,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() @@ -46,154 +50,314 @@ 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 = {} -BaseOp.__index = BaseOp +---------------------------------------------------------------------- +-- 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 } +---------------------------------------------------------------------- ---- 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. +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) - if wrap_fn == nil then wrap_fn = function(...) return ... end end return setmetatable( - { wrap_fn = wrap_fn, try_fn = try_fn, block_fn = block_fn }, - BaseOp) + { + kind = 'prim', + wrap_fn = wrap_fn or id_wrap, + try_fn = try_fn, + block_fn = block_fn, + }, + Event + ) +end + +-- Choice event: non-empty list of sub-events. +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 + else + events[#events + 1] = ev + end + end + if #events == 1 then return events[1] end + return setmetatable({ kind = 'choice', events = events }, Event) +end + +-- 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. +-- 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 ---- ChoiceOp class --- Represents a choice operation. --- @type ChoiceOp -local ChoiceOp = {} -ChoiceOp.__index = ChoiceOp -local function new_choice_op(base_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( - { base_ops = base_ops }, - ChoiceOp) + { kind = 'wrap', inner = self, wrap_fn = f }, + Event + ) end ---- Create a choice operation from the given operations. --- @tparam vararg ops The operations. --- @treturn ChoiceOp The created choice operation. -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 - else - table.insert(ops, op) +---------------------------------------------------------------------- +-- Simple one-shot condition primitive (used for with_nack; also exported) +---------------------------------------------------------------------- + +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 + suspension:complete(wrap_fn) + else + state.waiters[#state.waiters + 1] = 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 + 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 - if #ops == 1 then return ops[1] end - return new_choice_op(ops) + + return { + wait_op = wait_op, + signal = signal, + } 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 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) +---------------------------------------------------------------------- +-- Compile an event tree into primitive leaves +-- +-- A compiled leaf has: +-- { +-- 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, nacks) + out = out or {} + outer_wrap = outer_wrap or id_wrap + nacks = nacks or {} + + local kind = ev.kind + + if kind == 'choice' then + for _, sub in ipairs(ev.events) do + compile_event(sub, outer_wrap, out, nacks) + end + + elseif kind == 'guard' then + local inner = ev.builder() + 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) + -- 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, 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, + nacks = nacks, + } + end + + return out 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) +---------------------------------------------------------------------- +-- Nack triggering and non-blocking attempt +---------------------------------------------------------------------- + +-- Signal all with_nack conds that belong exclusively to losing arms. +local function trigger_nacks(ops, winner_index) + -- 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 -local function block_base_op(sched, fib, op) - op.block_fn(new_suspension(sched, fib), op.wrap_fn) +-- 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 end + local base = math.random(n) + for i = 1, n do + local idx = ((i + base) % n) + 1 + local op = ops[idx] + local retval = pack(op.try_fn()) + if retval[1] then + return idx, retval + end + end + return nil end ---- Perform the base operation. --- @treturn vararg The value returned by the operation. -function BaseOp:perform() - local retval = pack(self.try_fn()) - local success = table.remove(retval, 1) - if success then return self.wrap_fn(unpack(retval)) end - local new_retval = pack(fiber.suspend(block_base_op, self)) - local wrap = table.remove(new_retval, 1) - return wrap(unpack(new_retval)) +-- 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 op.block_fn(suspension, op.wrap_fn) end + for _, op in ipairs(ops) do + op.block_fn(suspension, op.wrap) + end 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] - local retval = pack(op.try_fn()) - local success = table.remove(retval, 1) - if success then return op.wrap_fn(unpack(retval)) end +---------------------------------------------------------------------- +-- Event methods: perform, poll, 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 - local retval = pack(fiber.suspend(block_choice_op, ops)) - local wrap = table.remove(retval, 1) - return wrap(unpack(retval)) + + -- Slow path: block on all compiled leaves. + local suspended = pack(fiber.suspend(block_choice_op, ops)) + local wrap = suspended[1] + + -- Find the winning leaf by matching its 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_index) + + 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) - local success, val = self.try_fn() - if success then return self.wrap_fn(val) end - return f() +-- 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 ops = self.base_ops - local base = math.random(#ops) - for i = 1, #ops do - local op = ops[((i + base) % #ops) + 1] - local success, val = op.try_fn() - if success then return op.wrap_fn(val) end +-- 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 +---------------------------------------------------------------------- +-- Public API +---------------------------------------------------------------------- + return { - new_base_op = new_base_op, - choice = choice + new_base_op = new_base_op, -- primitive event constructor + choice = choice, + guard = guard, + with_nack = with_nack, + 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 diff --git a/tests/test_op.lua b/tests/test_op.lua index b09f2601..3d5a03e8 100644 --- a/tests/test_op.lua +++ b/tests/test_op.lua @@ -1,45 +1,435 @@ ---- 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 -local op = require 'fibers.op' +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() +-- 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 + local block_fn = function() end + return op.new_base_op(wrap_fn, try_fn, block_fn) +end --- 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() +-- 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 + +------------------------------------------------------------ +-- Run all tests inside a single top-level fiber +------------------------------------------------------------ --- 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") + -------------------------------------------------------- + -- 1) Base op: perform, perform_alt + -------------------------------------------------------- + do + local base = task(1) + assert(base:perform() == 1, "base: perform failed") + + local base2 = task(2) + assert(base2:perform_alt(function() return 9 end) == 2, + "base: perform_alt should use event result") + + local never = never_op() + assert(never:perform_alt(function() return 99 end) == 99, + "base: perform_alt should use fallback when not ready") + end + + -------------------------------------------------------- + -- 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 + + -------------------------------------------------------- + -- 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 + + -------------------------------------------------------- + -- 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 + + -------------------------------------------------------- + -- 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 + + -------------------------------------------------------- + -- 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 + + -------------------------------------------------------- + -- 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 + + -------------------------------------------------------- + -- 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') +fiber.main()