From b847ab7ea65ddf6378cdd696625d91eb8fbb8c7b Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sun, 26 Oct 2025 18:10:00 +0000 Subject: [PATCH 001/138] after processing an event for fd, re-arm --- fibers/pollio.lua | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/fibers/pollio.lua b/fibers/pollio.lua index d78f97a..d569af0 100644 --- a/fibers/pollio.lua +++ b/fibers/pollio.lua @@ -100,13 +100,17 @@ function PollIOHandler:schedule_tasks(sched, _, timeout) if timeout >= 0 then timeout = timeout * 1e3 end for fd, event in pairs(self.epoll:poll(timeout)) do if bit.band(event, epoll.RD + epoll.ERR) ~= 0 then - local tasks = self.waiting_for_readable[fd] - schedule_tasks(sched, tasks) + schedule_tasks(sched, self.waiting_for_readable[fd]) + self.waiting_for_readable[fd] = nil end if bit.band(event, epoll.WR + epoll.ERR) ~= 0 then - local tasks = self.waiting_for_writable[fd] - schedule_tasks(sched, tasks) + schedule_tasks(sched, self.waiting_for_writable[fd]) + self.waiting_for_writable[fd] = nil end + local mask = 0 + if self.waiting_for_readable[fd] ~= nil then mask = bit.bor(mask, epoll.RD) end + if self.waiting_for_writable[fd] ~= nil then mask = bit.bor(mask, epoll.WR) end + if mask ~= 0 then self.epoll:add(fd, mask) end end end From c89d84d031b4c273320b0d6648eb7700a2647761 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sun, 26 Oct 2025 19:23:17 +0000 Subject: [PATCH 002/138] more consistent time usage --- fibers/sched.lua | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/fibers/sched.lua b/fibers/sched.lua index 46065fa..c132076 100644 --- a/fibers/sched.lua +++ b/fibers/sched.lua @@ -20,19 +20,30 @@ Scheduler.__index = Scheduler --- Creates a new Scheduler. -- @function new -- @return A new Scheduler. -local function new() +local function new(get_time) + -- get_time is an optional monotonic time source (defaults to sc.monotime) + local now_src = get_time or sc.monotime local ret = setmetatable( - { next = {}, cur = {}, sources = {}, wheel = timer.new(nil), maxsleep = MAX_SLEEP_TIME }, - Scheduler) + { + next = {}, -- runnable tasks (will run next turn) + cur = {}, -- tasks being run this turn + sources = {}, -- task sources (timer, poller, etc.) + wheel = timer.new(now_src()), -- timer wheel seeded from the same clock + maxsleep = MAX_SLEEP_TIME, -- upper bound on sleep between polls + get_time = now_src, -- single source of "current" monotonic time + event_waiter = nil -- (set by add_task_source when poller present) + }, + Scheduler + ) + + -- Timer task source: advances the wheel and schedules due tasks. local timer_task_source = { wheel = ret.wheel } - -- private method for timer_tast_source function timer_task_source:schedule_tasks(sched, now) self.wheel:advance(now, sched) end - - -- private method for timer_tast_source function timer_task_source:cancel_all_tasks() - -- Implement me! + -- No-op: advancing with 'now' drains due tasks; nothing to cancel explicitly. + -- (Keep for Scheduler:shutdown() symmetry.) end ret:add_task_source(timer_task_source) @@ -52,6 +63,11 @@ function Scheduler:schedule(task) table.insert(self.next, task) end +-- Helper for "current monotonic time" +function Scheduler:monotime() + return self.get_time() +end + --- Gets the current time from the timer wheel. -- @return Current time. function Scheduler:now() @@ -84,7 +100,7 @@ end -- If a specific time is provided, tasks scheduled for that time are run. -- @tparam number now (optional) The time to run tasks for. function Scheduler:run(now) - if now == nil then now = self:now() end + if now == nil then now = self:monotime() end self:schedule_tasks_from_sources(now) self.cur, self.next = self.next, self.cur for i = 1, #self.cur do @@ -103,7 +119,7 @@ end --- Waits for the next scheduled event. function Scheduler:wait_for_events() - local now, next_time = sc.monotime(), self:next_wake_time() + local now, next_time = self:monotime(), self:next_wake_time() local timeout = math.min(self.maxsleep, next_time - now) timeout = math.max(timeout, 0) if self.event_waiter then @@ -124,7 +140,7 @@ function Scheduler:main() self.done = false repeat self:wait_for_events() - self:run(sc.monotime()) + self:run(self:monotime()) until self.done end From 738fd1848367c774e77bd1af3c98b35c29011b9f Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Thu, 30 Oct 2025 20:46:05 +0000 Subject: [PATCH 003/138] rebuild popen on cmd/exec --- fibers/stream/compat.lua | 36 ++++++++++++++++++++++++++++++- fibers/stream/file.lua | 43 -------------------------------------- tests/test_stream-file.lua | 5 ++++- 3 files changed, 39 insertions(+), 45 deletions(-) diff --git a/fibers/stream/compat.lua b/fibers/stream/compat.lua index f42b588..b937db0 100644 --- a/fibers/stream/compat.lua +++ b/fibers/stream/compat.lua @@ -2,6 +2,7 @@ -- Shim to replace Lua's built-in IO module with streams. +local exec = require 'fibers.exec' local stream = require 'fibers.stream' local file = require 'fibers.stream.file' local sc = require 'fibers.utils.syscall' @@ -48,10 +49,43 @@ function io.output(new) io.current_output = new end + function io.popen(prog, mode) - return file.popen(prog, mode) + assert(type(prog) == 'string', "io.popen: prog must be a string") + mode = mode or 'r' + assert(mode == 'r' or mode == 'w', "io.popen: mode must be 'r' or 'w'") + + -- launch via /bin/sh -c (Lua-compatible semantics) + local cmd = exec.command('sh', '-c', prog) + + -- get a Stream for the appropriate end + local s = (mode == 'r') and cmd:stdout_pipe() or cmd:stdin_pipe() + + local err = cmd:start() + if err then + pcall(function() s:close() end) + return nil, "failed to start: " .. tostring(err) + end + + -- Override the *underlying IO* close, not the Stream close. + -- Stream:close() will call this and forward the return values. + local io_close = s.io.close + function s.io:close() + -- close our pipe end first + local _, _, _, _ = pcall(io_close, self) + -- wait for child using pidfd (handled inside exec:wait) + local status = cmd:wait() -- nil on success, non-zero exit code on failure + if status and status ~= 0 then + return nil, "exit", status + else + return true, "exit", 0 + end + end + + return s end + function io.read(...) return io.current_input:read(...) end diff --git a/fibers/stream/file.lua b/fibers/stream/file.lua index 2e79b91..e82e467 100644 --- a/fibers/stream/file.lua +++ b/fibers/stream/file.lua @@ -197,53 +197,10 @@ local function pipe() return fdopen(rd, sc.O_RDONLY), fdopen(wr, sc.O_WRONLY) end -local function popen(prog, mode) - assert(type(prog) == 'string') - assert(mode == 'r' or mode == 'w') - local parent_half, child_half - do - local rd, wr = assert(sc.pipe()) - if mode == 'r' then - parent_half, child_half = rd, wr - else - parent_half, child_half = wr, rd - end - end - local pid = assert(sc.fork()) - if pid == 0 then - sc.close(parent_half) - sc.dup2(child_half, mode == 'r' and 1 or 0) - sc.close(child_half) - sc.execve('/bin/sh', { "-c", prog }) - sc.write(2, "io.popen: Failed to exec /bin/sh!") - sc.exit(255) - end - sc.close(child_half) - local io = new_file_io(parent_half) - local close = io.close - function io:close() - if not pid then return end - close(self) - local ch_pid, status, code - repeat - ch_pid, status, code = sc.waitpid(pid, sc.WNOHANG) - -- some kind of sleep here, surely, if used in a fibers context - until (ch_pid and status ~= 'running') or (not ch_pid and code ~= sc.EINTR) - pid = nil - local retval1 = (status == "exited" and code == 0) or nil - local retval2 = status == "exited" and "exit" or "signal" - local retval3 = code - return retval1, retval2, retval3 - end - - return stream.open(io, mode == 'r', mode == 'w') -end - return { init_nonblocking = pollio.init_nonblocking, fdopen = fdopen, open = open, tmpfile = tmpfile, pipe = pipe, - popen = popen, } diff --git a/tests/test_stream-file.lua b/tests/test_stream-file.lua index 4539283..bdd9104 100644 --- a/tests/test_stream-file.lua +++ b/tests/test_stream-file.lua @@ -10,6 +10,9 @@ local op = require 'fibers.op' local sleep = require 'fibers.sleep' local channel = require 'fibers.channel' local file = require 'fibers.stream.file' +local compat = require 'fibers.stream.compat' + +compat.install() local function test() local rd, wr = file.pipe() @@ -23,7 +26,7 @@ local function test() assert(rd:read_some_chars() == nil) rd:close() - local subprocess = file.popen('echo "hello"; echo "world"', 'r') + local subprocess = assert(io.popen('echo "hello"; echo "world"', 'r')) local lines = {} while true do local line, err = subprocess:read_line() From 6373857d50ba9457a983ec73b89d84fa40499283 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Thu, 30 Oct 2025 20:53:39 +0000 Subject: [PATCH 004/138] add constants needed for future libs --- fibers/utils/syscall.lua | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/fibers/utils/syscall.lua b/fibers/utils/syscall.lua index 2d9fc30..8d02727 100644 --- a/fibers/utils/syscall.lua +++ b/fibers/utils/syscall.lua @@ -65,6 +65,12 @@ M.EWOULDBLOCK = p_errno.EWOULDBLOCK M.EINTR = p_errno.EINTR M.EINPROGRESS = p_errno.EINPROGRESS M.ESRCH = p_errno.ESRCH +M.EPIPE = p_errno.EPIPE +M.ETIMEDOUT = p_errno.ETIMEDOUT +M.ECONNRESET = p_errno.ECONNRESET +M.ECONNREFUSED = p_errno.ECONNREFUSED +M.ENETUNREACH = p_errno.ENETUNREACH +M.EHOSTUNREACH = p_errno.EHOSTUNREACH M.SIGKILL = p_signal.SIGKILL M.SIGTERM = p_signal.SIGTERM From 7e5c0529c37eb1090054c96a0e8cd860f1c5fef4 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Fri, 7 Nov 2025 21:01:25 +0000 Subject: [PATCH 005/138] `guard` and `with_nack` first-class CML events (#51) * adds guard + test * adds a basic condition variable in op, reuses it in cond and waitgroup * adds nack * neatens nack * streamlines op * complete reworking of op.lua * slightly neatens trigger_nacks * linter happiness * fiendish new op tests --- fibers/cond.lua | 53 ++---- fibers/op.lua | 394 +++++++++++++++++++++++++++----------- fibers/waitgroup.lua | 22 ++- tests/test_op.lua | 444 ++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 727 insertions(+), 186 deletions(-) diff --git a/fibers/cond.lua b/fibers/cond.lua index 06d4f7f..03fc4cc 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 0592ac2..67e924d 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 bec5bb2..c01ffb5 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 b09f260..3d5a03e 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() From 6437db038943789733550a8627719c01e2c504dc Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Fri, 7 Nov 2025 21:34:13 +0000 Subject: [PATCH 006/138] add bracket, reworked op and top-level perform --- README.md | 36 +- examples/1-basic-usage/4-choices.lua | 9 +- examples/1-basic-usage/5-choices-alt.lua | 9 +- examples/1-basic-usage/6-choices-adv.lua | 11 +- examples/1-basic-usage/context-examples.lua | 8 +- examples/2-lua-http/cq-http-fiber-test.lua | 17 +- fibers/alarm.lua | 8 +- fibers/channel.lua | 6 +- fibers/cond.lua | 4 +- fibers/exec.lua | 8 +- fibers/op.lua | 254 +++++++--- fibers/pollio.lua | 6 +- fibers/sleep.lua | 6 +- fibers/stream.lua | 20 +- fibers/stream/file.lua | 7 +- fibers/waitgroup.lua | 4 +- tests/test_context.lua | 7 +- tests/test_op.lua | 495 +++++++++----------- tests/test_stream-file.lua | 12 +- tests/test_waitgroup.lua | 24 +- 20 files changed, 534 insertions(+), 417 deletions(-) diff --git a/README.md b/README.md index bc1d231..99a6f5a 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,17 @@ local function fibonacci(c, quit) local x, y = 0, 1 local done = false repeat - op.choice( - c:put_op(x):wrap(function(value) - x, y = y, x+y - end), - quit:get_op():wrap(function(value) - print("quit") - done = true - end) - ):perform() + perform( + choice( + c:put_op(x):wrap(function(value) + x, y = y, x+y + end), + quit:get_op():wrap(function(value) + print("quit") + done = true + end) + ) + ) until done end @@ -35,7 +37,7 @@ end) fiber.main() ``` -Ported from the Snabb Project's [`fibers`](https://github.com/snabbco/snabb/tree/master/src/lib/fibers) and [`streams`](https://github.com/snabbco/snabb/tree/master/src/lib/stream) libraries, written by +Ported from the Snabb Project's [`fibers`](https://github.com/snabbco/snabb/tree/master/src/lib/fibers) and [`streams`](https://github.com/snabbco/snabb/tree/master/src/lib/stream) libraries, written by Andy Wingo as an implementation of Reppy et al's Concurrent ML(CML), and a fibers-based reimplementation of Lua's streams enabling smooth non-blocking access to files and sockets. Inspired by Andy's [blog post](https://wingolog.org/archives/2018/05/16/lightweight-concurrency-in-lua) introducing fibers on Lua @@ -55,7 +57,7 @@ with the following points to bear in mind: ## Installation This is a pure Lua (with FFI) module with the following dependencies: - - lua-posix (for micro/nano timing and sleeping options, for forking and + - lua-posix (for micro/nano timing and sleeping options, for forking and other syscall operations) - libffi and lua-cffi (if not using LuaJIT) - lua-bit32 (if not using LuaJIT) @@ -76,7 +78,7 @@ These dependencies will be installed in a VScode devcontainer automatically. To ### Installation with LuaJIT on OpenWRT -This is the simplest set up for running on OpenWRT. +This is the simplest set up for running on OpenWRT. `opkg update; opkg install luajit; opkg install luaposix` @@ -111,15 +113,15 @@ This library implements a simple version of Concurrent ML(CML), and provides pri ## Progress -All of the Snabb 'fibers' modules the following have so far been ported and -tested. All of Snabb's 'stream' module has also been ported, which can do -non-blocking reads and writes from file descriptors using familiar `line` -and `all` approaches. +All of the Snabb 'fibers' modules the following have so far been ported and +tested. All of Snabb's 'stream' module has also been ported, which can do +non-blocking reads and writes from file descriptors using familiar `line` +and `all` approaches. We use the `cffi` module to port Wingo's `luajit` C ffi based buffers implementation in a way that will work across multiple architectures, as Lua versions of these buffers would be inefficient and lead to allocation and -garbage collection without a substantial investment of time. +garbage collection without a substantial investment of time. ## Structured concurrency diff --git a/examples/1-basic-usage/4-choices.lua b/examples/1-basic-usage/4-choices.lua index cdad53e..909cf17 100644 --- a/examples/1-basic-usage/4-choices.lua +++ b/examples/1-basic-usage/4-choices.lua @@ -12,11 +12,13 @@ local fiber = require 'fibers.fiber' local channel = require 'fibers.channel' local op = require 'fibers.op' +local perform, choice = op.perform, op.choice + local function fibonacci(c, quit) local x, y = 0, 1 local done = false repeat - op.choice( + local task = choice( c:put_op(x):wrap(function() x, y = y, x+y end), @@ -24,7 +26,8 @@ local function fibonacci(c, quit) print("quit") done = true end) - ):perform() + ) + perform(task) until done end @@ -41,4 +44,4 @@ fiber.spawn(function() fiber.stop() end) -fiber.main() \ No newline at end of file +fiber.main() diff --git a/examples/1-basic-usage/5-choices-alt.lua b/examples/1-basic-usage/5-choices-alt.lua index d1e3b5d..64fa01f 100644 --- a/examples/1-basic-usage/5-choices-alt.lua +++ b/examples/1-basic-usage/5-choices-alt.lua @@ -11,6 +11,8 @@ local channel = require 'fibers.channel' local sleep = require 'fibers.sleep' local op = require 'fibers.op' +local perform, choice = op.perform, op.choice + -- time.After() is a Go library function local function after(t) local chan = channel.new() @@ -38,7 +40,7 @@ fiber.spawn(function() local boom = after(0.5) local done = false repeat - op.choice( + local task = choice( ticker:get_op():wrap(function() print("tick.") end), @@ -46,13 +48,14 @@ fiber.spawn(function() print("BOOM!") done = true end) - ):perform_alt(function() + ):or_else(function() print(" .") sleep.sleep(0.05) end) + perform(task) until done fiber.stop() end) -fiber.main() \ No newline at end of file +fiber.main() diff --git a/examples/1-basic-usage/6-choices-adv.lua b/examples/1-basic-usage/6-choices-adv.lua index 3aaffbd..2d5f50c 100644 --- a/examples/1-basic-usage/6-choices-adv.lua +++ b/examples/1-basic-usage/6-choices-adv.lua @@ -11,6 +11,8 @@ local cond = require 'fibers.cond' local op = require 'fibers.op' local sc = require 'fibers.utils.syscall' +local perform, choice = op.perform, op.choice + require("fibers.pollio").install_poll_io_handler() -- Set up the queues, channel and condition variable @@ -60,8 +62,8 @@ fiber.spawn(function() socket.socket(sc.AF_UNIX, sc.SOCK_STREAM, 0):listen_unix(sockname) local sock = socket.connect_unix(sockname) while true do - -- Use op.choice to handle multiple potentially blocking actions - op.choice( + -- Use choice to handle multiple potentially blocking actions + local task = choice( data_q:get_op():wrap(function(value) print("data received - writing to socket") sock:write(value .. "\n") @@ -79,8 +81,9 @@ fiber.spawn(function() sleep.sleep_op(0.5):wrap(function() print("yawn - nothing happening") end) - ):perform() + ) + perform(task) end end) -fiber.main() \ No newline at end of file +fiber.main() diff --git a/examples/1-basic-usage/context-examples.lua b/examples/1-basic-usage/context-examples.lua index a028eb4..b04bc1a 100644 --- a/examples/1-basic-usage/context-examples.lua +++ b/examples/1-basic-usage/context-examples.lua @@ -5,6 +5,8 @@ local context = require 'fibers.context' local sleep = require 'fibers.sleep' local op = require 'fibers.op' +local perform = op.perform + -- Simulated work function local function do_work(task_name, duration) print(task_name .. " started") @@ -20,8 +22,7 @@ local function sub_task_1(ctx) cancel("work_completed") -- Cancel the context (optional, as it will timeout) end) - local done_op = deadline_ctx:done_op() - op.choice(done_op):perform() -- Wait for the context to be done + perform(deadline_ctx:done_op()) -- Wait for the context to be done print("Sub-Task 1 status: " .. (deadline_ctx:err() or "completed")) end @@ -33,8 +34,7 @@ local function sub_task_2(ctx) cancel("work_completed") -- Cancel the context when work is done end) - local done_op = cancel_ctx:done_op() - op.choice(done_op):perform() -- Wait for the context to be done + perform(cancel_ctx:done_op()) -- Wait for the context to be done print("Sub-Task 2 status: " .. (cancel_ctx:err() or "completed")) end diff --git a/examples/2-lua-http/cq-http-fiber-test.lua b/examples/2-lua-http/cq-http-fiber-test.lua index 9bb843a..43a7012 100644 --- a/examples/2-lua-http/cq-http-fiber-test.lua +++ b/examples/2-lua-http/cq-http-fiber-test.lua @@ -13,6 +13,8 @@ local sleep = require "fibers.sleep" local cqueues = require "cqueues" local exec = require "fibers.exec" +local perform, choice = op.perform, op.choice + print("installing poll handler") pollio.install_poll_io_handler() @@ -32,14 +34,15 @@ local old_step; old_step = cqueues.interpose("step", function(self, timeout) local events = self:events() -- messy if events == 'r' then - pollio.fd_readable_op(self:pollfd()):perform() + perform(pollio.fd_readable_op(self:pollfd())) elseif events == 'w' then - pollio.fd_writable_op(self:pollfd()):perform() + perform(pollio.fd_writable_op(self:pollfd())) elseif events == 'rw' then - op.choice( - pollio.fd_readable_op(self:pollfd()), - pollio.fd_writable_op(self:pollfd()) - ):perform() + perform( + choice( + pollio.fd_readable_op(self:pollfd()), + pollio.fd_writable_op(self:pollfd()) + )) end return old_step(self, 0.0) end @@ -135,4 +138,4 @@ fiber.spawn(function() end) print("starting fibers") -fiber.main() \ No newline at end of file +fiber.main() diff --git a/fibers/alarm.lua b/fibers/alarm.lua index 7445c36..70590a3 100644 --- a/fibers/alarm.lua +++ b/fibers/alarm.lua @@ -7,6 +7,8 @@ local fiber = require 'fibers.fiber' local timer = require 'fibers.timer' local sc = require 'fibers.utils.syscall' +local perform = op.perform + local function days_in_year(y) return y % 4 == 0 and (y % 100 ~= 0 or y % 400 == 0) and 366 or 365 end @@ -240,7 +242,7 @@ end -- Wrapper for `absolute_op` that immediately performs the operation. -- @param t The absolute time (epoch) for the alarm. local function wait_absolute(t) - return wait_absolute_op(t):perform() + return perform(wait_absolute_op(t)) end --- Creates an operation for a next (relative) alarm. @@ -260,7 +262,7 @@ end -- @return An error if the time table is invalid. local function wait_next(t) local _, err = validate_next_table(t) - return err or assert(installed_alarm_handler):wait_next_op(t):perform() + return err or perform(assert(installed_alarm_handler):wait_next_op(t)) end -- Public API @@ -275,4 +277,4 @@ return { wait_next = wait_next, validate_next_table = validate_next_table, calculate_next = calculate_next -} \ No newline at end of file +} diff --git a/fibers/channel.lua b/fibers/channel.lua index 09cc181..4da9516 100644 --- a/fibers/channel.lua +++ b/fibers/channel.lua @@ -7,6 +7,8 @@ local op = require 'fibers.op' local fifo = require 'fibers.utils.fifo' +local perform = op.perform + --- Channel class -- Represents a communication channel between fibers. -- @type Channel @@ -115,7 +117,7 @@ end -- continue. Otherwise, block until a receiver becomes available. -- @tparam any message The message to put into the Channel. function Channel:put(message) - self:put_op(message):perform() + return perform(self:put_op(message)) end --- Get a message from the Channel. @@ -125,7 +127,7 @@ end -- available. -- @treturn any The message retrieved from the Channel. function Channel:get() - return self:get_op():perform() + return perform(self:get_op()) end --- @export diff --git a/fibers/cond.lua b/fibers/cond.lua index 03fc4cc..2c7583c 100644 --- a/fibers/cond.lua +++ b/fibers/cond.lua @@ -6,6 +6,8 @@ local op = require 'fibers.op' +local perform = op.perform + local Cond = {} Cond.__index = Cond @@ -24,7 +26,7 @@ end --- Put the fiber into a wait state on the condition variable. function Cond:wait() - return self:wait_op():perform() + return perform(self:wait_op()) end --- Wake up all fibers that are waiting on this condition variable. diff --git a/fibers/exec.lua b/fibers/exec.lua index e61a594..d327080 100644 --- a/fibers/exec.lua +++ b/fibers/exec.lua @@ -6,6 +6,8 @@ local op = require 'fibers.op' local buffer = require 'string.buffer' local sc = require 'fibers.utils.syscall' +local perform, choice = op.perform, op.choice + local io_mappings = { stdin = sc.STDIN_FILENO, stdout = sc.STDOUT_FILENO, @@ -106,7 +108,7 @@ function Cmd:_output_collector(pipes) ops[#ops + 1] = self.ctx:done_op():wrap(close_pipes) end - op.choice(unpack(ops)):perform() + perform(choice(unpack(ops))) end return buf:tostring(), self:wait() @@ -228,11 +230,11 @@ function Cmd:wait() if self.ctx then ops[#ops + 1] = self.ctx:done_op():wrap(function() self:kill() - pollio.fd_readable_op(self.process.pidfd):perform() + perform(pollio.fd_readable_op(self.process.pidfd)) end) end - op.choice(unpack(ops)):perform() + perform(choice(unpack(ops))) local _, _, status = sc.waitpid(self.process.pid) self.process.state = status sc.close(self.process.pidfd) diff --git a/fibers/op.lua b/fibers/op.lua index 67e924d..acd264d 100644 --- a/fibers/op.lua +++ b/fibers/op.lua @@ -1,7 +1,34 @@ --- fibers.op module -- Provides Concurrent ML style operations for managing concurrency. -- Events are CML-style: primitive leaves, choices, guards, with_nack, --- and wraps. Synchronization compiles an event tree into primitive leaves. +-- wraps, and an extra abort combinator (on_abort). +-- +-- Core event AST kinds: +-- prim : primitive leaf { try_fn, block_fn, wrap_fn } +-- choice : non-empty list of events +-- guard : delayed event builder (run once per sync) +-- with_nack : CML-style nack combinator +-- wrap : post-commit mapper (composed at compile time) +-- abort : attach abort handler to an event (run if this arm loses) +-- +-- Semantics sketch +-- ---------------- +-- We keep CML-style semantics for with_nack: +-- - with_nack g gets a nack event that becomes enabled iff the +-- *entire* resulting event loses in an enclosing choice. +-- - nested with_nack behaves correctly: outer nacks only fire when +-- the outer event loses, not when internal subchoices resolve. +-- +-- `on_abort(ev, f)` is implemented in terms of the same "nack" machinery: +-- - each abort scope behaves like a nack-cond whose signal() runs f(). +-- - after a choice commits, we figure out which conds are associated +-- exclusively with losing arms and signal those once. +-- +-- We also provide: +-- - bracket(acquire, release, use): RAII-style resource protocol. +-- - else_next_turn(ev, fallback_ev): biased choice; prefer ev, but +-- if it doesn't commit "by next turn", abort it cleanly and run +-- fallback_ev (in a separate sync). local fiber = require 'fibers.fiber' @@ -75,6 +102,7 @@ end -- kind = 'guard' : { builder = function() -> Event } -- kind = 'with_nack' : { builder = function(nack_ev) -> Event } -- kind = 'wrap' : { inner = Event, wrap_fn = f } +-- kind = 'abort' : { inner = Event, abort_fn = f } ---------------------------------------------------------------------- local Event = {} @@ -116,13 +144,50 @@ 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. +-- CML-style with_nack: builder gets a nack event that becomes ready +-- iff this event participates in a choice and *loses*. local function with_nack(g) return setmetatable({ kind = 'with_nack', builder = g }, Event) end --- Wrap event with a post-processing function f. +-- next_turn_op: primitive event that is never ready in the fast path; +-- if forced to block, it completes itself on the *next scheduler turn*. +local function next_turn_op() + local function try() + return false + end + + local function block(suspension, wrap_fn) + local task = suspension:complete_task(wrap_fn) + suspension.sched:schedule(task) + end + + return new_base_op(nil, try, block) +end + +local function always(value) + return new_base_op(nil, + function() return true, value end, + function() error("always: block_fn should never run") end) +end + +local function never() + -- An event that never becomes ready + return new_base_op(nil, + function() return false end, + function() end) +end + +function Event:or_else(fallback_thunk) + return choice( + self, + next_turn_op():wrap(function() + return fallback_thunk() + end) + ) +end + +-- Wrap event with a post-processing function f (commit phase). -- This is another node in the tree; composed at compile time. function Event:wrap(f) return setmetatable( @@ -131,17 +196,29 @@ function Event:wrap(f) ) end +-- Attach an abort handler to this event. +-- f() is run iff this event participates in a choice and *does not win*. +function Event:on_abort(f) + assert(type(f) == 'function', "on_abort expects a function") + return setmetatable( + { kind = 'abort', inner = self, abort_fn = f }, + Event + ) +end + ---------------------------------------------------------------------- -- Simple one-shot condition primitive (used for with_nack; also exported) ---------------------------------------------------------------------- -local function new_cond() +local function new_cond(opts) local state = { triggered = false, - waiters = {}, -- array of CompleteTask + waiters = {}, -- optional + abort_fn = opts and opts.abort_fn or nil, } local function wait_op() + assert(not state.abort_fn, "abort-only cond has no wait_op") local function try() return state.triggered end @@ -149,7 +226,8 @@ local function new_cond() if state.triggered then suspension:complete(wrap_fn) else - state.waiters[#state.waiters + 1] = suspension:complete_task(wrap_fn) + state.waiters[#state.waiters + 1] = + suspension:complete_task(wrap_fn) end end return new_base_op(nil, try, block) @@ -158,17 +236,25 @@ local function new_cond() local function signal() if state.triggered then return end state.triggered = true + -- wake waiters, if any 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 + if task + and task.suspension + and task.suspension:waiting() + then task.suspension.sched:schedule(task) end end + -- fire abort handler, if any + if state.abort_fn then + pcall(state.abort_fn) + end end return { - wait_op = wait_op, + wait_op = state.abort_fn and nil or wait_op, signal = signal, } end @@ -181,8 +267,13 @@ end -- try_fn, -- block_fn, -- wrap, -- final wrap function for this leaf --- nacks = {...}, -- list of all active with_nack conds on this path +-- nacks = {...}, -- list of all active nack/abort conds on this path -- } +-- +-- Semantics: +-- - Each with_nack or abort node adds a cond to the nacks list. +-- - After a winner leaf is chosen, we find which conds appear only +-- on losing paths and signal those once (CML-style nack). ---------------------------------------------------------------------- local function compile_event(ev, outer_wrap, out, nacks) @@ -203,12 +294,11 @@ local function compile_event(ev, outer_wrap, out, nacks) elseif kind == 'with_nack' then local cond = new_cond() - local nack_ev = cond.wait_op() -- Event + local nack_ev = cond.wait_op() 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 + local child_nacks = { unpack(nacks) } + child_nacks[#child_nacks + 1] = cond compile_event(inner, outer_wrap, out, child_nacks) elseif kind == 'wrap' then @@ -218,7 +308,14 @@ local function compile_event(ev, outer_wrap, out, nacks) end compile_event(ev.inner, new_outer, out, nacks) + elseif kind == 'abort' then + local cond = new_cond{ abort_fn = ev.abort_fn } + local child_nacks = { unpack(nacks) } + child_nacks[#child_nacks + 1] = cond + compile_event(ev.inner, outer_wrap, out, child_nacks) + else -- 'prim' + -- Each leaf gets a unique final_wrap closure so identity comparison in local final_wrap = function(...) return outer_wrap(ev.wrap_fn(...)) end @@ -237,29 +334,36 @@ end -- Nack triggering and non-blocking attempt ---------------------------------------------------------------------- --- Signal all with_nack conds that belong exclusively to losing arms. +-- Signal all conds that belong exclusively to losing arms. +-- This is the original CML-style logic: +-- - Build set of nacks on the winner path. +-- - For each loser leaf, signal any nacks not in the winner set. +-- - Each cond object is responsible for idempotence. 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 + assert(winner_index, "trigger_nacks: no winner_index (internal error)") + + local winner_set = {} + local wnacks = ops[winner_index].nacks + if wnacks then + for i = 1, #wnacks do + winner_set[wnacks[i]] = true 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() + if i ~= winner_index then + local nacks = ops[i].nacks + if nacks then + for j = #nacks, 1, -1 do + local cond = nacks[j] + if cond + and not winner_set[cond] + and not signaled[cond] + then + signaled[cond] = true + cond.signal() + end end end end @@ -300,54 +404,71 @@ local function block_choice_op(sched, fib, ops) end ---------------------------------------------------------------------- --- Event methods: perform, poll, perform_alt +-- Event methods: perform, perform_alt ---------------------------------------------------------------------- -- Perform this event (primitive or composite), possibly blocking. -function Event:perform() - local ops = compile_event(self) +local function perform(ev) + local leaves = compile_event(ev) - -- Fast path: non-blocking attempt. - local idx, retval = try_ready(ops) + -- Fast path + local idx, retval = try_ready(leaves) if idx then - trigger_nacks(ops, idx) - return apply_wrap(ops[idx].wrap, retval) + trigger_nacks(leaves, idx) + return apply_wrap(leaves[idx].wrap, retval) end - -- Slow path: block on all compiled leaves. - local suspended = pack(fiber.suspend(block_choice_op, ops)) + -- Slow path + local suspended = pack(fiber.suspend(block_choice_op, leaves)) 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 + for i, leaf in ipairs(leaves) do + if leaf.wrap == wrap then winner_index = i break end end - trigger_nacks(ops, winner_index) + trigger_nacks(leaves, winner_index) return wrap(unpack(suspended, 2, suspended.n)) end --- 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 +---------------------------------------------------------------------- +-- bracket : (acquire, release, use) -> 'a event +-- +-- acquire() : -> resource +-- release(resource, aborted:boolean) +-- use(resource) : -> Event (the main action) +-- +-- Semantics: +-- * acquire is run once, at sync time (inside a guard). +-- * if the resulting event `ev` WINS: +-- - its result is returned +-- - release(res, false) is called (best-effort, pcall) +-- * if the resulting event PARTICIPATES in a choice but LOSES: +-- - release(res, true) is called (via on_abort / nack machinery) +-- +-- This is *purely an Event combinator*; no new fiber is spawned. +---------------------------------------------------------------------- --- 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) +local function bracket(acquire, release, use) + return guard(function() + local res = acquire() + local ok, ev = pcall(use, res) + if not ok then + pcall(release, res, true) -- ensure cleanup on builder failure + error(ev) end - return f() + return ev + :wrap(function(...) + pcall(release, res, false) + return ... + end) + :on_abort(function() + pcall(release, res, true) + end) + end) end ---------------------------------------------------------------------- @@ -355,9 +476,14 @@ end ---------------------------------------------------------------------- return { - new_base_op = new_base_op, -- primitive event constructor - choice = choice, - guard = guard, - with_nack = with_nack, - new_cond = new_cond, + perform = perform, + new_base_op = new_base_op, -- primitive event constructor + choice = choice, + guard = guard, + with_nack = with_nack, + new_cond = new_cond, + bracket = bracket, + always = always, + never = never, + -- Event instances have methods: wrap, on_abort. } diff --git a/fibers/pollio.lua b/fibers/pollio.lua index d569af0..e8c2ddd 100644 --- a/fibers/pollio.lua +++ b/fibers/pollio.lua @@ -8,6 +8,8 @@ local epoll = require 'fibers.epoll' local sc = require 'fibers.utils.syscall' local bit = rawget(_G, "bit") or require 'bit32' +local perform = op.perform + local PollIOHandler = {} PollIOHandler.__index = PollIOHandler @@ -172,13 +174,13 @@ local function fd_readable_op(fd) return assert(installed_poll_handler):fd_readable_op(fd) end local function fd_readable(fd) - return fd_readable_op(fd):perform() + return perform(fd_readable_op(fd)) end local function fd_writable_op(fd) return assert(installed_poll_handler):fd_writable_op(fd) end local function fd_writable(fd) - return fd_writable_op(fd):perform() + return perform(fd_writable_op(fd)) end local function stream_readable_op(stream) return assert(installed_poll_handler):stream_readable_op(stream) diff --git a/fibers/sleep.lua b/fibers/sleep.lua index 808d9cb..788843e 100644 --- a/fibers/sleep.lua +++ b/fibers/sleep.lua @@ -7,6 +7,8 @@ local op = require 'fibers.op' local fiber = require 'fibers.fiber' +local perform = op.perform + --- Timeout class. -- Represents a timeout for a fiber. -- @type Timeout @@ -29,7 +31,7 @@ end --- Put the current fiber to sleep until time t. -- @tparam number t The time to sleep until. local function sleep_until(t) - return sleep_until_op(t):perform() + return perform(sleep_until_op(t)) end --- Create a new operation that puts the current fiber to sleep for a duration dt. @@ -46,7 +48,7 @@ end --- Put the current fiber to sleep for a duration dt. -- @tparam number dt The duration to sleep. local function sleep(dt) - return sleep_op(dt):perform() + return perform(sleep_op(dt)) end return { diff --git a/fibers/stream.lua b/fibers/stream.lua index e8639da..c8935a5 100644 --- a/fibers/stream.lua +++ b/fibers/stream.lua @@ -9,6 +9,8 @@ local op = require 'fibers.op' local fixed_buffer = require 'fibers.utils.fixed_buffer' local buffer = require 'string.buffer' +local perform = op.perform + local unpack = table.unpack or unpack -- luacheck: ignore -- Compatibility fallback local Stream = {} @@ -148,7 +150,7 @@ end -- @return number of bytes written -- @return error encountered during the write function Stream:write_chars(str) - return self:write_chars_op(str):perform() + return perform(self:write_chars_op(str)) end local function core_read_op(stream, buf, min, max, terminator) @@ -244,7 +246,7 @@ end -- @return string containing the characters read -- @return error during read, if any function Stream:read_chars(count) - return self:read_chars_op(count):perform() + return perform(self:read_chars_op(count)) end --- Operation to read up to a specified number of characters from the stream. @@ -263,7 +265,7 @@ end -- @return string containing the characters read -- @return error during read, if any function Stream:read_some_chars(count) - return self:read_some_chars_op(count):perform() + return perform(self:read_some_chars_op(count)) end --- Operation to read all characters from the stream. @@ -279,7 +281,7 @@ end -- @return string containing all characters read -- @return error during read, if any function Stream:read_all_chars() - return self:read_all_chars_op():perform() + return perform(self:read_all_chars_op()) end --- Operation to read a single character from the stream. @@ -295,7 +297,7 @@ end -- @return the character read, or nil if at end of file -- @return error during read, if any function Stream:read_char() - return self:read_char_op():perform() + return perform(self:read_char_op()) end --- Operation to read a line from the stream. @@ -316,7 +318,7 @@ end -- @return the line read, or nil if at end of file -- @return error during read, if any function Stream:read_line(style) - return self:read_line_op(style):perform() + return perform(self:read_line_op(style)) end function Stream:flush_input() @@ -335,7 +337,7 @@ end --- Flush the output buffer, writing all buffered data to the underlying IO. function Stream:flush_output() - return self:flush_output_op():perform() + return perform(self:flush_output_op()) end Stream.flush = Stream.flush_output @@ -397,7 +399,7 @@ end -- @return the data read from the stream according to the specified format, or nil on end of file -- @return error during read, if any function Stream:read(...) - return self:read_op(...):perform() + return perform(self:read_op(...)) end --- Get or set the file position. @@ -492,7 +494,7 @@ end -- @param ... data to write (strings or numbers) -- @return true on success, or nil plus an error message on failure function Stream:write(...) - return self:write_op(...):perform() + return perform(self:write_op(...)) end -- The result may be nil. diff --git a/fibers/stream/file.lua b/fibers/stream/file.lua index e82e467..40b3677 100644 --- a/fibers/stream/file.lua +++ b/fibers/stream/file.lua @@ -7,9 +7,12 @@ package.path = "../../?.lua;../?.lua;" .. package.path local stream = require 'fibers.stream' +local op = require 'fibers.op' local pollio = require 'fibers.pollio' local sc = require 'fibers.utils.syscall' +local perform = op.perform + local pio_handler = pollio.install_poll_io_handler() local bit = rawget(_G, "bit") or require 'bit32' @@ -88,11 +91,11 @@ end function File:wait_for_readable_op() return pollio.fd_readable_op(self.fd) end -function File:wait_for_readable() self:wait_for_readable_op():perform() end +function File:wait_for_readable() perform(self:wait_for_readable_op()) end function File:wait_for_writable_op() return pollio.fd_writable_op(self.fd) end -function File:wait_for_writable() self:wait_for_writable_op():perform() end +function File:wait_for_writable() perform(self:wait_for_writable_op()) end function File:task_on_readable(t) if self.fd then pio_handler:task_on_readable(self.fd, t) end end diff --git a/fibers/waitgroup.lua b/fibers/waitgroup.lua index c01ffb5..f0b1aa0 100644 --- a/fibers/waitgroup.lua +++ b/fibers/waitgroup.lua @@ -1,6 +1,8 @@ -- waitgroup.lua local op = require 'fibers.op' +local perform = op.perform + local Waitgroup = {} Waitgroup.__index = Waitgroup @@ -47,7 +49,7 @@ function Waitgroup:wait_op() end function Waitgroup:wait() - self:wait_op():perform() + return perform(self:wait_op()) end return { diff --git a/tests/test_context.lua b/tests/test_context.lua index 11ac773..ebcb61a 100644 --- a/tests/test_context.lua +++ b/tests/test_context.lua @@ -6,8 +6,11 @@ package.path = "../?.lua;" .. package.path local context = require 'fibers.context' local fiber = require 'fibers.fiber' +local op = require 'fibers.op' local sleep = require 'fibers.sleep' +local perform = op.perform + -- Test Background Context local function test_background() local ctx = context.background() @@ -23,7 +26,7 @@ local function test_with_cancel() local is_cancelled = false fiber.spawn(function() - child_ctx:done_op():perform() + perform(child_ctx:done_op()) is_cancelled = true end) @@ -54,7 +57,7 @@ local function test_with_timeout() local is_cancelled = false fiber.spawn(function() - ctx:done_op():perform() + perform(ctx:done_op()) is_cancelled = true end) diff --git a/tests/test_op.lua b/tests/test_op.lua index 3d5a03e..6d30eb6 100644 --- a/tests/test_op.lua +++ b/tests/test_op.lua @@ -1,32 +1,20 @@ --- fibers/op comprehensive test -print("testing: fibers.op (CML events)") +-- fibers/op compact but comprehensive test (no poll) +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 perform, choice = op.perform, op.choice +local always = op.always +local never = op.never + ------------------------------------------------------------ -- 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 block_fn = function() end - return op.new_base_op(wrap_fn, try_fn, block_fn) -end - --- 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 - -- Primitive that *forces* the blocking path then completes once. local function async_task(val) local tries = 0 @@ -35,7 +23,6 @@ local function async_task(val) 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 @@ -50,19 +37,44 @@ end fiber.spawn(function() -------------------------------------------------------- - -- 1) Base op: perform, perform_alt + -- 1) Base event: perform, or_else, wrap -------------------------------------------------------- do - local base = task(1) - assert(base:perform() == 1, "base: perform failed") + local base = always(1) + assert(perform(base) == 1, "base: perform failed") + + -- or_else: event wins, fallback ignored + local base2 = always(2) + local ev1 = base2:or_else(function() return 9 end) + local palt1 = perform(ev1) + assert(palt1 == 2, "base: or_else should use event result") + + -- or_else: never-ready event → fallback wins + local ev2 = never():or_else(function() return 99 end) + local palt2 = perform(ev2) + assert(palt2 == 99, "base: or_else should use fallback when event can't commit") - local base2 = task(2) - assert(base2:perform_alt(function() return 9 end) == 2, - "base: perform_alt should use event result") + -- or_else: async event should still win (gets a chance next turn) + do + local fallback_called = false + local ev_async, tries = async_task(123) + local ev = ev_async:or_else(function() + fallback_called = true + return -1 + end) + local r = perform(ev) + assert(r == 123, "or_else(async): expected main event to win") + assert(tries() == 1, "async_task: try_fn not called exactly once") + assert(fallback_called == false, + "or_else(async): fallback should not run when event commits") + end - local never = never_op() - assert(never:perform_alt(function() return 99 end) == 99, - "base: perform_alt should use fallback when not ready") + -- nested wrap: ((x + 1) * 2) + local ev3 = always(5) + :wrap(function(x) return x + 1 end) + :wrap(function(y) return y * 2 end) + local v3 = perform(ev3) + assert(v3 == 12, "nested wrap: wrong result") end -------------------------------------------------------- @@ -70,361 +82,292 @@ fiber.spawn(function() -------------------------------------------------------- do local ev, tries = async_task(42) - local v = ev:perform() + local v = perform(ev) 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) + -- 3) Choice + wrap -------------------------------------------------------- do - local choice_ev = op.choice(task(1), task(2), task(3)) + -- choice over multiple ready events + local choice_ev = choice(always(1), always(2), always(3)) for _ = 1, 5 do - local v = choice_ev:perform() + local v = perform(choice_ev) 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) + local ev = choice(always(1), always(2)):wrap(function(x) return x * 10 end) - local v = ev:perform() + local v = perform(ev) 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 + -- 4) Guard: basic + in choice + with with_nack -------------------------------------------------------- do - -- basic + -- basic guard local calls = 0 local g = function() calls = calls + 1 - return task(42) + return always(42) end local ev = op.guard(g) - local v = ev:perform() + local v = perform(ev) assert(v == 42, "guard basic: wrong result") assert(calls == 1, "guard basic: builder not called once") - -- guard inside choice + -- guard in choice (ensures builder runs each sync) local calls2 = 0 - local g2 = function() + local guarded = op.guard(function() calls2 = calls2 + 1 - return task(10) - end - local guarded = op.guard(g2) - local choice_ev = op.choice(guarded, task(20)) + return always(10) + end) + local choice_ev = choice(guarded, always(20)) local runs = 5 for _ = 1, runs do - local r = choice_ev:perform() + local r = perform(choice_ev) 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") + -- guard + with_nack (builder returns a with_nack event) + local guard_calls, cancelled = 0, false + local guarded_nack = op.guard(function() + guard_calls = guard_calls + 1 + return op.with_nack(function(nack_ev) + fiber.spawn(function() + perform(nack_ev) + cancelled = true + end) + return never() + end) + end) + + local ev2 = choice(guarded_nack, always("OK")) + local v2 = perform(ev2) + assert(v2 == "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 -------------------------------------------------------- - -- 6) poll() and perform_alt() on composite events + -- 5) or_else 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) + -- composite ready: choice(always, always) + local comp_ready = choice(always(1), always(2)) + local ev1 = comp_ready:or_else(function() return 99 end) + local r1 = perform(ev1) assert(r1 == 1 or r1 == 2, - "perform_alt(composite ready): wrong result") + "or_else(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") + -- composite never-ready: choice(never, never) → fallback + local comp_never = choice(never(), never()) + local ev2 = comp_never:or_else(function() return 42 end) + local r2 = perform(ev2) + assert(r2 == 42, "or_else(composite none): fallback not used") end -------------------------------------------------------- - -- 7) with_nack: winner vs loser vs poll, plus guard+with_nack + -- 6) with_nack: winner vs loser, basic nesting -------------------------------------------------------- do - -- 7.1 with_nack branch wins: nack must NOT fire + -- 6.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() + perform(nack_ev) cancelled = true end) - return task("WIN") + return always("WIN") end) - -- opposing arm never ready - local ev = op.choice(with_nack_ev, never_op()) - local v = ev:perform() + local ev = choice(with_nack_ev, never()) + local v = perform(ev) 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 + -- 6.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() + perform(nack_ev) cancelled = true end) - return never_op() + return never() end) - local ev = op.choice(with_nack_ev, task("OTHER")) - local v = ev:perform() + local ev = choice(with_nack_ev, always("OTHER")) + local v = perform(ev) 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. + -- 6.3 basic nested with_nack: + -- outer subtree wins via inner leaf → neither nack fires. 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() + perform(outer_nack_ev) 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() + perform(inner_nack_ev) inner_cancelled = true end) - -- this leaf *wins*, so both nacks are on the winner path - return task("INNER_WIN") + return always("INNER_WIN") end) end) - -- Only competitor is never_ready, so outer subtree wins. - local ev = op.choice(outer, never_op()) - local v = ev:perform() + local ev = choice(outer, never()) + local v = perform(ev) assert(v == "INNER_WIN", - "nested with_nack (inner winner): wrong result") + "nested with_nack: wrong result") fiber.yield() assert(outer_cancelled == false, - "nested with_nack (inner winner): outer nack fired unexpectedly") + "nested with_nack: outer nack fired unexpectedly") assert(inner_cancelled == false, - "nested with_nack (inner winner): inner nack fired unexpectedly") + "nested with_nack: inner nack fired unexpectedly") end + end - -- 8.2 winner is in OUTER subtree but NOT in inner subtree: - -- outer nack must NOT fire, inner nack MUST fire. + -------------------------------------------------------- + -- 7) bracket: RAII-style resource management over events + -------------------------------------------------------- + do + ---------------------------------------------------- + -- 7.1 basic success: inner event wins → aborted=false + ---------------------------------------------------- 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") + local acq_count = 0 + local rel_count = 0 + local use_count = 0 + local last_res, last_aborted + + local ev = op.bracket( + function() + acq_count = acq_count + 1 + return "RESOURCE" + end, + function(res, aborted) + rel_count = rel_count + 1 + last_res = res + last_aborted = aborted + end, + function(res) + use_count = use_count + 1 + assert(res == "RESOURCE", "bracket basic: wrong resource") + return always(99) + end + ) + + local v = perform(ev) + assert(v == 99, "bracket basic: wrong result") + + assert(acq_count == 1, "bracket basic: acquire not once") + assert(use_count == 1, "bracket basic: use not once") + assert(rel_count == 1, "bracket basic: release not once") + assert(last_res == "RESOURCE", "bracket basic: wrong res in release") + assert(last_aborted == false, + "bracket basic: aborted flag should be false on success") end - -- 8.3 winner is OUTSIDE the outer with_nack subtree: - -- both outer and inner nacks MUST fire. + ---------------------------------------------------- + -- 7.2 losing branch in choice → aborted=true + ---------------------------------------------------- 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) + local acq_count, use_count, rel_count = 0, 0, 0 + local last_aborted + + local bracket_ev = op.bracket( + function() + acq_count = acq_count + 1 + return "R" + end, + function(_, aborted) + rel_count = rel_count + 1 + last_aborted = aborted + end, + function(r) + use_count = use_count + 1 + assert(r == "R") + return never() + end + ) + + local ev = choice(bracket_ev, always("WIN")) + local v = perform(ev) + assert(v == "WIN", "bracket choice: wrong winner") + + assert(acq_count == 1, "bracket choice: acquire not once") + assert(use_count == 1, "bracket choice: use not once") + assert(rel_count == 1, "bracket choice: release not once") + assert(last_aborted == true, + "bracket choice: aborted flag should be true when losing") + 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) + ---------------------------------------------------- + -- 7.3 bracket + or_else: + -- bracket arm never commits → aborted=true, fallback used + ---------------------------------------------------- + do + local acq_count, use_count, rel_count = 0, 0, 0 + local last_aborted, fallback_called + + local bracket_ev = op.bracket( + function() + acq_count = acq_count + 1 + return "R" + end, + function(_, aborted) + rel_count = rel_count + 1 + last_aborted = aborted + end, + function(r) + use_count = use_count + 1 + assert(r == "R") + return never() + end + ) + + local ev = bracket_ev:or_else(function() + fallback_called = true + return "FALLBACK" 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") + local res = perform(ev) - 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") + assert(res == "FALLBACK", + "bracket+or_else: expected fallback result") + assert(fallback_called == true, + "bracket+or_else: fallback thunk not called") + + assert(acq_count == 1, "bracket+or_else: acquire once") + assert(use_count == 1, "bracket+or_else: use once") + assert(rel_count == 1, "bracket+or_else: release once") + assert(last_aborted == true, + "bracket+or_else: aborted flag should be true when losing") end end diff --git a/tests/test_stream-file.lua b/tests/test_stream-file.lua index bdd9104..09bb04f 100644 --- a/tests/test_stream-file.lua +++ b/tests/test_stream-file.lua @@ -14,6 +14,8 @@ local compat = require 'fibers.stream.compat' compat.install() +local perform, choice = op.perform, op.choice + local function test() local rd, wr = file.pipe() local message = "hello, world\n" @@ -77,10 +79,11 @@ local function test_read_op() wg:done() end) - local chars, err = op.choice( + local task = choice( rd:read_all_chars_op(), sleep.sleep_op(0.01):wrap(function () return nil, 'timeout' end) - ):perform() + ) + local chars, err = perform(task) assert(not chars and err == 'timeout') @@ -118,10 +121,11 @@ local function test_write_op() wg:done() end) - local written, err = op.choice( + local task = choice( wr:write_chars_op(msg..msg2), sleep.sleep_op(0.01):wrap(function () return nil, 'timeout' end) - ):perform() + ) + local written, err = perform(task) assert(not written and err=='timeout') diff --git a/tests/test_waitgroup.lua b/tests/test_waitgroup.lua index 1b87a10..007fcfe 100644 --- a/tests/test_waitgroup.lua +++ b/tests/test_waitgroup.lua @@ -11,11 +11,14 @@ local op = require 'fibers.op' local waitgroup = require 'fibers.waitgroup' local sc = require 'fibers.utils.syscall' +local perform, choice = op.perform, op.choice + local function test_nowait() local wg = waitgroup.new() - wg:wait_op():perform_alt(function() + local task = wg:wait_op():or_else(function() error("blocked on empty waitgroup") end) + perform(task) print("No wait test: ok") end @@ -32,9 +35,12 @@ local function test_simple() end) end - wg:wait_op():wrap(function() - error("waitgroup didn't block when it should have") - end):perform_alt(function() end) + perform( + wg:wait_op():wrap(function() + error("waitgroup didn't block when it should have") + end) + :or_else(function() end) + ) wg:wait() print("Simple test: ok") @@ -68,10 +74,12 @@ local function test_complex() end while not done do - op.choice( - wg:wait_op():wrap(function() done = true end), - sleep.sleep_op(0.9):wrap(extra_work) - ):perform() + perform( + choice( + wg:wait_op():wrap(function() done = true end), + sleep.sleep_op(0.9):wrap(extra_work) + ) + ) end assert(sc.monotime() - start > 1.5) From 64ded936025eba00bf4fa04d7aedf0bb0a9f3853 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sun, 9 Nov 2025 13:59:02 +0000 Subject: [PATCH 007/138] working wrap_handler + finally --- fibers/op.lua | 172 +++++++++++++++++++++++++++++++++++----------- tests/test_op.lua | 139 +++++++++++++++++++++++++++++++++++++ 2 files changed, 271 insertions(+), 40 deletions(-) diff --git a/fibers/op.lua b/fibers/op.lua index acd264d..fa20ded 100644 --- a/fibers/op.lua +++ b/fibers/op.lua @@ -4,12 +4,13 @@ -- wraps, and an extra abort combinator (on_abort). -- -- Core event AST kinds: --- prim : primitive leaf { try_fn, block_fn, wrap_fn } --- choice : non-empty list of events --- guard : delayed event builder (run once per sync) --- with_nack : CML-style nack combinator --- wrap : post-commit mapper (composed at compile time) --- abort : attach abort handler to an event (run if this arm loses) +-- prim : primitive leaf { try_fn, block_fn, wrap_fn } +-- choice : non-empty list of events +-- guard : delayed event builder (run once per sync) +-- with_nack : CML-style nack combinator +-- wrap : post-commit mapper (composed at compile time) +-- abort : attach abort handler to an event (run if this arm loses) +-- wrap_handler : CML-style exception handler (see wrap_handler below) -- -- Semantics sketch -- ---------------- @@ -26,6 +27,8 @@ -- -- We also provide: -- - bracket(acquire, release, use): RAII-style resource protocol. +-- - wrap_handler(ev, h): CML-style wrapHandler (exn -> event). +-- - finally(ev, cleanup): derived "always run cleanup" combinator. -- - else_next_turn(ev, fallback_ev): biased choice; prefer ev, but -- if it doesn't commit "by next turn", abort it cleanly and run -- fallback_ev (in a separate sync). @@ -97,17 +100,21 @@ end ---------------------------------------------------------------------- -- 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 } --- kind = 'abort' : { inner = Event, abort_fn = f } +-- 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 } +-- kind = 'abort' : { inner = Event, abort_fn = f } +-- kind = 'wrap_handler' : { inner = Event, handler = function(ex) -> Event } ---------------------------------------------------------------------- local Event = {} Event.__index = Event +-- forward declaration so compile_event can call perform +local perform + -- Primitive event (leaf). -- try_fn() -> success:boolean, ... -- block_fn(suspension, wrap_fn) sets up async completion. @@ -206,6 +213,16 @@ function Event:on_abort(f) ) end +-- Attach an exception handler for post-synchronisation +-- actions. h(ex) must return a replacement event to synchronise on. +function Event:wrap_handler(handler) + assert(type(handler) == 'function', "wrap_handler expects a function") + return setmetatable( + { kind = 'wrap_handler', inner = self, handler = handler }, + Event + ) +end + ---------------------------------------------------------------------- -- Simple one-shot condition primitive (used for with_nack; also exported) ---------------------------------------------------------------------- @@ -272,25 +289,28 @@ end -- -- Semantics: -- - Each with_nack or abort node adds a cond to the nacks list. --- - After a winner leaf is chosen, we find which conds appear only --- on losing paths and signal those once (CML-style nack). +-- - Each wrap_handler node adds a handler to the handlers list. +-- - At the leaf, we build a final wrap function that: +-- * runs the normal wrap chain +-- * then applies the wrap_handler chain (innermost first) using pcall. ---------------------------------------------------------------------- -local function compile_event(ev, outer_wrap, out, nacks) +local function compile_event(ev, outer_wrap, out, nacks, handlers) out = out or {} outer_wrap = outer_wrap or id_wrap nacks = nacks or {} + handlers = handlers or {} local kind = ev.kind if kind == 'choice' then for _, sub in ipairs(ev.events) do - compile_event(sub, outer_wrap, out, nacks) + compile_event(sub, outer_wrap, out, nacks, handlers) end elseif kind == 'guard' then local inner = ev.builder() - compile_event(inner, outer_wrap, out, nacks) + compile_event(inner, outer_wrap, out, nacks, handlers) elseif kind == 'with_nack' then local cond = new_cond() @@ -299,26 +319,58 @@ local function compile_event(ev, outer_wrap, out, nacks) local child_nacks = { unpack(nacks) } child_nacks[#child_nacks + 1] = cond - compile_event(inner, outer_wrap, out, child_nacks) + compile_event(inner, outer_wrap, out, child_nacks, handlers) 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) + compile_event(ev.inner, new_outer, out, nacks, handlers) elseif kind == 'abort' then local cond = new_cond{ abort_fn = ev.abort_fn } local child_nacks = { unpack(nacks) } child_nacks[#child_nacks + 1] = cond - compile_event(ev.inner, outer_wrap, out, child_nacks) + compile_event(ev.inner, outer_wrap, out, child_nacks, handlers) + + elseif kind == 'wrap_handler' then + -- Accumulate handlers; innermost handler should see the exception first. + local child_handlers = { unpack(handlers) } + child_handlers[#child_handlers + 1] = ev.handler + compile_event(ev.inner, outer_wrap, out, nacks, child_handlers) else -- 'prim' - -- Each leaf gets a unique final_wrap closure so identity comparison in - local final_wrap = function(...) + -- Core wrap chain for this leaf (no exception handling yet). + local function core(...) return outer_wrap(ev.wrap_fn(...)) end + + -- If there are handlers, wrap the core in them, innermost first. + local wrapped = core + if #handlers > 0 then + for i = #handlers, 1, -1 do + local h = handlers[i] + local prev = wrapped + wrapped = function(...) + local res = pack(pcall(prev, ...)) + local ok = res[1] + if ok then + return unpack(res, 2, res.n) + end + -- res[2] is the exception (CML: pass exn to handler) + local ex = res[2] + local hev = h(ex) + -- Handler returns an event; synchronise on it. + return perform(hev) + end + end + end + + local final_wrap = function(...) + return wrapped(...) + end + out[#out + 1] = { try_fn = ev.try_fn, block_fn = ev.block_fn, @@ -404,11 +456,11 @@ local function block_choice_op(sched, fib, ops) end ---------------------------------------------------------------------- --- Event methods: perform, perform_alt +-- Event methods: perform ---------------------------------------------------------------------- -- Perform this event (primitive or composite), possibly blocking. -local function perform(ev) +perform = function(ev) local leaves = compile_event(ev) -- Fast path @@ -434,6 +486,42 @@ local function perform(ev) return wrap(unpack(suspended, 2, suspended.n)) end +---------------------------------------------------------------------- +-- finally : (ev, cleanup) -> ev' +-- +-- cleanup(aborted:boolean, exn:any|nil) +-- +-- Semantics: +-- * on normal post-sync completion: +-- cleanup(false, nil) is called (best-effort, protected). +-- * if a post-sync action raises: +-- cleanup(true, exn) is called (best-effort) and the same +-- exception is re-raised. +-- * Exceptions from guard/with_nack builders are not intercepted. +---------------------------------------------------------------------- + +function Event:finally(cleanup) + -- Success path: only runs if the entire event (including any inner + -- wrap_handler handlers) completes without raising. + local function success_wrap(...) + pcall(cleanup, false, nil) + return ... + end + + local function handler(ex) + -- Error path: run cleanup(true, ex) and rethrow. + return always(true):wrap(function() + pcall(cleanup, true, ex) + error(ex) + end) + end + + -- Important: wrap_handler on ev, then a wrap on top. + -- This ensures success_wrap runs only on the success path, + -- and handler is responsible for the error path. + return self:wrap_handler(handler):wrap(success_wrap) +end + ---------------------------------------------------------------------- -- bracket : (acquire, release, use) -> 'a event -- @@ -453,22 +541,24 @@ end ---------------------------------------------------------------------- local function bracket(acquire, release, use) - return guard(function() - local res = acquire() - local ok, ev = pcall(use, res) - if not ok then - pcall(release, res, true) -- ensure cleanup on builder failure - error(ev) - end - return ev - :wrap(function(...) - pcall(release, res, false) - return ... - end) - :on_abort(function() - pcall(release, res, true) - end) - end) + return guard(function() + local res = acquire() + + local ok, ev = pcall(use, res) + if not ok then + local ex = ev + pcall(release, res, true) + error(ex) + end + + local wrapped = ev:finally(function(aborted, _) + pcall(release, res, aborted) + end) + + return wrapped:on_abort(function() + pcall(release, res, true) + end) + end) end ---------------------------------------------------------------------- @@ -485,5 +575,7 @@ return { bracket = bracket, always = always, never = never, + -- wrap_handler = wrap_handler, + -- finally = finally, -- Event instances have methods: wrap, on_abort. } diff --git a/tests/test_op.lua b/tests/test_op.lua index 6d30eb6..eb54407 100644 --- a/tests/test_op.lua +++ b/tests/test_op.lua @@ -371,6 +371,145 @@ fiber.spawn(function() end end + -------------------------------------------------------- + -- 8) wrap_handler: exception in post-sync, plus pre-sync error + -------------------------------------------------------- + do + -- 8.1 error in a post-synchronisation wrap is caught and + -- mapped to a recovery event. + do + local handler_called = false + + local base = always(10) + + local ev = base + :wrap_handler(function(ex) + handler_called = true + assert(tostring(ex):match("boom"), + "wrap_handler: unexpected exception value") + return always("recovered") + end) + :wrap(function() + -- post-sync action that fails + error("boom") + end) + + local r = perform(ev) + assert(r == "recovered", + "wrap_handler: expected recovery result") + assert(handler_called, + "wrap_handler: handler was not invoked") + end + + -- 8.2 guard builder error is *not* caught by wrap_handler + do + local g_ev = op.guard(function() + error("builder-fail") + end) + + local handled = g_ev:wrap_handler(function(_) + return always("ignored") + end) + + local ok, err = pcall(function() + perform(handled) + end) + assert(not ok, "wrap_handler: should not catch guard builder errors") + assert(tostring(err):match("builder%-fail"), + "wrap_handler: wrong error propagated for guard builder") + end + + -- 8.3 wrap_handler: nesting order (innermost first) + do + local log = {} + + -- Base event whose post-sync action fails. + local base = always(1):wrap(function() + error("boom-inner") + end) + + -- Inner handler: sees the original exception and rethrows via a new event. + local ev_inner = base:wrap_handler(function(ex) + table.insert(log, "inner:" .. tostring(ex)) + -- Rethrow as a different error so the outer handler can distinguish it. + return always(true):wrap(function() + error("inner-rethrow") + end) + end) + + -- Outer handler: should see the *rethrown* exception, not the original. + local ev = ev_inner:wrap_handler(function(ex) + table.insert(log, "outer:" .. tostring(ex)) + return always("ok") + end) + + local res = perform(ev) + + assert(res == "ok", + "wrap_handler nesting: final result mismatch") + assert(#log == 2, + "wrap_handler nesting: expected two handlers invoked") + + -- Innermost handler must see the original error first. + assert(log[1]:match("^inner:.*boom%-inner"), + "wrap_handler nesting: inner handler did not see original exception first") + + -- Outermost handler must see the rethrown error. + assert(log[2]:match("^outer:.*inner%-rethrow"), + "wrap_handler nesting: outer handler did not see rethrown exception") + end + end + + + -------------------------------------------------------- + -- 9) finally: cleanup on success and on failure + -------------------------------------------------------- + do + -- 9.1 success path: cleanup(false, nil) once, result propagated + do + local calls = {} + local base = always(7) + + local ev = base:finally(function(aborted, exn) + calls[#calls + 1] = { aborted = aborted, exn = exn } + end) + + local r = perform(ev) + assert(r == 7, "finally(success): wrong result") + assert(#calls == 1, "finally(success): cleanup not called once") + assert(calls[1].aborted == false, + "finally(success): aborted should be false") + assert(calls[1].exn == nil, + "finally(success): exn should be nil") + end + + -- 9.2 failure in post-sync action: cleanup(true, exn), then rethrow + do + local calls = {} + + local base = always(1):wrap(function(_) + -- simulate user post-sync failure + error("post-sync-fail") + end) + + local ev = base:finally(function(aborted, exn) + calls[#calls + 1] = { aborted = aborted, exn = exn } + end) + + local ok, err = pcall(function() + perform(ev) + end) + assert(not ok, "finally(error): expected re-raise of exception") + assert(tostring(err):match("post%-sync%-fail"), + "finally(error): wrong exception propagated") + + assert(#calls == 1, "finally(error): cleanup not called once") + assert(calls[1].aborted == true, + "finally(error): aborted should be true") + assert(calls[1].exn ~= nil, + "finally(error): exn should be non-nil") + end + end print("fibers.op tests: ok") fiber.stop() end) From ae25540cc234ae3f21d4daad65d1d15b09beb1ce Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sun, 9 Nov 2025 14:20:57 +0000 Subject: [PATCH 008/138] stage 1: extract fibers.runtime and make op depend on it --- fibers/fiber.lua | 164 ++++----------------------------------------- fibers/op.lua | 4 +- fibers/runtime.lua | 124 ++++++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 154 deletions(-) create mode 100644 fibers/runtime.lua diff --git a/fibers/fiber.lua b/fibers/fiber.lua index a3c7d94..2fe511f 100644 --- a/fibers/fiber.lua +++ b/fibers/fiber.lua @@ -1,157 +1,17 @@ --- (c) Snabb project --- (c) Jangala - --- Use of this source code is governed by the XXXXXXXXX license; see COPYING. - ---- Fiber module. --- Implements a fiber system using Lua's coroutines for cooperative multitasking. +-- fibers/fiber.lua +--- +-- Compatibility wrapper around fibers.runtime. +-- Existing code can continue to require 'fibers.fiber'. -- @module fibers.fiber --- Required packages -local sched = require 'fibers.sched' - -local current_fiber -local current_scheduler = sched.new() - ---- The Fiber class --- Represents a single fiber, or lightweight thread. --- @type Fiber -local Fiber = {} -Fiber.__index = Fiber - ---- Spawns a new fiber. --- @function spawn --- @tparam function fn The function to run in the new fiber. -local function spawn(fn) - -- Capture the traceback - local tb = debug.traceback("", 2):match("\n[^\n]*\n(.*)") or "" - -- If we're inside another fiber, append the traceback to the parent's traceback - if current_fiber and current_fiber.traceback then - tb = tb .. "\n" .. current_fiber.traceback - end - - current_scheduler:schedule( - setmetatable({ - coroutine = coroutine.create(fn), - alive = true, - sockets = {}, - traceback = tb - }, Fiber)) -end - ---- Resumes execution of the fiber. --- If the fiber is already dead, this will throw an error. --- @tparam vararg ... The arguments to pass to the fiber. -function Fiber:resume(wrap, ...) - assert(self.alive, "dead fiber") -- checks that the fiber is alive - local saved_current_fiber = current_fiber -- shift the old current fiber into a safe place - current_fiber = self -- we are the new current fiber - local ok, err = coroutine.resume(self.coroutine, wrap, ...) -- rev up our coroutine - -- current_fiber = saved_current_fiber the KEY bit, we only get here when the coroutine above has yielded, - -- but we then pop back in the fiber we previously displaced - current_fiber = saved_current_fiber - if not ok then - print('Error while running fiber: ' .. tostring(err)) - print(debug.traceback(self.coroutine)) - print('fibers history:\n' .. self.traceback) - os.exit(255) - end -end - -Fiber.run = Fiber.resume - ---- Suspends execution of the fiber. --- The fiber will be resumed when the provided blocking function finishes. --- @tparam function block_fn The function to block on. --- @tparam vararg ... The arguments to pass to the blocking function. -function Fiber:suspend(block_fn, ...) - assert(current_fiber == self) - -- The block_fn should arrange to reschedule the fiber when it - -- becomes runnable. - block_fn(current_scheduler, current_fiber, ...) - return coroutine.yield() -end - ---- Returns the socket associated with the provided descriptor. --- @tparam number sd The socket descriptor. --- @treturn table The socket. -function Fiber:get_socket(sd) - return assert(self.sockets[sd]) -end - ---- Adds a new socket to the fiber. --- @tparam table sock The socket to add. --- @treturn number The descriptor of the added socket. -function Fiber:add_socket(sock) - local sd = #self.sockets - -- FIXME: add refcount on socket - self.sockets[sd] = sock - return sd -end - ---- Closes the socket associated with the provided descriptor. --- @tparam number sd The socket descriptor. -function Fiber:close_socket(sd) - self:get_socket(sd) - self.sockets[sd] = nil - -- FIXME: remove refcount on socket -end - ---- Waits until the socket associated with the provided descriptor is readable. --- @tparam number sd The socket descriptor. -function Fiber:wait_for_readable(sd) - local s = self:get_socket(sd) - current_scheduler:resume_when_readable(s, self) - return coroutine.yield() -end - ---- Waits until the socket associated with the provided descriptor is writable. --- @tparam number sd The socket descriptor. -function Fiber:wait_for_writable(sd) - local s = self:get_socket(sd) - current_scheduler:schedule_when_writable(s, self) - return coroutine.yield() -end - ---- Returns the traceback of the fiber. --- @function get_traceback -function Fiber:get_traceback() - return self.traceback or "No traceback available" -end - ---- Returns the current time according to the current scheduler. --- @treturn number The current time. -local function now() return current_scheduler:now() end - ---- Suspends execution of the current fiber. --- The fiber will be resumed when the provided blocking function finishes. --- @function suspend --- @tparam function block_fn The function to block on. --- @tparam vararg ... The arguments to pass to the blocking function. -local function suspend(block_fn, ...) return current_fiber:suspend(block_fn, ...) end - -local function schedule(scheduler, fiber) scheduler:schedule(fiber) end - ---- Suspends execution of the current fiber. --- The fiber will be resumed when the scheduler is ready to run it again. --- @function yield -local function yield() return suspend(schedule) end - ---- Stops the current scheduler from running more tasks. --- @function stop -local function stop() current_scheduler:stop() end - ---- Runs the main event loop of the current scheduler. --- The scheduler will continue to run tasks and wait for events until stopped. --- @function main -local function main() return current_scheduler:main() end +local runtime = require 'fibers.runtime' return { - current_scheduler = current_scheduler, - spawn = spawn, - now = now, - suspend = suspend, - yield = yield, - stop = stop, - main = main + current_scheduler = runtime.current_scheduler, + spawn = runtime.spawn, + now = runtime.now, + suspend = runtime.suspend, + yield = runtime.yield, + stop = runtime.stop, + main = runtime.main, } diff --git a/fibers/op.lua b/fibers/op.lua index fa20ded..8f019e3 100644 --- a/fibers/op.lua +++ b/fibers/op.lua @@ -33,7 +33,7 @@ -- if it doesn't commit "by next turn", abort it cleanly and run -- fallback_ev (in a separate sync). -local fiber = require 'fibers.fiber' +local runtime = require 'fibers.runtime' local unpack = rawget(table, "unpack") or _G.unpack local pack = rawget(table, "pack") or function(...) @@ -471,7 +471,7 @@ perform = function(ev) end -- Slow path - local suspended = pack(fiber.suspend(block_choice_op, leaves)) + local suspended = pack(runtime.suspend(block_choice_op, leaves)) local wrap = suspended[1] local winner_index diff --git a/fibers/runtime.lua b/fibers/runtime.lua new file mode 100644 index 0000000..12a1981 --- /dev/null +++ b/fibers/runtime.lua @@ -0,0 +1,124 @@ +-- fibers/runtime.lua +--- +-- Runtime module. +-- Wraps the global scheduler and fiber machinery. +-- This is a low-level module; most code should go via higher-level APIs. +-- @module fibers.runtime + +local sched = require 'fibers.sched' + +local current_fiber +local current_scheduler = sched.new() + +--- Fiber class +-- Represents a single fiber, or lightweight thread. +-- @type Fiber +local Fiber = {} +Fiber.__index = Fiber + +--- Spawns a new fiber. +-- @function spawn +-- @tparam function fn The function to run in the new fiber. +local function spawn(fn) + -- Capture the traceback + local tb = debug.traceback("", 2):match("\n[^\n]*\n(.*)") or "" + -- If we're inside another fiber, append the traceback to the parent's traceback + if current_fiber and current_fiber.traceback then + tb = tb .. "\n" .. current_fiber.traceback + end + + current_scheduler:schedule( + setmetatable({ + coroutine = coroutine.create(fn), + alive = true, + sockets = {}, + traceback = tb + }, Fiber) + ) +end + +--- Resumes execution of the fiber. +-- If the fiber is already dead, this will throw an error. +-- @tparam vararg ... The arguments to pass to the fiber. +function Fiber:resume(wrap, ...) + assert(self.alive, "dead fiber") -- checks that the fiber is alive + local saved_current_fiber = current_fiber -- shift the old current fiber into a safe place + current_fiber = self -- we are the new current fiber + local ok, err = coroutine.resume(self.coroutine, wrap, ...) -- rev up our coroutine + -- current_fiber = saved_current_fiber the KEY bit, we only get here when the coroutine above has yielded, + -- but we then pop back in the fiber we previously displaced + current_fiber = saved_current_fiber + if not ok then + print('Error while running fiber: ' .. tostring(err)) + print(debug.traceback(self.coroutine)) + print('fibers history:\n' .. self.traceback) + os.exit(255) + end +end + +Fiber.run = Fiber.resume + +function Fiber:suspend(block_fn, ...) + assert(current_fiber == self) + -- The block_fn should arrange to reschedule the fiber when it + -- becomes runnable. + block_fn(current_scheduler, current_fiber, ...) + return coroutine.yield() +end + +function Fiber:get_traceback() + return self.traceback or "No traceback available" +end + +--- Returns the current Fiber object, or nil if not inside a fiber. +local function current() + return current_fiber +end + +local function now() + return current_scheduler:now() +end + +local function suspend(block_fn, ...) + return current_fiber:suspend(block_fn, ...) +end + +local function schedule(scheduler, fiber) + scheduler:schedule(fiber) +end + +--- Suspends execution of the current fiber. +-- The fiber will be resumed when the scheduler is ready to run it again. +-- @function yield +local function yield() + return suspend(schedule) +end + +--- Stops the current scheduler from running more tasks. +-- @function stop +local function stop() + current_scheduler:stop() +end + +--- Runs the main event loop of the current scheduler. +-- The scheduler will continue to run tasks and wait for events until stopped. +-- @function main +local function main() + return current_scheduler:main() +end + +return { + -- core runtime state + current_scheduler = current_scheduler, + current_fiber = current, + + -- time and suspension + now = now, + suspend = suspend, + yield = yield, + + -- fiber management + spawn = spawn, + stop = stop, + main = main, +} From c77fa7be1462c0f8da4e9b5858b6078ae7e15b77 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sun, 9 Nov 2025 14:47:49 +0000 Subject: [PATCH 009/138] stage 2: introduce a basic fibers.scope with a default root scope + tests --- fibers/scope.lua | 171 +++++++++++++++++++++++++++++++++++++++++++ tests/test_scope.lua | 122 ++++++++++++++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 fibers/scope.lua create mode 100644 tests/test_scope.lua diff --git a/fibers/scope.lua b/fibers/scope.lua new file mode 100644 index 0000000..06c03c5 --- /dev/null +++ b/fibers/scope.lua @@ -0,0 +1,171 @@ +-- fibers/scope.lua +--- +-- Basic scope module (stage 2). +-- Provides a tree of Scope objects and a per-fibre “current scope”. +-- +-- At this stage: +-- - A single root scope exists for the process. +-- - Each fibre may have an associated current scope. +-- - scope.current() returns the current scope for the fibre, +-- or the process-wide current scope when not in a fibre. +-- - scope.run(fn, ...) runs fn in a fresh child scope in the +-- current context (fibre or non-fibre). +-- - scope.root():spawn(fn, ...) spawns a new fibre whose current +-- scope is that scope for the duration of fn. +-- +-- Policies (failure, cancellation, resource limits) are *not* yet +-- implemented; they will be added in later stages. +-- +-- @module fibers.scope + +local runtime = require 'fibers.runtime' + +local unpack = rawget(table, "unpack") or _G.unpack +local pack = rawget(table, "pack") or function(...) + return { n = select("#", ...), ... } +end + +local Scope = {} +Scope.__index = Scope + +-- Weak-keyed table mapping Fiber objects to their current Scope. +local fiber_scopes = setmetatable({}, { __mode = "k" }) + +-- Process-wide root scope and “current scope” when *not* in a fibre. +local root_scope +local global_scope + +-- Internal: create a new Scope with the given parent. +local function new_scope(parent) + local s = setmetatable({ + _parent = parent, + _children = {}, + }, Scope) + if parent then + local children = parent._children + children[#children + 1] = s + end + return s +end + +--- Return the process-wide root scope. +local function root() + if not root_scope then + root_scope = new_scope(nil) + global_scope = root_scope + end + return root_scope +end + +-- Internal: current fibre object, or nil if not in a fibre. +local function current_fiber() + if runtime.current_fiber then + return runtime.current_fiber() + end + return nil +end + +--- Return the current Scope. +-- Inside a fibre: the fibre's mapped scope, or the root if none. +-- Outside a fibre: the process-wide current scope, defaulting to root. +local function current() + local fib = current_fiber() + if fib then + return fiber_scopes[fib] or root() + end + return global_scope or root() +end + +--- Internal helper: run fn(scope, ...) with 'scope' as current in this context. +local function with_scope(scope, fn, ...) + local fib = current_fiber() + if fib then + local prev = fiber_scopes[fib] + fiber_scopes[fib] = scope + + local res = pack(pcall(fn, scope, ...)) + fiber_scopes[fib] = prev + if not res[1] then error(res[2]) end + return unpack(res, 2, res.n) + else + local prev = global_scope or root() + global_scope = scope + + local res = pack(pcall(fn, scope, ...)) + global_scope = prev + if not res[1] then error(res[2]) end + return unpack(res, 2, res.n) + end +end + +--- Run a function inside a fresh child scope of the current scope. +-- Synchronous: runs in the current fibre or process context. +-- body_fn :: function(Scope, ...): ... +local function run(body_fn, ...) + local parent = current() + local child = new_scope(parent) + return with_scope(child, body_fn, ...) +end + +--- Create a new child scope of this scope. +-- Does not change the current scope or run any code. +function Scope:new_child() + return new_scope(self) +end + +--- Spawn a child fibre attached to this scope. +-- fn :: function(Scope, ...): () +-- ... :: arguments passed to fn +-- +-- The new fibre's current scope is set to 'self' for the duration +-- of fn, and any previous mapping for that fibre (normally none) +-- is restored afterwards. +function Scope:spawn(fn, ...) + local args = { ... } + runtime.spawn(function() + local fib = current_fiber() + local prev = fib and fiber_scopes[fib] or nil + if fib then + fiber_scopes[fib] = self + end + + local ok, err + if #args > 0 then + ok, err = pcall(fn, self, unpack(args)) + else + ok, err = pcall(fn, self) + end + + if fib then + fiber_scopes[fib] = prev + end + + if not ok then + -- Stage 2: preserve existing behaviour (unhandled errors + -- still crash the process via runtime/Fiber:resume). + error(err) + end + end) +end + +--- Return this scope's parent, or nil for the root scope. +function Scope:parent() + return self._parent +end + +--- Return a shallow copy of this scope's children array. +function Scope:children() + local out = {} + local ch = self._children or {} + for i, child in ipairs(ch) do + out[i] = child + end + return out +end + +return { + root = root, + current = current, + run = run, + Scope = Scope, +} diff --git a/tests/test_scope.lua b/tests/test_scope.lua new file mode 100644 index 0000000..d9fa6a4 --- /dev/null +++ b/tests/test_scope.lua @@ -0,0 +1,122 @@ +--- Tests the Scope implementation. +print("test: fibers.scope") + +-- look one level up +package.path = "../?.lua;" .. package.path + +local runtime = require "fibers.runtime" +local scope = require "fibers.scope" + +local function test_outside_fibers() + local root = scope.root() + + -- current() outside any fibre should be the root (process-wide current scope) + assert(scope.current() == root, "outside fibres, current() should be root") + + local outer_scope + local inner_scope + + scope.run(function(s) + outer_scope = s + + -- Inside run, current() should be this child scope + assert(scope.current() == s, "inside scope.run, current() should be child scope") + assert(s:parent() == root, "outer scope parent must be root") + + -- root should see this child in its children list + local rc = root:children() + assert(#rc == 1 and rc[1] == s, "root:children() should contain outer scope") + + -- Nested run creates a grandchild of s + scope.run(function(child2) + inner_scope = child2 + assert(scope.current() == child2, "inside nested run, current() should be nested child") + assert(child2:parent() == s, "nested scope parent must be outer scope") + + local sc = s:children() + assert(#sc == 1 and sc[1] == child2, "outer scope children() should contain nested scope") + end) + + -- After nested run, current() should be back to the outer scope + assert(scope.current() == s, "after nested run, current() should be outer scope again") + end) + + assert(outer_scope ~= nil, "outer_scope should have been set") + assert(inner_scope ~= nil, "inner_scope should have been set") + assert(outer_scope ~= inner_scope, "outer and inner scopes must differ") + + -- After scope.run returns, current() outside fibres should be root again + assert(scope.current() == scope.root(), "after scope.run, current() should be root outside fibres") +end + +local function test_inside_fibers() + local root = scope.root() + + local child_in_fiber + local grandchild_in_fiber + + -- Spawn a fibre anchored to the root scope. + root:spawn(function(s) + -- In this fibre, s is the scope used for spawn -> root + assert(s == root, "spawn(fn) on root should pass root as scope") + assert(scope.current() == root, "inside spawned fibre, current() should be root initially") + + -- Create a child scope inside the fibre + scope.run(function(child) + child_in_fiber = child + assert(scope.current() == child, "inside scope.run in fibre, current() should be child") + assert(child:parent() == root, "child-in-fibre parent must be root") + + -- Create a grandchild scope + scope.run(function(grandchild) + grandchild_in_fiber = grandchild + assert(scope.current() == grandchild, "inside nested run in fibre, current() should be grandchild") + assert(grandchild:parent() == child, "grandchild parent must be child") + end) + + -- After nested run, current() should be back to child + assert(scope.current() == child, "after nested run in fibre, current() should be child again") + end) + + -- After inner run, current() should be back to root for this fibre + assert(scope.current() == root, "after scope.run in fibre, current() should be root again") + + -- Stop the scheduler once all fibre-local tests have run + runtime.stop() + end) + + -- Drive the scheduler so the spawned fibre runs + runtime.main() + + -- After main() returns we are back outside fibres; + -- current() should again be the process-wide current scope (root). + assert(scope.current() == root, "after runtime.main, current() outside fibres should be root") + + -- Check that scopes created inside the fibre were recorded + assert(child_in_fiber ~= nil, "child_in_fiber should have been set") + assert(grandchild_in_fiber ~= nil, "grandchild_in_fiber should have been set") + assert(child_in_fiber:parent() == root, "child_in_fiber parent must be root") + assert(grandchild_in_fiber:parent() == child_in_fiber, "grandchild_in_fiber parent must be child_in_fiber") + + -- Check that root children include both the outer test scope + -- (from test_outside_fibers) and the child created in this fibre. + local rc = root:children() + assert(#rc >= 2, "root should have at least two children by now") + local found_child = false + for _, s in ipairs(rc) do + if s == child_in_fiber then + found_child = true + break + end + end + assert(found_child, "root:children() should contain child_in_fiber") +end + +local function main() + io.stdout:write("Running scope tests...\n") + test_outside_fibers() + test_inside_fibers() + io.stdout:write("OK\n") +end + +main() From 707089e33c6333b4a95c6913f61de5b5db05924b Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sun, 9 Nov 2025 15:50:07 +0000 Subject: [PATCH 010/138] moves all fibers files into a top-level src folder --- {fibers => src/fibers}/alarm.lua | 0 {fibers => src/fibers}/channel.lua | 0 {fibers => src/fibers}/cond.lua | 0 {fibers => src/fibers}/context.lua | 0 {fibers => src/fibers}/epoll.lua | 0 {fibers => src/fibers}/exec.lua | 0 {fibers => src/fibers}/fiber.lua | 0 {fibers => src/fibers}/op.lua | 0 {fibers => src/fibers}/pollio.lua | 0 {fibers => src/fibers}/queue.lua | 0 {fibers => src/fibers}/runtime.lua | 0 {fibers => src/fibers}/sched.lua | 0 {fibers => src/fibers}/scope.lua | 28 ++++++------ {fibers => src/fibers}/sleep.lua | 0 {fibers => src/fibers}/stream.lua | 0 {fibers => src/fibers}/stream/compat.lua | 0 {fibers => src/fibers}/stream/file.lua | 0 {fibers => src/fibers}/stream/mem.lua | 0 {fibers => src/fibers}/stream/socket.lua | 0 {fibers => src/fibers}/timer.lua | 0 {fibers => src/fibers}/utils/fifo.lua | 0 {fibers => src/fibers}/utils/fixed_buffer.lua | 0 {fibers => src/fibers}/utils/helper.lua | 0 {fibers => src/fibers}/utils/syscall.lua | 0 {fibers => src/fibers}/waitgroup.lua | 0 tests/test.lua | 7 +-- tests/test_alarm.lua | 2 +- tests/test_channel.lua | 2 +- tests/test_cond.lua | 2 +- tests/test_context.lua | 2 +- tests/test_epoll.lua | 2 +- tests/test_exec.lua | 44 ++++++++++--------- tests/test_fiber.lua | 2 +- tests/test_op.lua | 2 +- tests/test_pollio.lua | 2 +- tests/test_queue.lua | 2 +- tests/test_sched.lua | 4 +- tests/test_scope.lua | 2 +- tests/test_sleep.lua | 2 +- tests/test_stream-compat.lua | 2 +- tests/test_stream-file.lua | 2 +- tests/test_stream-mem.lua | 2 +- tests/test_stream-socket.lua | 2 +- tests/test_stream.lua | 2 +- tests/test_timer.lua | 2 +- tests/test_utils-fixed_buffer.lua | 2 +- tests/test_waitgroup.lua | 2 +- 47 files changed, 62 insertions(+), 59 deletions(-) rename {fibers => src/fibers}/alarm.lua (100%) rename {fibers => src/fibers}/channel.lua (100%) rename {fibers => src/fibers}/cond.lua (100%) rename {fibers => src/fibers}/context.lua (100%) rename {fibers => src/fibers}/epoll.lua (100%) rename {fibers => src/fibers}/exec.lua (100%) rename {fibers => src/fibers}/fiber.lua (100%) rename {fibers => src/fibers}/op.lua (100%) rename {fibers => src/fibers}/pollio.lua (100%) rename {fibers => src/fibers}/queue.lua (100%) rename {fibers => src/fibers}/runtime.lua (100%) rename {fibers => src/fibers}/sched.lua (100%) rename {fibers => src/fibers}/scope.lua (83%) rename {fibers => src/fibers}/sleep.lua (100%) rename {fibers => src/fibers}/stream.lua (100%) rename {fibers => src/fibers}/stream/compat.lua (100%) rename {fibers => src/fibers}/stream/file.lua (100%) rename {fibers => src/fibers}/stream/mem.lua (100%) rename {fibers => src/fibers}/stream/socket.lua (100%) rename {fibers => src/fibers}/timer.lua (100%) rename {fibers => src/fibers}/utils/fifo.lua (100%) rename {fibers => src/fibers}/utils/fixed_buffer.lua (100%) rename {fibers => src/fibers}/utils/helper.lua (100%) rename {fibers => src/fibers}/utils/syscall.lua (100%) rename {fibers => src/fibers}/waitgroup.lua (100%) diff --git a/fibers/alarm.lua b/src/fibers/alarm.lua similarity index 100% rename from fibers/alarm.lua rename to src/fibers/alarm.lua diff --git a/fibers/channel.lua b/src/fibers/channel.lua similarity index 100% rename from fibers/channel.lua rename to src/fibers/channel.lua diff --git a/fibers/cond.lua b/src/fibers/cond.lua similarity index 100% rename from fibers/cond.lua rename to src/fibers/cond.lua diff --git a/fibers/context.lua b/src/fibers/context.lua similarity index 100% rename from fibers/context.lua rename to src/fibers/context.lua diff --git a/fibers/epoll.lua b/src/fibers/epoll.lua similarity index 100% rename from fibers/epoll.lua rename to src/fibers/epoll.lua diff --git a/fibers/exec.lua b/src/fibers/exec.lua similarity index 100% rename from fibers/exec.lua rename to src/fibers/exec.lua diff --git a/fibers/fiber.lua b/src/fibers/fiber.lua similarity index 100% rename from fibers/fiber.lua rename to src/fibers/fiber.lua diff --git a/fibers/op.lua b/src/fibers/op.lua similarity index 100% rename from fibers/op.lua rename to src/fibers/op.lua diff --git a/fibers/pollio.lua b/src/fibers/pollio.lua similarity index 100% rename from fibers/pollio.lua rename to src/fibers/pollio.lua diff --git a/fibers/queue.lua b/src/fibers/queue.lua similarity index 100% rename from fibers/queue.lua rename to src/fibers/queue.lua diff --git a/fibers/runtime.lua b/src/fibers/runtime.lua similarity index 100% rename from fibers/runtime.lua rename to src/fibers/runtime.lua diff --git a/fibers/sched.lua b/src/fibers/sched.lua similarity index 100% rename from fibers/sched.lua rename to src/fibers/sched.lua diff --git a/fibers/scope.lua b/src/fibers/scope.lua similarity index 83% rename from fibers/scope.lua rename to src/fibers/scope.lua index 06c03c5..8e83b68 100644 --- a/fibers/scope.lua +++ b/src/fibers/scope.lua @@ -1,16 +1,16 @@ -- fibers/scope.lua --- -- Basic scope module (stage 2). --- Provides a tree of Scope objects and a per-fibre “current scope”. +-- Provides a tree of Scope objects and a per-fiber “current scope”. -- -- At this stage: -- - A single root scope exists for the process. --- - Each fibre may have an associated current scope. --- - scope.current() returns the current scope for the fibre, --- or the process-wide current scope when not in a fibre. +-- - Each fiber may have an associated current scope. +-- - scope.current() returns the current scope for the fiber, +-- or the process-wide current scope when not in a fiber. -- - scope.run(fn, ...) runs fn in a fresh child scope in the --- current context (fibre or non-fibre). --- - scope.root():spawn(fn, ...) spawns a new fibre whose current +-- current context (fiber or non-fiber). +-- - scope.root():spawn(fn, ...) spawns a new fiber whose current -- scope is that scope for the duration of fn. -- -- Policies (failure, cancellation, resource limits) are *not* yet @@ -31,7 +31,7 @@ Scope.__index = Scope -- Weak-keyed table mapping Fiber objects to their current Scope. local fiber_scopes = setmetatable({}, { __mode = "k" }) --- Process-wide root scope and “current scope” when *not* in a fibre. +-- Process-wide root scope and “current scope” when *not* in a fiber. local root_scope local global_scope @@ -57,7 +57,7 @@ local function root() return root_scope end --- Internal: current fibre object, or nil if not in a fibre. +-- Internal: current fiber object, or nil if not in a fiber. local function current_fiber() if runtime.current_fiber then return runtime.current_fiber() @@ -66,8 +66,8 @@ local function current_fiber() end --- Return the current Scope. --- Inside a fibre: the fibre's mapped scope, or the root if none. --- Outside a fibre: the process-wide current scope, defaulting to root. +-- Inside a fiber: the fiber's mapped scope, or the root if none. +-- Outside a fiber: the process-wide current scope, defaulting to root. local function current() local fib = current_fiber() if fib then @@ -99,7 +99,7 @@ local function with_scope(scope, fn, ...) end --- Run a function inside a fresh child scope of the current scope. --- Synchronous: runs in the current fibre or process context. +-- Synchronous: runs in the current fiber or process context. -- body_fn :: function(Scope, ...): ... local function run(body_fn, ...) local parent = current() @@ -113,12 +113,12 @@ function Scope:new_child() return new_scope(self) end ---- Spawn a child fibre attached to this scope. +--- Spawn a child fiber attached to this scope. -- fn :: function(Scope, ...): () -- ... :: arguments passed to fn -- --- The new fibre's current scope is set to 'self' for the duration --- of fn, and any previous mapping for that fibre (normally none) +-- The new fiber's current scope is set to 'self' for the duration +-- of fn, and any previous mapping for that fiber (normally none) -- is restored afterwards. function Scope:spawn(fn, ...) local args = { ... } diff --git a/fibers/sleep.lua b/src/fibers/sleep.lua similarity index 100% rename from fibers/sleep.lua rename to src/fibers/sleep.lua diff --git a/fibers/stream.lua b/src/fibers/stream.lua similarity index 100% rename from fibers/stream.lua rename to src/fibers/stream.lua diff --git a/fibers/stream/compat.lua b/src/fibers/stream/compat.lua similarity index 100% rename from fibers/stream/compat.lua rename to src/fibers/stream/compat.lua diff --git a/fibers/stream/file.lua b/src/fibers/stream/file.lua similarity index 100% rename from fibers/stream/file.lua rename to src/fibers/stream/file.lua diff --git a/fibers/stream/mem.lua b/src/fibers/stream/mem.lua similarity index 100% rename from fibers/stream/mem.lua rename to src/fibers/stream/mem.lua diff --git a/fibers/stream/socket.lua b/src/fibers/stream/socket.lua similarity index 100% rename from fibers/stream/socket.lua rename to src/fibers/stream/socket.lua diff --git a/fibers/timer.lua b/src/fibers/timer.lua similarity index 100% rename from fibers/timer.lua rename to src/fibers/timer.lua diff --git a/fibers/utils/fifo.lua b/src/fibers/utils/fifo.lua similarity index 100% rename from fibers/utils/fifo.lua rename to src/fibers/utils/fifo.lua diff --git a/fibers/utils/fixed_buffer.lua b/src/fibers/utils/fixed_buffer.lua similarity index 100% rename from fibers/utils/fixed_buffer.lua rename to src/fibers/utils/fixed_buffer.lua diff --git a/fibers/utils/helper.lua b/src/fibers/utils/helper.lua similarity index 100% rename from fibers/utils/helper.lua rename to src/fibers/utils/helper.lua diff --git a/fibers/utils/syscall.lua b/src/fibers/utils/syscall.lua similarity index 100% rename from fibers/utils/syscall.lua rename to src/fibers/utils/syscall.lua diff --git a/fibers/waitgroup.lua b/src/fibers/waitgroup.lua similarity index 100% rename from fibers/waitgroup.lua rename to src/fibers/waitgroup.lua diff --git a/tests/test.lua b/tests/test.lua index abc8374..9d43180 100644 --- a/tests/test.lua +++ b/tests/test.lua @@ -1,4 +1,4 @@ -package.path = "../?.lua;" .. package.path +package.path = "../src/?.lua;" .. package.path package.path = package.path .. ';/usr/lib/lua/?.lua;/usr/lib/lua/?/init.lua' local sep = '-' @@ -19,10 +19,11 @@ local modules = { { 'sleep' }, { 'epoll' }, { 'pollio' }, - { 'exec' }, + -- { 'exec' }, { 'waitgroup' }, { 'alarm' }, - { 'context' } + { 'context' }, + { 'scope' }, } for _, j in ipairs(modules) do diff --git a/tests/test_alarm.lua b/tests/test_alarm.lua index a8af3ac..ebd8a9a 100644 --- a/tests/test_alarm.lua +++ b/tests/test_alarm.lua @@ -2,7 +2,7 @@ print('testing: fibers.alarm') -- look one level up -package.path = "../?.lua;" .. package.path +package.path = "../src/?.lua;" .. package.path local fiber = require 'fibers.fiber' local alarm = require 'fibers.alarm' diff --git a/tests/test_channel.lua b/tests/test_channel.lua index 0c1bbc9..81ed78a 100644 --- a/tests/test_channel.lua +++ b/tests/test_channel.lua @@ -1,6 +1,6 @@ print('testing: fibers.channel') -package.path = "../?.lua;" .. package.path +package.path = "../src/?.lua;" .. package.path local fiber = require 'fibers.fiber' local channel = require 'fibers.channel' diff --git a/tests/test_cond.lua b/tests/test_cond.lua index af27293..f3d3a61 100644 --- a/tests/test_cond.lua +++ b/tests/test_cond.lua @@ -2,7 +2,7 @@ print('testing: fibers.cond') -- look one level up -package.path = "../?.lua;" .. package.path +package.path = "../src/?.lua;" .. package.path local cond = require 'fibers.cond' local fiber = require 'fibers.fiber' diff --git a/tests/test_context.lua b/tests/test_context.lua index ebcb61a..e5129e0 100644 --- a/tests/test_context.lua +++ b/tests/test_context.lua @@ -2,7 +2,7 @@ print('testing: fibers.context') -- look one level up -package.path = "../?.lua;" .. package.path +package.path = "../src/?.lua;" .. package.path local context = require 'fibers.context' local fiber = require 'fibers.fiber' diff --git a/tests/test_epoll.lua b/tests/test_epoll.lua index fae19af..22aeab3 100644 --- a/tests/test_epoll.lua +++ b/tests/test_epoll.lua @@ -2,7 +2,7 @@ print('testing: fibers.epoll') -- look one level up -package.path = "../?.lua;" .. package.path +package.path = "../src/?.lua;" .. package.path local epoll = require 'fibers.epoll' local sc = require 'fibers.utils.syscall' diff --git a/tests/test_exec.lua b/tests/test_exec.lua index 47d703c..baedeb8 100644 --- a/tests/test_exec.lua +++ b/tests/test_exec.lua @@ -1,5 +1,7 @@ -- test_fibers_exec.lua -package.path = "../../?.lua;../?.lua;" .. package.path + +-- look one level up +package.path = "../src/?.lua;" .. package.path local fiber = require 'fibers.fiber' local sleep = require 'fibers.sleep' @@ -146,28 +148,28 @@ local function test_cleanup_on_crash(id) local temp_script_dir = "/tmp/crash_test" .. id .. ".lua" local script = [[ - package.path = "../../?.lua;../?.lua;" .. package.path - local fiber = require 'fibers.fiber' - local exec = require 'fibers.exec' - local sleep = require 'fibers.sleep' - local pollio = require 'fibers.pollio' - local sc = require 'fibers.utils.syscall' - pollio.install_poll_io_handler() + package.path = "../src/?.lua;" .. package.path + local fiber = require 'fibers.fiber' + local exec = require 'fibers.exec' + local sleep = require 'fibers.sleep' + local pollio = require 'fibers.pollio' + local sc = require 'fibers.utils.syscall' + pollio.install_poll_io_handler() - fiber.spawn(function () - local cmd = exec.command('sleep', '1') - cmd:setprdeathsig(sc.SIGKILL) - local err = cmd:start() - if err then - error(err) - end - io.stdout:write(tostring(cmd.process.pid) .. "\n") - io.stdout:flush() - sleep.sleep(0.01) - print(obj.obj) - end) + fiber.spawn(function () + local cmd = exec.command('sleep', '1') + cmd:setprdeathsig(sc.SIGKILL) + local err = cmd:start() + if err then + error(err) + end + io.stdout:write(tostring(cmd.process.pid) .. "\n") + io.stdout:flush() + sleep.sleep(0.01) + print(obj.obj) + end) - fiber.main() + fiber.main() ]] -- Write the script to a temporary file diff --git a/tests/test_fiber.lua b/tests/test_fiber.lua index 39513b9..2f3e31d 100644 --- a/tests/test_fiber.lua +++ b/tests/test_fiber.lua @@ -2,7 +2,7 @@ print('testing: fibers.fiber') -- look one level up -package.path = "../?.lua;" .. package.path +package.path = "../src/?.lua;" .. package.path local fiber = require 'fibers.fiber' local sc = require 'fibers.utils.syscall' diff --git a/tests/test_op.lua b/tests/test_op.lua index eb54407..e0cf028 100644 --- a/tests/test_op.lua +++ b/tests/test_op.lua @@ -2,7 +2,7 @@ print("testing: fibers.op") -- look one level up -package.path = "../?.lua;" .. package.path +package.path = "../src/?.lua;" .. package.path local op = require 'fibers.op' local fiber = require 'fibers.fiber' diff --git a/tests/test_pollio.lua b/tests/test_pollio.lua index 4e1ac66..00f76a0 100644 --- a/tests/test_pollio.lua +++ b/tests/test_pollio.lua @@ -2,7 +2,7 @@ print('testing: fibers.fd') -- look one level up -package.path = "../?.lua;" .. package.path +package.path = "../src/?.lua;" .. package.path local pollio = require 'fibers.pollio' local file_stream = require 'fibers.stream.file' diff --git a/tests/test_queue.lua b/tests/test_queue.lua index b0822ac..e475b98 100644 --- a/tests/test_queue.lua +++ b/tests/test_queue.lua @@ -2,7 +2,7 @@ print('testing: fibers.queue') -- look one level up -package.path = "../?.lua;" .. package.path +package.path = "../src/?.lua;" .. package.path local queue = require 'fibers.queue' local fiber = require 'fibers.fiber' diff --git a/tests/test_sched.lua b/tests/test_sched.lua index 4ec7990..57e49c7 100644 --- a/tests/test_sched.lua +++ b/tests/test_sched.lua @@ -2,7 +2,7 @@ print("test: fibers.sched") -- look one level up -package.path = "../?.lua;" .. package.path +package.path = "../src/?.lua;" .. package.path local sched = require 'fibers.sched' local sc = require 'fibers.utils.syscall' @@ -47,4 +47,4 @@ assert(count == event_count) local averageImprecision = totalImprecision / event_count print("Average imprecision: " .. averageImprecision) -print("test: ok") \ No newline at end of file +print("test: ok") diff --git a/tests/test_scope.lua b/tests/test_scope.lua index d9fa6a4..10d9dbe 100644 --- a/tests/test_scope.lua +++ b/tests/test_scope.lua @@ -2,7 +2,7 @@ print("test: fibers.scope") -- look one level up -package.path = "../?.lua;" .. package.path +package.path = "../src/?.lua;" .. package.path local runtime = require "fibers.runtime" local scope = require "fibers.scope" diff --git a/tests/test_sleep.lua b/tests/test_sleep.lua index 210e396..fd16308 100644 --- a/tests/test_sleep.lua +++ b/tests/test_sleep.lua @@ -2,7 +2,7 @@ print('testing: fibers.sleep') -- look one level up -package.path = "../?.lua;" .. package.path +package.path = "../src/?.lua;" .. package.path local sleep = require 'fibers.sleep' local fiber = require 'fibers.fiber' diff --git a/tests/test_stream-compat.lua b/tests/test_stream-compat.lua index 0a836ce..26192e7 100644 --- a/tests/test_stream-compat.lua +++ b/tests/test_stream-compat.lua @@ -2,7 +2,7 @@ print('testing: fibers.stream.compat') -- look one level up -package.path = "../?.lua;" .. package.path +package.path = "../src/?.lua;" .. package.path local compat = require 'fibers.stream.compat' diff --git a/tests/test_stream-file.lua b/tests/test_stream-file.lua index 09bb04f..b1849bf 100644 --- a/tests/test_stream-file.lua +++ b/tests/test_stream-file.lua @@ -2,7 +2,7 @@ print('testing: fibers.stream.file') -- look one level up -package.path = "../?.lua;" .. package.path +package.path = "../src/?.lua;" .. package.path local fiber = require 'fibers.fiber' local waitgroup = require "fibers.waitgroup" diff --git a/tests/test_stream-mem.lua b/tests/test_stream-mem.lua index cffec82..6ce42a8 100644 --- a/tests/test_stream-mem.lua +++ b/tests/test_stream-mem.lua @@ -2,7 +2,7 @@ print('testing: fibers.stream.mem') -- look one level up -package.path = "../?.lua;" .. package.path +package.path = "../src/?.lua;" .. package.path local mem = require 'fibers.stream.mem' local sc = require 'fibers.utils.syscall' diff --git a/tests/test_stream-socket.lua b/tests/test_stream-socket.lua index 4e68b59..bf1b4ad 100644 --- a/tests/test_stream-socket.lua +++ b/tests/test_stream-socket.lua @@ -2,7 +2,7 @@ print('testing: fibers.stream.socket') -- look one level up -package.path = "../?.lua;" .. package.path +package.path = "../src/?.lua;" .. package.path local fiber = require 'fibers.fiber' local socket = require 'fibers.stream.socket' diff --git a/tests/test_stream.lua b/tests/test_stream.lua index b7236e6..ce11d74 100644 --- a/tests/test_stream.lua +++ b/tests/test_stream.lua @@ -2,7 +2,7 @@ print('testing: fibers.stream') -- look one level up -package.path = "../?.lua;" .. package.path +package.path = "../src/?.lua;" .. package.path local fiber = require 'fibers.fiber' local stream = require 'fibers.stream' diff --git a/tests/test_timer.lua b/tests/test_timer.lua index 1455ba7..3399720 100644 --- a/tests/test_timer.lua +++ b/tests/test_timer.lua @@ -2,7 +2,7 @@ print("test: fibers.timer") -- look one level up -package.path = "../?.lua;" .. package.path +package.path = "../src/?.lua;" .. package.path local timer = require 'fibers.timer' local sc = require 'fibers.utils.syscall' diff --git a/tests/test_utils-fixed_buffer.lua b/tests/test_utils-fixed_buffer.lua index 45fef02..033e2f8 100644 --- a/tests/test_utils-fixed_buffer.lua +++ b/tests/test_utils-fixed_buffer.lua @@ -1,7 +1,7 @@ --- Tests the fixed_buffer implementation. print('testing: fibers.utils.fixed_buffer') -package.path = "../?.lua;" .. package.path +package.path = "../src/?.lua;" .. package.path local buffer = require 'fibers.utils.fixed_buffer' local sc = require 'fibers.utils.syscall' diff --git a/tests/test_waitgroup.lua b/tests/test_waitgroup.lua index 007fcfe..b80ef7d 100644 --- a/tests/test_waitgroup.lua +++ b/tests/test_waitgroup.lua @@ -2,7 +2,7 @@ print('testing: fibers.waitgroup') -- look one level up -package.path = "../?.lua;" .. package.path +package.path = "../src/?.lua;" .. package.path -- test_waitgroup.lua local fiber = require 'fibers.fiber' From 29f3577354ee48edbfd405c6ab984019646fb1c6 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sun, 9 Nov 2025 16:28:12 +0000 Subject: [PATCH 011/138] adds scope-aware performer, modifies all libraries, tests and examples --- examples/1-basic-usage/4-choices.lua | 2 +- examples/1-basic-usage/5-choices-alt.lua | 2 +- examples/1-basic-usage/6-choices-adv.lua | 2 +- examples/1-basic-usage/context-examples.lua | 2 +- examples/2-lua-http/cq-http-fiber-test.lua | 2 +- src/fibers.lua | 82 +++++++++++++++++++++ src/fibers/alarm.lua | 2 +- src/fibers/channel.lua | 2 +- src/fibers/cond.lua | 2 +- src/fibers/context.lua | 6 ++ src/fibers/exec.lua | 2 +- src/fibers/op.lua | 3 +- src/fibers/performer.lua | 28 +++++++ src/fibers/sleep.lua | 2 +- src/fibers/stream.lua | 2 +- src/fibers/stream/file.lua | 3 +- src/fibers/waitgroup.lua | 2 +- tests/test_context.lua | 3 +- tests/test_op.lua | 2 +- tests/test_stream-file.lua | 2 +- tests/test_waitgroup.lua | 2 +- 21 files changed, 134 insertions(+), 21 deletions(-) create mode 100644 src/fibers.lua create mode 100644 src/fibers/performer.lua diff --git a/examples/1-basic-usage/4-choices.lua b/examples/1-basic-usage/4-choices.lua index 909cf17..996b588 100644 --- a/examples/1-basic-usage/4-choices.lua +++ b/examples/1-basic-usage/4-choices.lua @@ -12,7 +12,7 @@ local fiber = require 'fibers.fiber' local channel = require 'fibers.channel' local op = require 'fibers.op' -local perform, choice = op.perform, op.choice +local perform, choice = require 'fibers.performer'.perform, op.choice local function fibonacci(c, quit) local x, y = 0, 1 diff --git a/examples/1-basic-usage/5-choices-alt.lua b/examples/1-basic-usage/5-choices-alt.lua index 64fa01f..e558325 100644 --- a/examples/1-basic-usage/5-choices-alt.lua +++ b/examples/1-basic-usage/5-choices-alt.lua @@ -11,7 +11,7 @@ local channel = require 'fibers.channel' local sleep = require 'fibers.sleep' local op = require 'fibers.op' -local perform, choice = op.perform, op.choice +local perform, choice = require 'fibers.performer'.perform, op.choice -- time.After() is a Go library function local function after(t) diff --git a/examples/1-basic-usage/6-choices-adv.lua b/examples/1-basic-usage/6-choices-adv.lua index 2d5f50c..0a1be4f 100644 --- a/examples/1-basic-usage/6-choices-adv.lua +++ b/examples/1-basic-usage/6-choices-adv.lua @@ -11,7 +11,7 @@ local cond = require 'fibers.cond' local op = require 'fibers.op' local sc = require 'fibers.utils.syscall' -local perform, choice = op.perform, op.choice +local perform, choice = require 'fibers.performer'.perform, op.choice require("fibers.pollio").install_poll_io_handler() diff --git a/examples/1-basic-usage/context-examples.lua b/examples/1-basic-usage/context-examples.lua index b04bc1a..d630647 100644 --- a/examples/1-basic-usage/context-examples.lua +++ b/examples/1-basic-usage/context-examples.lua @@ -5,7 +5,7 @@ local context = require 'fibers.context' local sleep = require 'fibers.sleep' local op = require 'fibers.op' -local perform = op.perform +local perform = require 'fibers.performer'.perform -- Simulated work function local function do_work(task_name, duration) diff --git a/examples/2-lua-http/cq-http-fiber-test.lua b/examples/2-lua-http/cq-http-fiber-test.lua index 43a7012..13523f0 100644 --- a/examples/2-lua-http/cq-http-fiber-test.lua +++ b/examples/2-lua-http/cq-http-fiber-test.lua @@ -13,7 +13,7 @@ local sleep = require "fibers.sleep" local cqueues = require "cqueues" local exec = require "fibers.exec" -local perform, choice = op.perform, op.choice +local perform, choice = require 'fibers.performer'.perform, op.choice print("installing poll handler") pollio.install_poll_io_handler() diff --git a/src/fibers.lua b/src/fibers.lua new file mode 100644 index 0000000..f823435 --- /dev/null +++ b/src/fibers.lua @@ -0,0 +1,82 @@ +-- fibers.lua +--- +-- Top-level façade for the fibers library. +-- Provides a small, convenient surface over the lower-level modules: +-- - runtime (scheduler and fibres), +-- - op (CML engine), +-- - scope (structured concurrency), +-- - primitives (sleep, channel, etc.). +-- +-- At this stage, scopes carry no policies; they simply form a tree +-- and track the current scope per fibre. +-- +-- @module fibers + +local runtime = require 'fibers.runtime' +local scope_mod = require 'fibers.scope' +local performer = require 'fibers.performer' + +local sleep_mod = require 'fibers.sleep' +local channel = require 'fibers.channel' +local op = require 'fibers.op' + +local unpack = rawget(table, "unpack") or _G.unpack + +local fibers = {} + +---------------------------------------------------------------------- +-- Core execution +---------------------------------------------------------------------- + +--- Perform an event under the current scope. +fibers.perform = performer.perform + +--- Current monotonic time. +fibers.now = runtime.now + +--- Run a main function under the scheduler's root scope. +-- main_fn :: function(Scope, ...): () +function fibers.run(main_fn, ...) + local root = scope_mod.root() + local args = { ... } + + -- Spawn an initial fibre under the root scope. + root:spawn(function(s) + main_fn(s, unpack(args)) + -- When main_fn returns, stop the scheduler loop. + runtime.stop() + end) + + -- Drive the scheduler until stop() is called. + runtime.main() +end + +--- Spawn a fibre under the current scope. +-- fn :: function(Scope, ...): () +function fibers.spawn(fn, ...) + local s = scope_mod.current() + return s:spawn(fn, ...) +end + +---------------------------------------------------------------------- +-- Common primitives +---------------------------------------------------------------------- + +fibers.sleep = sleep_mod.sleep +fibers.channel = channel.new + +---------------------------------------------------------------------- +-- Scopes and ops for advanced use +---------------------------------------------------------------------- + +fibers.scope = scope_mod +fibers.op = op + +-- Re-export a few core CML combinators for convenience. +fibers.choice = op.choice +fibers.guard = op.guard +fibers.with_nack = op.with_nack +fibers.always = op.always +fibers.never = op.never + +return fibers diff --git a/src/fibers/alarm.lua b/src/fibers/alarm.lua index 70590a3..bbd892f 100644 --- a/src/fibers/alarm.lua +++ b/src/fibers/alarm.lua @@ -7,7 +7,7 @@ local fiber = require 'fibers.fiber' local timer = require 'fibers.timer' local sc = require 'fibers.utils.syscall' -local perform = op.perform +local perform = require 'fibers.performer'.perform local function days_in_year(y) return y % 4 == 0 and (y % 100 ~= 0 or y % 400 == 0) and 366 or 365 diff --git a/src/fibers/channel.lua b/src/fibers/channel.lua index 4da9516..22173b9 100644 --- a/src/fibers/channel.lua +++ b/src/fibers/channel.lua @@ -7,7 +7,7 @@ local op = require 'fibers.op' local fifo = require 'fibers.utils.fifo' -local perform = op.perform +local perform = require 'fibers.performer'.perform --- Channel class -- Represents a communication channel between fibers. diff --git a/src/fibers/cond.lua b/src/fibers/cond.lua index 2c7583c..41255c8 100644 --- a/src/fibers/cond.lua +++ b/src/fibers/cond.lua @@ -6,7 +6,7 @@ local op = require 'fibers.op' -local perform = op.perform +local perform = require 'fibers.performer'.perform local Cond = {} Cond.__index = Cond diff --git a/src/fibers/context.lua b/src/fibers/context.lua index 21dd922..7797b46 100644 --- a/src/fibers/context.lua +++ b/src/fibers/context.lua @@ -17,6 +17,8 @@ local fiber = require "fibers.fiber" local op = require "fibers.op" local cond = require "fibers.cond" +local perform = require 'fibers.performer'.perform + -- ------------------------------------------------------------------------ -- base_context: -- Minimal context that defers cancellation, deadlines and values to its parent. @@ -105,6 +107,10 @@ function cancel_context:done_op() end end +function cancel_context:done() + return perform(self:done_op()) +end + --- Overridden err() that checks for a local cancellation cause. function cancel_context:err() return self.cause or (self.parent and self.parent:err() or nil) diff --git a/src/fibers/exec.lua b/src/fibers/exec.lua index d327080..30f0247 100644 --- a/src/fibers/exec.lua +++ b/src/fibers/exec.lua @@ -6,7 +6,7 @@ local op = require 'fibers.op' local buffer = require 'string.buffer' local sc = require 'fibers.utils.syscall' -local perform, choice = op.perform, op.choice +local perform, choice = require 'fibers.performer'.perform, op.choice local io_mappings = { stdin = sc.STDIN_FILENO, diff --git a/src/fibers/op.lua b/src/fibers/op.lua index 8f019e3..cf21866 100644 --- a/src/fibers/op.lua +++ b/src/fibers/op.lua @@ -567,6 +567,7 @@ end return { perform = perform, + perform_raw = perform, new_base_op = new_base_op, -- primitive event constructor choice = choice, guard = guard, @@ -575,7 +576,5 @@ return { bracket = bracket, always = always, never = never, - -- wrap_handler = wrap_handler, - -- finally = finally, -- Event instances have methods: wrap, on_abort. } diff --git a/src/fibers/performer.lua b/src/fibers/performer.lua new file mode 100644 index 0000000..dc5338f --- /dev/null +++ b/src/fibers/performer.lua @@ -0,0 +1,28 @@ +-- fibers/performer.lua +--- +-- Scope-aware performer. +-- This is the preferred entry point for synchronising on events +-- in normal code. It delegates to the current scope (or the root +-- scope) and uses the raw op.perform under the hood. +-- +-- Policies (failure, cancellation, etc.) will be wired into the +-- Scope methods in later stages. +-- +-- @module fibers.performer + +local scope = require 'fibers.scope' +local op = require 'fibers.op' + +local M = {} + +--- Perform an event under the current scope. +-- For now this simply calls the raw op.perform; in later stages +-- Scope:sync can wrap events with policies before performing. +function M.perform(ev) + -- At this stage, scopes do not transform events, so we just + -- ensure there *is* a scope and call the raw engine. + local _ = scope.current() -- touch to ensure root initialises + return op.perform(ev) +end + +return M diff --git a/src/fibers/sleep.lua b/src/fibers/sleep.lua index 788843e..115fd07 100644 --- a/src/fibers/sleep.lua +++ b/src/fibers/sleep.lua @@ -7,7 +7,7 @@ local op = require 'fibers.op' local fiber = require 'fibers.fiber' -local perform = op.perform +local perform = require 'fibers.performer'.perform --- Timeout class. -- Represents a timeout for a fiber. diff --git a/src/fibers/stream.lua b/src/fibers/stream.lua index c8935a5..ea790dd 100644 --- a/src/fibers/stream.lua +++ b/src/fibers/stream.lua @@ -9,7 +9,7 @@ local op = require 'fibers.op' local fixed_buffer = require 'fibers.utils.fixed_buffer' local buffer = require 'string.buffer' -local perform = op.perform +local perform = require 'fibers.performer'.perform local unpack = table.unpack or unpack -- luacheck: ignore -- Compatibility fallback diff --git a/src/fibers/stream/file.lua b/src/fibers/stream/file.lua index 40b3677..5c83e52 100644 --- a/src/fibers/stream/file.lua +++ b/src/fibers/stream/file.lua @@ -7,11 +7,10 @@ package.path = "../../?.lua;../?.lua;" .. package.path local stream = require 'fibers.stream' -local op = require 'fibers.op' local pollio = require 'fibers.pollio' local sc = require 'fibers.utils.syscall' -local perform = op.perform +local perform = require 'fibers.performer'.perform local pio_handler = pollio.install_poll_io_handler() diff --git a/src/fibers/waitgroup.lua b/src/fibers/waitgroup.lua index f0b1aa0..83c70bc 100644 --- a/src/fibers/waitgroup.lua +++ b/src/fibers/waitgroup.lua @@ -1,7 +1,7 @@ -- waitgroup.lua local op = require 'fibers.op' -local perform = op.perform +local perform = require 'fibers.performer'.perform local Waitgroup = {} Waitgroup.__index = Waitgroup diff --git a/tests/test_context.lua b/tests/test_context.lua index e5129e0..2a28dbc 100644 --- a/tests/test_context.lua +++ b/tests/test_context.lua @@ -6,10 +6,9 @@ package.path = "../src/?.lua;" .. package.path local context = require 'fibers.context' local fiber = require 'fibers.fiber' -local op = require 'fibers.op' local sleep = require 'fibers.sleep' -local perform = op.perform +local perform = require 'fibers.performer'.perform -- Test Background Context local function test_background() diff --git a/tests/test_op.lua b/tests/test_op.lua index e0cf028..da3ce65 100644 --- a/tests/test_op.lua +++ b/tests/test_op.lua @@ -7,7 +7,7 @@ package.path = "../src/?.lua;" .. package.path local op = require 'fibers.op' local fiber = require 'fibers.fiber' -local perform, choice = op.perform, op.choice +local perform, choice = require 'fibers.performer'.perform, op.choice local always = op.always local never = op.never diff --git a/tests/test_stream-file.lua b/tests/test_stream-file.lua index b1849bf..3141b7c 100644 --- a/tests/test_stream-file.lua +++ b/tests/test_stream-file.lua @@ -14,7 +14,7 @@ local compat = require 'fibers.stream.compat' compat.install() -local perform, choice = op.perform, op.choice +local perform, choice = require 'fibers.performer'.perform, op.choice local function test() local rd, wr = file.pipe() diff --git a/tests/test_waitgroup.lua b/tests/test_waitgroup.lua index b80ef7d..27b4c14 100644 --- a/tests/test_waitgroup.lua +++ b/tests/test_waitgroup.lua @@ -11,7 +11,7 @@ local op = require 'fibers.op' local waitgroup = require 'fibers.waitgroup' local sc = require 'fibers.utils.syscall' -local perform, choice = op.perform, op.choice +local perform, choice = require 'fibers.performer'.perform, op.choice local function test_nowait() local wg = waitgroup.new() From 845ad650375cdd9a75357b45a3cb331659d4c1c1 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Tue, 11 Nov 2025 00:13:15 +0000 Subject: [PATCH 012/138] adds full fat scope as event, retires fibers.fiber, updates examples --- examples/1-basic-usage/1-fibers.lua | 13 +- examples/1-basic-usage/10-luacat.lua | 11 +- examples/1-basic-usage/2-channels.lua | 16 +- examples/1-basic-usage/3-queues.lua | 12 +- examples/1-basic-usage/4-choices.lua | 16 +- examples/1-basic-usage/5-choices-alt.lua | 19 +- examples/1-basic-usage/6-choices-adv.lua | 32 +- examples/1-basic-usage/7-exec.lua | 12 +- examples/1-basic-usage/8-ipc-simple.lua | 21 +- examples/1-basic-usage/9-ipc-server.lua | 22 +- examples/1-basic-usage/9a-ipc-client.lua | 11 +- examples/1-basic-usage/9a-ipc-server.lua | 13 +- examples/1-basic-usage/alarm-examples.lua | 12 +- examples/1-basic-usage/context-examples.lua | 15 +- examples/2-lua-http/cq-http-fiber-test.lua | 69 ++-- src/fibers.lua | 46 ++- src/fibers/alarm.lua | 16 +- src/fibers/channel.lua | 4 +- src/fibers/context.lua | 8 +- src/fibers/fiber.lua | 17 - src/fibers/op.lua | 159 +++++---- src/fibers/performer.lua | 34 +- src/fibers/pollio.lua | 16 +- src/fibers/runtime.lua | 32 +- src/fibers/scope.lua | 357 +++++++++++++++++--- src/fibers/sleep.lua | 8 +- src/fibers/stream.lua | 4 +- src/fibers/waitgroup.lua | 2 +- tests/test.lua | 4 +- tests/test_alarm.lua | 13 +- tests/test_channel.lua | 27 +- tests/test_cond.lua | 22 +- tests/test_context.lua | 19 +- tests/test_exec.lua | 21 +- tests/test_op.lua | 41 +-- tests/test_pollio.lua | 14 +- tests/test_queue.lua | 6 +- tests/{test_fiber.lua => test_runtime.lua} | 20 +- tests/test_scope.lua | 337 ++++++++++++++++-- tests/test_sleep.lua | 19 +- tests/test_stream-file.lua | 25 +- tests/test_stream-socket.lua | 10 +- tests/test_stream.lua | 9 +- tests/test_waitgroup.lua | 13 +- 44 files changed, 1063 insertions(+), 534 deletions(-) delete mode 100644 src/fibers/fiber.lua rename tests/{test_fiber.lua => test_runtime.lua} (71%) diff --git a/examples/1-basic-usage/1-fibers.lua b/examples/1-basic-usage/1-fibers.lua index 0c965a6..ca1c332 100644 --- a/examples/1-basic-usage/1-fibers.lua +++ b/examples/1-basic-usage/1-fibers.lua @@ -5,10 +5,9 @@ -- -- Example ported from Go's Select https://go.dev/tour/concurrency/1 +package.path = "../../src/?.lua;../?.lua;" .. package.path -package.path = "../../?.lua;../?.lua;" .. package.path - -local fiber = require 'fibers.fiber' +local fibers = require 'fibers' local sleep = require 'fibers.sleep' local function say(string) @@ -18,11 +17,7 @@ local function say(string) end end -fiber.spawn(function() - fiber.spawn(function() say("world") end) +fibers.run(function() + fibers.spawn(function() say("world") end) say("hello") - - fiber.stop() end) - -fiber.main() \ No newline at end of file diff --git a/examples/1-basic-usage/10-luacat.lua b/examples/1-basic-usage/10-luacat.lua index ebd71f5..f3f7038 100644 --- a/examples/1-basic-usage/10-luacat.lua +++ b/examples/1-basic-usage/10-luacat.lua @@ -1,7 +1,7 @@ -package.path = "../../?.lua;../?.lua;" .. package.path +package.path = "../../src/?.lua;../?.lua;" .. package.path -- Importing the necessary modules from the fibers framework -local fiber = require 'fibers.fiber' +local fibers = require 'fibers' local file = require 'fibers.stream.file' local socket = require 'fibers.stream.socket' local sc = require 'fibers.utils.syscall' @@ -21,7 +21,7 @@ end local sock = socket.connect_unix(socketPath) -- Fiber to read from stdin and write to the socket -fiber.spawn(function() +local function main() while true do local line = stdin:read('*l') if line then @@ -35,8 +35,7 @@ fiber.spawn(function() -- Close the socket once done stdin:close() sock:close() - fiber.stop() -end) +end -- Start the main fiber loop -fiber.main() +fibers.run(main) diff --git a/examples/1-basic-usage/2-channels.lua b/examples/1-basic-usage/2-channels.lua index 9481ab8..58ee760 100644 --- a/examples/1-basic-usage/2-channels.lua +++ b/examples/1-basic-usage/2-channels.lua @@ -6,10 +6,9 @@ -- -- Example ported from Go's Select https://go.dev/tour/concurrency/2 +package.path = "../../src/?.lua;../?.lua;" .. package.path -package.path = "../../?.lua;../?.lua;" .. package.path - -local fiber = require 'fibers.fiber' +local fibers = require 'fibers' local channel = require 'fibers.channel' local function sum(array, chan) @@ -20,14 +19,13 @@ local function sum(array, chan) chan:put(total) end -fiber.spawn(function() +local function main() local s = {7, 2, 8, -9, 4, 0} local chan = channel.new() - fiber.spawn(function() sum({unpack(s,1,#s/2)}, chan) end) - fiber.spawn(function() sum({unpack(s,#s/2+1,#s)}, chan) end) + fibers.spawn(function() sum({unpack(s,1,#s/2)}, chan) end) + fibers.spawn(function() sum({unpack(s,#s/2+1,#s)}, chan) end) local x, y = chan:get(), chan:get() print(x, y, x+y) - fiber.stop() -end) +end -fiber.main() \ No newline at end of file +fibers.run(main) diff --git a/examples/1-basic-usage/3-queues.lua b/examples/1-basic-usage/3-queues.lua index 6c89b64..df2a4c5 100644 --- a/examples/1-basic-usage/3-queues.lua +++ b/examples/1-basic-usage/3-queues.lua @@ -5,19 +5,17 @@ -- -- Example ported from Go's Select https://go.dev/tour/concurrency/3 +package.path = "../../src/?.lua;../?.lua;" .. package.path -package.path = "../../?.lua;../?.lua;" .. package.path - -local fiber = require 'fibers.fiber' +local fibers = require 'fibers' local queue = require 'fibers.queue' -fiber.spawn(function() +local function main() local q = queue.new(2) q:put(1) q:put(2) print(q:get()) print(q:get()) - fiber.stop() -end) +end -fiber.main() \ No newline at end of file +fibers.run(main) diff --git a/examples/1-basic-usage/4-choices.lua b/examples/1-basic-usage/4-choices.lua index 996b588..b6989c1 100644 --- a/examples/1-basic-usage/4-choices.lua +++ b/examples/1-basic-usage/4-choices.lua @@ -6,13 +6,12 @@ -- Example ported from Go's Select https://go.dev/tour/concurrency/5 -package.path = "../../?.lua;../?.lua;" .. package.path +package.path = "../../src/?.lua;../?.lua;" .. package.path -local fiber = require 'fibers.fiber' +local fibers = require 'fibers' local channel = require 'fibers.channel' -local op = require 'fibers.op' -local perform, choice = require 'fibers.performer'.perform, op.choice +local perform, choice = fibers.perform, fibers.choice local function fibonacci(c, quit) local x, y = 0, 1 @@ -31,17 +30,16 @@ local function fibonacci(c, quit) until done end -fiber.spawn(function() +local function main() local c = channel.new() local quit = channel.new() - fiber.spawn(function() + fibers.spawn(function() for _=1, 10 do print(c:get()) end quit:put(0) end) fibonacci(c, quit) - fiber.stop() -end) +end -fiber.main() +fibers.run(main) diff --git a/examples/1-basic-usage/5-choices-alt.lua b/examples/1-basic-usage/5-choices-alt.lua index e558325..95c9eb3 100644 --- a/examples/1-basic-usage/5-choices-alt.lua +++ b/examples/1-basic-usage/5-choices-alt.lua @@ -4,19 +4,18 @@ -- -- Example ported from Go's Select https://go.dev/tour/concurrency/6 -package.path = "../../?.lua;../?.lua;" .. package.path +package.path = "../../src/?.lua;../?.lua;" .. package.path -local fiber = require 'fibers.fiber' +local fibers = require 'fibers' local channel = require 'fibers.channel' local sleep = require 'fibers.sleep' -local op = require 'fibers.op' -local perform, choice = require 'fibers.performer'.perform, op.choice +local perform, choice = fibers.perform, fibers.choice -- time.After() is a Go library function local function after(t) local chan = channel.new() - fiber.spawn(function() + fibers.spawn(function() sleep.sleep(t) chan:put(1) end) @@ -26,7 +25,7 @@ end -- time.Tick() is a Go library function local function tick(t) local chan = channel.new() - fiber.spawn(function() + fibers.spawn(function() while true do sleep.sleep(t) chan:put(1) @@ -35,7 +34,7 @@ local function tick(t) return chan end -fiber.spawn(function() +local function main() local ticker = tick(0.1) local boom = after(0.5) local done = false @@ -54,8 +53,6 @@ fiber.spawn(function() end) perform(task) until done +end - fiber.stop() -end) - -fiber.main() +fibers.run(main) diff --git a/examples/1-basic-usage/6-choices-adv.lua b/examples/1-basic-usage/6-choices-adv.lua index 0a1be4f..c665c9a 100644 --- a/examples/1-basic-usage/6-choices-adv.lua +++ b/examples/1-basic-usage/6-choices-adv.lua @@ -1,17 +1,16 @@ -package.path = "../../?.lua;../?.lua;" .. package.path +package.path = "../../src/?.lua;../?.lua;" .. package.path -- Importing the necessary modules -local fiber = require 'fibers.fiber' +local fibers = require 'fibers' local sleep = require 'fibers.sleep' local channel = require 'fibers.channel' local queues = require 'fibers.queue' local socket = require 'fibers.stream.socket' local file = require 'fibers.stream.file' local cond = require 'fibers.cond' -local op = require 'fibers.op' local sc = require 'fibers.utils.syscall' -local perform, choice = require 'fibers.performer'.perform, op.choice +local perform, choice = fibers.perform, fibers.choice require("fibers.pollio").install_poll_io_handler() @@ -21,37 +20,37 @@ local notif_chan = channel.new() local exit_cond = cond.new() -- Data Producer fiber -fiber.spawn(function() +local function producer() while true do -- sleep for some time to simulate work sleep.sleep(math.random()) -- Send data to the queue data_q:put(os.date('%Y-%m-%d %H:%M:%S')) end -end) +end -- Notifier fiber -fiber.spawn(function() +local function notifier() while true do -- sleep for some time to simulate work sleep.sleep(4 * math.random()) -- Send data to the channel notif_chan:put(1) end -end) +end -- Exit signaller -fiber.spawn(function() +local function exit() while true do -- sleep for some time to simulate work sleep.sleep(30 * math.random()) -- Signal the condition exit_cond:signal() end -end) +end -- Consumer fiber -fiber.spawn(function() +local function consumer() -- file to write data to local filename = "/tmp/data.txt" os.execute("rm "..filename) @@ -84,6 +83,13 @@ fiber.spawn(function() ) perform(task) end -end) +end -fiber.main() +local function main() + fibers.spawn(producer) + fibers.spawn(notifier) + fibers.spawn(exit) + consumer() +end + +fibers.run(main) diff --git a/examples/1-basic-usage/7-exec.lua b/examples/1-basic-usage/7-exec.lua index 39d2351..1296d30 100644 --- a/examples/1-basic-usage/7-exec.lua +++ b/examples/1-basic-usage/7-exec.lua @@ -1,8 +1,8 @@ -- usage of the fibers.exec module -package.path = "../../?.lua;../?.lua;" .. package.path +package.path = "../../src/?.lua;../?.lua;" .. package.path -local fiber = require 'fibers.fiber' +local fibers = require 'fibers' local sleep = require 'fibers.sleep' local exec = require 'fibers.exec' local pollio = require 'fibers.pollio' @@ -10,13 +10,13 @@ local pollio = require 'fibers.pollio' pollio.install_poll_io_handler() local function main() - fiber.spawn(function() -- long running process where we want to periodically deal with output + fibers.spawn(function() -- long running process where we want to periodically deal with output local cmd = exec.command('cat') local stdin_pipe = assert(cmd:stdin_pipe()) local stdout_pipe = assert(cmd:stdout_pipe()) local err = cmd:start() if err then error(err) end - fiber.spawn(function() + fibers.spawn(function() for _ = 1, 4 do stdin_pipe:write('tick') sleep.sleep(0.2) @@ -38,8 +38,6 @@ local function main() local output, err = exec.command('sh', '-c', 'sleep 1; echo hello world; exit 255'):combined_output() assert(err, "expected error!") print("output:", output) - fiber.stop() end -fiber.spawn(main) -fiber.main() +fibers.run(main) diff --git a/examples/1-basic-usage/8-ipc-simple.lua b/examples/1-basic-usage/8-ipc-simple.lua index b07e4e8..661c3ed 100644 --- a/examples/1-basic-usage/8-ipc-simple.lua +++ b/examples/1-basic-usage/8-ipc-simple.lua @@ -1,8 +1,8 @@ -- demonstrates IPC using exec and non-blocking sockets -package.path = "../../?.lua;../?.lua;" .. package.path +package.path = "../../src/?.lua;../?.lua;" .. package.path -local fiber = require "fibers.fiber" +local fibers = require "fibers" local exec = require "fibers.exec" local sleep = require "fibers.sleep" local socket = require 'fibers.stream.socket' @@ -15,7 +15,7 @@ local sockname = '/tmp/test-sock' sc.unlink(sockname) local server = assert(socket.listen_unix(sockname)) -fiber.spawn(function() +local function receiver() sleep.sleep(2) -- to show that things don't block and are gracefully buffered by sockets while true do @@ -29,10 +29,9 @@ fiber.spawn(function() end end sc.unlink(sockname) - fiber.stop() -end) +end -fiber.spawn(function() +local function sender() local messages = { "apple", "pear", "exit!" } for _, v in ipairs(messages) do print("sending:", v) @@ -41,13 +40,15 @@ fiber.spawn(function() local out, err = exec.command('sh', '-c', command):combined_output() if err then error(out) end end -end) +end -fiber.spawn(function() +local function main() + fibers.spawn(sender) + fibers.spawn(receiver) while true do print("hb") sleep.sleep(1) end -end) +end -fiber.main() +fibers.run(main) diff --git a/examples/1-basic-usage/9-ipc-server.lua b/examples/1-basic-usage/9-ipc-server.lua index ef96b41..803c4b7 100644 --- a/examples/1-basic-usage/9-ipc-server.lua +++ b/examples/1-basic-usage/9-ipc-server.lua @@ -3,10 +3,10 @@ can handle multiple clients. ]] -package.path = "../../?.lua;../?.lua;" .. package.path +package.path = "../../src/?.lua;../?.lua;" .. package.path -- Importing necessary modules -local fiber = require "fibers.fiber" +local fibers = require "fibers" local sleep = require "fibers.sleep" local socket = require 'fibers.stream.socket' local sc = require 'fibers.utils.syscall' @@ -24,7 +24,7 @@ sc.unlink(sockname) local server = assert(socket.listen_unix(sockname)) -- Spawn a fiber to handle incoming connections -fiber.spawn(function () +local function start_server() while true do -- Accept a new connection local peer, err = assert(server:accept()) @@ -35,7 +35,7 @@ fiber.spawn(function () end -- Spawn a new fiber for each connection to handle client communication - fiber.spawn(function() + fibers.spawn(function() while true do -- Read a line from the connected client local rec = peer:read_line() @@ -59,17 +59,21 @@ fiber.spawn(function () end -- After the server is stopped, remove the socket file and stop the fiber sc.unlink(sockname) - fiber.stop() -end) +end -- Spawn another fiber to periodically print a heartbeat message -fiber.spawn(function () +local function heartbeat() while true do print("hb") -- Sleep for 1 second before printing the next heartbeat sleep.sleep(1) end -end) +end + +local function main() + fibers.spawn(start_server) + fibers.spawn(heartbeat) +end -- Start the main fiber loop -fiber.main() +fibers.run(main) diff --git a/examples/1-basic-usage/9a-ipc-client.lua b/examples/1-basic-usage/9a-ipc-client.lua index b126e3a..557dff8 100644 --- a/examples/1-basic-usage/9a-ipc-client.lua +++ b/examples/1-basic-usage/9a-ipc-client.lua @@ -1,9 +1,9 @@ package.path = "../?.lua;" .. package.path .. ";/usr/lib/lua/?.lua;/usr/lib/lua/?/init.lua" -package.path = "../../?.lua;../?.lua;" .. package.path +package.path = "../../src/?.lua;../?.lua;" .. package.path -- Importing necessary modules -local fiber = require "fibers.fiber" +local fibers = require "fibers" local socket = require 'fibers.stream.socket' -- Install a polling I/O handler from the fibers library @@ -15,15 +15,14 @@ local json_str = arg[1] -- Define the path for the Unix domain socket local sockname = '/tmp/ntpd-sock' -fiber.spawn(function () +local function main() local client = socket.connect_unix(sockname) client:setvbuf('no') client:write(json_str) client:close() - fiber.stop() -end) +end -- Start the main fiber loop -fiber.main() \ No newline at end of file +fibers.run(main) diff --git a/examples/1-basic-usage/9a-ipc-server.lua b/examples/1-basic-usage/9a-ipc-server.lua index 824ed66..369bb2d 100644 --- a/examples/1-basic-usage/9a-ipc-server.lua +++ b/examples/1-basic-usage/9a-ipc-server.lua @@ -4,10 +4,10 @@ ]] package.path = "../?.lua;" .. package.path .. ";/usr/lib/lua/?.lua;/usr/lib/lua/?/init.lua" -package.path = "../../?.lua;../?.lua;" .. package.path +package.path = "../../src/?.lua;../?.lua;" .. package.path -- Importing necessary modules -local fiber = require "fibers.fiber" +local fibers = require "fibers" local socket = require 'fibers.stream.socket' local sc = require 'fibers.utils.syscall' @@ -21,7 +21,7 @@ local sockname = '/tmp/ntpd-sock' sc.unlink(sockname) -- Spawn a fiber to handle incoming connections -fiber.spawn(function () +local function main() -- Create and start listening on the Unix domain socket local server = assert(socket.listen_unix(sockname)) @@ -36,7 +36,7 @@ fiber.spawn(function () end -- Spawn a new fiber for each connection to handle client communication - fiber.spawn(function() + fibers.spawn(function() while true do -- Read a line from the connected client local rec = peer:read_line() @@ -56,8 +56,7 @@ fiber.spawn(function () end -- After the server is stopped, remove the socket file and stop the fiber sc.unlink(sockname) - fiber.stop() -end) +end -- Start the main fiber loop -fiber.main() +fibers.run(main) diff --git a/examples/1-basic-usage/alarm-examples.lua b/examples/1-basic-usage/alarm-examples.lua index bd1e747..b191dbb 100644 --- a/examples/1-basic-usage/alarm-examples.lua +++ b/examples/1-basic-usage/alarm-examples.lua @@ -1,6 +1,6 @@ -package.path = "../../?.lua;../?.lua;" .. package.path +package.path = "../../src/?.lua;../?.lua;" .. package.path -local fiber = require 'fibers.fiber' +local fibers = require 'fibers' local alarm = require 'fibers.alarm' local sc = require 'fibers.utils.syscall' @@ -20,7 +20,7 @@ local function set_alarm(t, number) print("alarm "..number, "fired at:", os.date("%A %d %B %Y at %H:%M:", sec)..os.date("%S", sec) + nsec/1e9) end -fiber.spawn(function () +local function main() local _, sec, nsec = sc.realtime() print("Time now:", os.date("%A %d %B %Y at %H:%M:", sec)..os.date("%S", sec) + nsec/1e9, os.date("TZ: %z:", sec)) @@ -36,10 +36,10 @@ fiber.spawn(function () } for i, j in ipairs(alarm_times) do - fiber.spawn(function () + fibers.spawn(function () set_alarm(j, i) end) end -end) +end -fiber.main() \ No newline at end of file +fibers.run(main) diff --git a/examples/1-basic-usage/context-examples.lua b/examples/1-basic-usage/context-examples.lua index d630647..e849e63 100644 --- a/examples/1-basic-usage/context-examples.lua +++ b/examples/1-basic-usage/context-examples.lua @@ -1,9 +1,8 @@ package.path = "../../?.lua;../?.lua;" .. package.path -local fiber = require 'fibers.fiber' +local fibers = require 'fibers' local context = require 'fibers.context' local sleep = require 'fibers.sleep' -local op = require 'fibers.op' local perform = require 'fibers.performer'.perform @@ -17,7 +16,7 @@ end -- Sub-Task 1: Canceled by timeout local function sub_task_1(ctx) local deadline_ctx, cancel = context.with_timeout(ctx, 5) -- Timeout after 5 seconds - fiber.spawn(function() + fibers.spawn(function() do_work("Sub-Task 1", 1) -- Simulates work for 10 seconds cancel("work_completed") -- Cancel the context (optional, as it will timeout) end) @@ -29,7 +28,7 @@ end -- Sub-Task 2: Waits for cancellation from the main task local function sub_task_2(ctx) local cancel_ctx, cancel = context.with_cancel(ctx) - fiber.spawn(function() + fibers.spawn(function() do_work("Sub-Task 2", 3) -- Simulates longer work cancel("work_completed") -- Cancel the context when work is done end) @@ -42,14 +41,12 @@ end local function main() local root_ctx = context.background() -- Root context local main_ctx, cancel = context.with_cancel(root_ctx) -- Root context - fiber.spawn(function() sub_task_1(main_ctx) end) - fiber.spawn(function() sub_task_2(main_ctx) end) + fibers.spawn(function() sub_task_1(main_ctx) end) + fibers.spawn(function() sub_task_2(main_ctx) end) sleep.sleep(2) -- Main task waits for 2 seconds before canceling Sub-Task 2 cancel("main_canceled") -- This will propagate to Sub-Task 2 sleep.sleep(1) -- Main task waits for 8 seconds before canceling Sub-Task 2 - fiber.stop() end -fiber.spawn(main) -fiber.main() +fibers.run(main) diff --git a/examples/2-lua-http/cq-http-fiber-test.lua b/examples/2-lua-http/cq-http-fiber-test.lua index 13523f0..36f3182 100644 --- a/examples/2-lua-http/cq-http-fiber-test.lua +++ b/examples/2-lua-http/cq-http-fiber-test.lua @@ -4,9 +4,9 @@ print("starting lua-http test") package.path="./?.lua;/usr/share/lua/?.lua;/usr/share/lua/?/init.lua;/usr/lib/lua/?.lua;/usr/lib/lua/?/init.lua" .. package.path -package.path = "../../?.lua;../?.lua;" .. package.path +package.path = "../../src/?.lua;../?.lua;" .. package.path -local fiber = require "fibers.fiber" +local fibers = require "fibers" local op = require "fibers.op" local pollio = require "fibers.pollio" local sleep = require "fibers.sleep" @@ -24,7 +24,7 @@ require 'fibers.stream.compat'.install() print("overriding cqueues step") local old_step; old_step = cqueues.interpose("step", function(self, timeout) if cqueues.running() then - fiber.yield() + sleep.sleep(0) return old_step(self, timeout) else -- local t = self:timeout() or math.huge @@ -96,8 +96,8 @@ local myserver = assert(http_server.listen { -- Override :add_stream to call onstream in a new fiber (instead of new cqueues coroutine) print("overriding server's 'add_stream' method") function myserver:add_stream(stream) - fiber.spawn(function() - fiber.yield() -- want to be called from main loop; not from :add_stream callee + fibers.spawn(function() + sleep.sleep(0) -- want to be called from main loop; not from :add_stream callee local ok, err = http_util.yieldable_pcall(self.onstream, self, stream) stream:shutdown() if not ok then @@ -106,36 +106,39 @@ function myserver:add_stream(stream) end) end --- Run server in its own lua-fiber -print("spawning server") -fiber.spawn(function() - print("starting server") - assert(myserver:loop()) -end) +local function main() + -- Run server in its own lua-fiber + print("spawning server") + fibers.spawn(function() + print("starting server") + assert(myserver:loop()) + end) --- Start another fiber that just prints+sleeps in a loop to show off non-blocking-ness of http server -print("spawning heartbeat") -fiber.spawn(function() - print("starting heartbeat") - while true do - print("slow heartbeat") - sleep.sleep(1) - end -end) + -- Start another fiber that just prints+sleeps in a loop to show off non-blocking-ness of http server + print("spawning heartbeat") + fibers.spawn(function() + print("starting heartbeat") + while true do + print("slow heartbeat") + sleep.sleep(1) + end + end) --- And one more to show multiple epolls in action -print("spawning popen fiber") -fiber.spawn(function() - local cmd = exec.command('sh', '-c', 'while true; do echo "non-http fd input received!"; sleep 5; done') - local stdout_pipe = assert(cmd:stdout_pipe()) - local err = cmd:start() - if err then error(err) end - while true do - local received = stdout_pipe:read_line() - print(received) - end + -- And one more to show multiple epolls in action + print("spawning popen fiber") + fibers.spawn(function() + local cmd = exec.command('sh', '-c', 'while true; do echo "non-http fd input received!"; sleep 5; done') + local stdout_pipe = assert(cmd:stdout_pipe()) + local err = cmd:start() + if err then error(err) end + while true do + local received = stdout_pipe:read_line() + print(received) + end + + end) +end -end) print("starting fibers") -fiber.main() +fibers.run(main) diff --git a/src/fibers.lua b/src/fibers.lua index f823435..8a61e1b 100644 --- a/src/fibers.lua +++ b/src/fibers.lua @@ -1,14 +1,14 @@ -- fibers.lua --- --- Top-level façade for the fibers library. +-- Top-level facade for the fibers library. -- Provides a small, convenient surface over the lower-level modules: --- - runtime (scheduler and fibres), +-- - runtime (scheduler and fibers), -- - op (CML engine), -- - scope (structured concurrency), -- - primitives (sleep, channel, etc.). -- -- At this stage, scopes carry no policies; they simply form a tree --- and track the current scope per fibre. +-- and track the current scope per fiber. -- -- @module fibers @@ -21,17 +21,15 @@ local channel = require 'fibers.channel' local op = require 'fibers.op' local unpack = rawget(table, "unpack") or _G.unpack +local pack = rawget(table, "pack") or function(...) + return { n = select("#", ...), ... } +end local fibers = {} ----------------------------------------------------------------------- --- Core execution ----------------------------------------------------------------------- - ---- Perform an event under the current scope. +-- Perform an event under the current scope. fibers.perform = performer.perform ---- Current monotonic time. fibers.now = runtime.now --- Run a main function under the scheduler's root scope. @@ -40,39 +38,37 @@ function fibers.run(main_fn, ...) local root = scope_mod.root() local args = { ... } - -- Spawn an initial fibre under the root scope. - root:spawn(function(s) - main_fn(s, unpack(args)) - -- When main_fn returns, stop the scheduler loop. + root:spawn(function() + -- Run main_fn inside a child scope of the current scope (root). + local res = pack( + pcall(function() + return scope_mod.run(main_fn, unpack(args)) + end) + ) + -- In all cases, stop the scheduler so runtime.main() returns. runtime.stop() + -- If the main scope failed, treat as fatal for the process. + if not res[1] then + print(unpack(res, 2, res.n)) + os.exit(255) + end end) - -- Drive the scheduler until stop() is called. runtime.main() end ---- Spawn a fibre under the current scope. --- fn :: function(Scope, ...): () +--- Spawn a fiber under the current scope. function fibers.spawn(fn, ...) local s = scope_mod.current() return s:spawn(fn, ...) end ----------------------------------------------------------------------- --- Common primitives ----------------------------------------------------------------------- - fibers.sleep = sleep_mod.sleep fibers.channel = channel.new ----------------------------------------------------------------------- --- Scopes and ops for advanced use ----------------------------------------------------------------------- - fibers.scope = scope_mod fibers.op = op --- Re-export a few core CML combinators for convenience. fibers.choice = op.choice fibers.guard = op.guard fibers.with_nack = op.with_nack diff --git a/src/fibers/alarm.lua b/src/fibers/alarm.lua index bbd892f..2281720 100644 --- a/src/fibers/alarm.lua +++ b/src/fibers/alarm.lua @@ -3,7 +3,7 @@ -- Alarms. local op = require 'fibers.op' -local fiber = require 'fibers.fiber' +local runtime = require 'fibers.runtime' local timer = require 'fibers.timer' local sc = require 'fibers.utils.syscall' @@ -121,7 +121,7 @@ local installed_alarm_handler = nil local function install_alarm_handler() if not installed_alarm_handler then installed_alarm_handler = new_alarm_handler() - fiber.current_scheduler:add_task_source(installed_alarm_handler) + runtime.current_scheduler:add_task_source(installed_alarm_handler) end return installed_alarm_handler end @@ -130,9 +130,9 @@ end -- This should be called to clean up when the Alarm Handler is no longer needed. local function uninstall_alarm_handler() if installed_alarm_handler then - for i, source in ipairs(fiber.current_scheduler.sources) do + for i, source in ipairs(runtime.current_scheduler.sources) do if source == installed_alarm_handler then - table.remove(fiber.current_scheduler.sources, i) + table.remove(runtime.current_scheduler.sources, i) break end end @@ -154,8 +154,8 @@ function AlarmHandler:schedule_tasks(sched) end function AlarmHandler:block(time_to_start, t, task) - if time_to_start < fiber.current_scheduler.maxsleep then - fiber.current_scheduler:schedule_after_sleep(time_to_start, task) + if time_to_start < runtime.current_scheduler.maxsleep then + runtime.current_scheduler:schedule_after_sleep(time_to_start, task) else self.abs_timer:add_absolute(t, task) end @@ -196,7 +196,7 @@ function AlarmHandler:wait_absolute_op(t) end self:block(time_to_start, t, task) end - return op.new_base_op(nil, try, block) + return op.new_primitive(nil, try, block) end function AlarmHandler:wait_next_op(t) @@ -212,7 +212,7 @@ function AlarmHandler:wait_next_op(t) local target, _ = calculate_next(t, now) self:block(target-now, target, task) end - return op.new_base_op(nil, try, block) + return op.new_primitive(nil, try, block) end --- Indicates to the Alarm Handler that time synchronisation has been achieved (through NTP or other methods). diff --git a/src/fibers/channel.lua b/src/fibers/channel.lua index 22173b9..f665da3 100644 --- a/src/fibers/channel.lua +++ b/src/fibers/channel.lua @@ -66,7 +66,7 @@ function Channel:put_op(val) -- No space in buffer and no receivers, so block putq:push({ suspension = suspension, wrap = wrap_fn, val = val }) end - return op.new_base_op(nil, try, block) + return op.new_primitive(nil, try, block) end --- Create a get operation for the Channel. @@ -108,7 +108,7 @@ function Channel:get_op() -- Block this receiver getq:push({ suspension = suspension, wrap = wrap_fn }) end - return op.new_base_op(nil, try, block) + return op.new_primitive(nil, try, block) end --- Put a message into the Channel. diff --git a/src/fibers/context.lua b/src/fibers/context.lua index 7797b46..b8fb040 100644 --- a/src/fibers/context.lua +++ b/src/fibers/context.lua @@ -13,7 +13,7 @@ --- cancellation. -- @module context -local fiber = require "fibers.fiber" +local runtime = require "fibers.runtime" local op = require "fibers.op" local cond = require "fibers.cond" @@ -45,7 +45,7 @@ function base_context:done_op() return self.parent:done_op() else -- Background context: never cancelled, so done_op never completes. - return op.new_base_op(nil, function() return false end, function() end) + return op.new_primitive(nil, function() return false end, function() end) end end @@ -165,7 +165,7 @@ end -- @return The new with_deadline_context and a function to cancel it. local function with_deadline(parent, deadline) local ctx, cancel_fn = with_cancel(parent) - fiber.current_scheduler:schedule_at_time(deadline, { run = function() ctx:cancel("deadline_exceeded") end }) + runtime.current_scheduler:schedule_at_time(deadline, { run = function() ctx:cancel("deadline_exceeded") end }) return ctx, cancel_fn end @@ -174,7 +174,7 @@ end -- @param The timeout at which the context will be cancelled. -- @return The new with_timeout_context and a function to cancel it. local function with_timeout(parent, timeout) - return with_deadline(parent, fiber.now() + timeout) + return with_deadline(parent, runtime.now() + timeout) end --- Returns a value_context that stores a key/value pair. diff --git a/src/fibers/fiber.lua b/src/fibers/fiber.lua deleted file mode 100644 index 2fe511f..0000000 --- a/src/fibers/fiber.lua +++ /dev/null @@ -1,17 +0,0 @@ --- fibers/fiber.lua ---- --- Compatibility wrapper around fibers.runtime. --- Existing code can continue to require 'fibers.fiber'. --- @module fibers.fiber - -local runtime = require 'fibers.runtime' - -return { - current_scheduler = runtime.current_scheduler, - spawn = runtime.spawn, - now = runtime.now, - suspend = runtime.suspend, - yield = runtime.yield, - stop = runtime.stop, - main = runtime.main, -} diff --git a/src/fibers/op.lua b/src/fibers/op.lua index cf21866..c51599b 100644 --- a/src/fibers/op.lua +++ b/src/fibers/op.lua @@ -118,7 +118,7 @@ local perform -- 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) +local function new_primitive(wrap_fn, try_fn, block_fn) return setmetatable( { kind = 'prim', @@ -157,42 +157,31 @@ local function with_nack(g) return setmetatable({ kind = 'with_nack', builder = g }, Event) end --- next_turn_op: primitive event that is never ready in the fast path; --- if forced to block, it completes itself on the *next scheduler turn*. -local function next_turn_op() +local function always(...) + local results = { ... } local function try() - return false + return true, unpack(results) end - - local function block(suspension, wrap_fn) - local task = suspension:complete_task(wrap_fn) - suspension.sched:schedule(task) - end - - return new_base_op(nil, try, block) + local function block() error("always: block_fn should never run") end -- never reached + return new_primitive(nil, try, block) end -local function always(value) - return new_base_op(nil, - function() return true, value end, - function() error("always: block_fn should never run") end) -end local function never() -- An event that never becomes ready - return new_base_op(nil, + return new_primitive(nil, function() return false end, function() end) end -function Event:or_else(fallback_thunk) - return choice( - self, - next_turn_op():wrap(function() - return fallback_thunk() - end) - ) -end +-- function Event:or_else(fallback_thunk) +-- return choice( +-- self, +-- next_turn_op():wrap(function() +-- return fallback_thunk() +-- end) +-- ) +-- end -- Wrap event with a post-processing function f (commit phase). -- This is another node in the tree; composed at compile time. @@ -247,24 +236,23 @@ local function new_cond(opts) suspension:complete_task(wrap_fn) end end - return new_base_op(nil, try, block) + return new_primitive(nil, try, block) end local function signal() if state.triggered then return end state.triggered = true - -- wake waiters, if any for i = 1, #state.waiters do local task = state.waiters[i] state.waiters[i] = nil if task - and task.suspension - and task.suspension:waiting() + and task.suspension + and task.suspension:waiting() then - task.suspension.sched:schedule(task) + -- Run the completion *now*, in this turn. + task:run() end end - -- fire abort handler, if any if state.abort_fn then pcall(state.abort_fn) end @@ -341,40 +329,34 @@ local function compile_event(ev, outer_wrap, out, nacks, handlers) compile_event(ev.inner, outer_wrap, out, nacks, child_handlers) else -- 'prim' - -- Core wrap chain for this leaf (no exception handling yet). - local function core(...) - return outer_wrap(ev.wrap_fn(...)) - end + local function wrapped(...) + local function core(...) + return outer_wrap(ev.wrap_fn(...)) + end - -- If there are handlers, wrap the core in them, innermost first. - local wrapped = core - if #handlers > 0 then - for i = #handlers, 1, -1 do - local h = handlers[i] - local prev = wrapped - wrapped = function(...) - local res = pack(pcall(prev, ...)) - local ok = res[1] - if ok then - return unpack(res, 2, res.n) + local f = core + if #handlers > 0 then + for i = #handlers, 1, -1 do + local h = handlers[i] + local prev = f + f = function(...) + local res = pack(pcall(prev, ...)) + if res[1] then + return unpack(res, 2, res.n) + end + local ex = res[2] + local hev = h(ex) + return perform(hev) end - -- res[2] is the exception (CML: pass exn to handler) - local ex = res[2] - local hev = h(ex) - -- Handler returns an event; synchronise on it. - return perform(hev) end end - end - - local final_wrap = function(...) - return wrapped(...) + return f(...) end out[#out + 1] = { try_fn = ev.try_fn, block_fn = ev.block_fn, - wrap = final_wrap, + wrap = wrapped, nacks = nacks, } end @@ -392,28 +374,30 @@ end -- - For each loser leaf, signal any nacks not in the winner set. -- - Each cond object is responsible for idempotence. local function trigger_nacks(ops, winner_index) - assert(winner_index, "trigger_nacks: no winner_index (internal error)") - - local winner_set = {} - local wnacks = ops[winner_index].nacks - if wnacks then - for i = 1, #wnacks do - winner_set[wnacks[i]] = true + local winner_set + if winner_index then + winner_set = {} + local wnacks = ops[winner_index].nacks + if wnacks then + for i = 1, #wnacks do + winner_set[wnacks[i]] = true + end end end - local signaled = {} + local function is_winner_cond(cond) + return winner_set and winner_set[cond] or false + end + + local signalled = {} for i = 1, #ops do - if i ~= winner_index then + if not winner_index or i ~= winner_index then local nacks = ops[i].nacks if nacks then for j = #nacks, 1, -1 do local cond = nacks[j] - if cond - and not winner_set[cond] - and not signaled[cond] - then - signaled[cond] = true + if cond and not is_winner_cond(cond) and not signalled[cond] then + signalled[cond] = true cond.signal() end end @@ -444,6 +428,31 @@ local function apply_wrap(wrap, retval) return wrap(unpack(retval, 2, retval.n)) end +function Event:or_else(fallback_thunk) + assert(type(fallback_thunk) == "function", "or_else expects a function") + + return guard(function() + -- Compile `self` once for this synchronisation. + local leaves = compile_event(self) + + -- Non-blocking attempt to commit to `self`. + local idx, retval = try_ready(leaves) + if idx then + -- Normal CML semantics: `self` wins, fire nacks for losers. + trigger_nacks(leaves, idx) + local results = { apply_wrap(leaves[idx].wrap, retval) } + return always(unpack(results)) + end + + -- No leaf of `self` is ready now → `self` loses as a whole. + -- Fire all nacks/abort handlers hanging off `self`. + trigger_nacks(leaves, nil) + + local results = { fallback_thunk() } + return always(unpack(results)) + end) +end + ---------------------------------------------------------------------- -- Blocking choice path ---------------------------------------------------------------------- @@ -463,17 +472,18 @@ end perform = function(ev) local leaves = compile_event(ev) - -- Fast path + -- Fast path: try once using the same semantics as fast_commit(). local idx, retval = try_ready(leaves) if idx then trigger_nacks(leaves, idx) return apply_wrap(leaves[idx].wrap, retval) end - -- Slow path + -- Slow path: we now block all leaves using block_choice_op. local suspended = pack(runtime.suspend(block_choice_op, leaves)) local wrap = suspended[1] + -- Identify winning leaf by its wrap function. local winner_index for i, leaf in ipairs(leaves) do if leaf.wrap == wrap then @@ -568,7 +578,7 @@ end return { perform = perform, perform_raw = perform, - new_base_op = new_base_op, -- primitive event constructor + new_primitive = new_primitive, -- primitive event constructor choice = choice, guard = guard, with_nack = with_nack, @@ -576,5 +586,6 @@ return { bracket = bracket, always = always, never = never, + Event = Event, -- Event instances have methods: wrap, on_abort. } diff --git a/src/fibers/performer.lua b/src/fibers/performer.lua index dc5338f..269ec62 100644 --- a/src/fibers/performer.lua +++ b/src/fibers/performer.lua @@ -1,28 +1,32 @@ -- fibers/performer.lua --- -- Scope-aware performer. --- This is the preferred entry point for synchronising on events --- in normal code. It delegates to the current scope (or the root --- scope) and uses the raw op.perform under the hood. --- --- Policies (failure, cancellation, etc.) will be wired into the --- Scope methods in later stages. +-- Preferred entry point for synchronising on events in normal code. +-- Delegates to the current scope if available, otherwise falls back +-- to the raw op.perform. -- -- @module fibers.performer -local scope = require 'fibers.scope' -local op = require 'fibers.op' +local op = require 'fibers.op' + +local scope_mod local M = {} ---- Perform an event under the current scope. --- For now this simply calls the raw op.perform; in later stages --- Scope:sync can wrap events with policies before performing. +local function current_scope() + if not scope_mod then + scope_mod = require 'fibers.scope' + end + return scope_mod.current and scope_mod.current() or nil +end + function M.perform(ev) - -- At this stage, scopes do not transform events, so we just - -- ensure there *is* a scope and call the raw engine. - local _ = scope.current() -- touch to ensure root initialises - return op.perform(ev) + local s = current_scope() + if s and s.sync then + return s:sync(ev) + else + return op.perform_raw(ev) + end end return M diff --git a/src/fibers/pollio.lua b/src/fibers/pollio.lua index e8c2ddd..37cd2cc 100644 --- a/src/fibers/pollio.lua +++ b/src/fibers/pollio.lua @@ -3,12 +3,12 @@ -- File events. local op = require 'fibers.op' -local fiber = require 'fibers.fiber' +local runtime = require 'fibers.runtime' local epoll = require 'fibers.epoll' local sc = require 'fibers.utils.syscall' local bit = rawget(_G, "bit") or require 'bit32' -local perform = op.perform +local perform = require 'fibers.performer'.perform local PollIOHandler = {} PollIOHandler.__index = PollIOHandler @@ -58,14 +58,14 @@ function PollIOHandler:fd_readable_op(fd) local function try() return false end local block = make_block_fn( fd, self.waiting_for_readable, self.epoll, epoll.RD) - return op.new_base_op(nil, try, block) + return op.new_primitive(nil, try, block) end function PollIOHandler:fd_writable_op(fd) local function try() return false end local block = make_block_fn( fd, self.waiting_for_writable, self.epoll, epoll.WR) - return op.new_base_op(nil, try, block) + return op.new_primitive(nil, try, block) end function PollIOHandler:stream_readable_op(stream) @@ -73,7 +73,7 @@ function PollIOHandler:stream_readable_op(stream) local function try() return not stream.rx:is_empty() end local block = make_block_fn( fd, self.waiting_for_readable, self.epoll, epoll.RD) - return op.new_base_op(nil, try, block) + return op.new_primitive(nil, try, block) end -- A stream_writable_op is the same as fd_writable_op, as a stream's @@ -146,7 +146,7 @@ local function install_poll_io_handler() if installed == 1 then installed_poll_handler = new_poll_io_handler() -- file.set_blocking_handler(installed_poll_handler) - fiber.current_scheduler:add_task_source(installed_poll_handler) + runtime.current_scheduler:add_task_source(installed_poll_handler) end return installed_poll_handler end @@ -156,9 +156,9 @@ local function uninstall_poll_io_handler() if installed == 0 then -- file.set_blocking_handler(nil) -- FIXME: Remove task source. - for i, source in ipairs(fiber.current_scheduler.sources) do + for i, source in ipairs(runtime.current_scheduler.sources) do if source == installed_poll_handler then - table.remove(fiber.current_scheduler.sources, i) + table.remove(runtime.current_scheduler.sources, i) break end end diff --git a/src/fibers/runtime.lua b/src/fibers/runtime.lua index 12a1981..055351a 100644 --- a/src/fibers/runtime.lua +++ b/src/fibers/runtime.lua @@ -7,7 +7,7 @@ local sched = require 'fibers.sched' -local current_fiber +local _current_fiber local current_scheduler = sched.new() --- Fiber class @@ -23,8 +23,8 @@ local function spawn(fn) -- Capture the traceback local tb = debug.traceback("", 2):match("\n[^\n]*\n(.*)") or "" -- If we're inside another fiber, append the traceback to the parent's traceback - if current_fiber and current_fiber.traceback then - tb = tb .. "\n" .. current_fiber.traceback + if _current_fiber and _current_fiber.traceback then + tb = tb .. "\n" .. _current_fiber.traceback end current_scheduler:schedule( @@ -42,12 +42,12 @@ end -- @tparam vararg ... The arguments to pass to the fiber. function Fiber:resume(wrap, ...) assert(self.alive, "dead fiber") -- checks that the fiber is alive - local saved_current_fiber = current_fiber -- shift the old current fiber into a safe place - current_fiber = self -- we are the new current fiber + local saved_current_fiber = _current_fiber -- shift the old current fiber into a safe place + _current_fiber = self -- we are the new current fiber local ok, err = coroutine.resume(self.coroutine, wrap, ...) -- rev up our coroutine -- current_fiber = saved_current_fiber the KEY bit, we only get here when the coroutine above has yielded, -- but we then pop back in the fiber we previously displaced - current_fiber = saved_current_fiber + _current_fiber = saved_current_fiber if not ok then print('Error while running fiber: ' .. tostring(err)) print(debug.traceback(self.coroutine)) @@ -59,10 +59,10 @@ end Fiber.run = Fiber.resume function Fiber:suspend(block_fn, ...) - assert(current_fiber == self) + assert(_current_fiber == self) -- The block_fn should arrange to reschedule the fiber when it -- becomes runnable. - block_fn(current_scheduler, current_fiber, ...) + block_fn(current_scheduler, _current_fiber, ...) return coroutine.yield() end @@ -71,8 +71,8 @@ function Fiber:get_traceback() end --- Returns the current Fiber object, or nil if not inside a fiber. -local function current() - return current_fiber +local function current_fiber() + return _current_fiber end local function now() @@ -80,18 +80,16 @@ local function now() end local function suspend(block_fn, ...) - return current_fiber:suspend(block_fn, ...) -end - -local function schedule(scheduler, fiber) - scheduler:schedule(fiber) + return _current_fiber:suspend(block_fn, ...) end --- Suspends execution of the current fiber. -- The fiber will be resumed when the scheduler is ready to run it again. -- @function yield local function yield() - return suspend(schedule) + return suspend(function(scheduler, fiber) + scheduler:schedule(fiber) + end) end --- Stops the current scheduler from running more tasks. @@ -110,7 +108,7 @@ end return { -- core runtime state current_scheduler = current_scheduler, - current_fiber = current, + current_fiber = current_fiber, -- time and suspension now = now, diff --git a/src/fibers/scope.lua b/src/fibers/scope.lua index 8e83b68..9827ace 100644 --- a/src/fibers/scope.lua +++ b/src/fibers/scope.lua @@ -1,24 +1,35 @@ -- fibers/scope.lua --- --- Basic scope module (stage 2). +-- Scope module (structured concurrency). +-- -- Provides a tree of Scope objects and a per-fiber “current scope”. -- --- At this stage: +-- Semantics: -- - A single root scope exists for the process. --- - Each fiber may have an associated current scope. +-- - Each fiber has an associated current scope (defaulting to root). -- - scope.current() returns the current scope for the fiber, -- or the process-wide current scope when not in a fiber. -- - scope.run(fn, ...) runs fn in a fresh child scope in the --- current context (fiber or non-fiber). --- - scope.root():spawn(fn, ...) spawns a new fiber whose current --- scope is that scope for the duration of fn. +-- current context (fiber or non-fiber), waits for its children, +-- runs defers, and then returns or raises based on scope status. +-- - s:spawn(fn, ...) spawns a new fiber whose current scope is s +-- for the duration of fn. +-- - scope.with_ev(build_ev) creates a *first-class Event* that +-- represents running a child scope whose body is build_ev(child). -- --- Policies (failure, cancellation, resource limits) are *not* yet --- implemented; they will be added in later stages. +-- Policies implemented: +-- - Status: "running" | "ok" | "failed" | "cancelled". +-- - Fail-fast failure propagation by default. +-- - Cancellation via Scope:cancel(reason) and a cancellation event. +-- - Child fibers tracked via a waitgroup; scope exit waits for them. +-- - Scope-level defers (LIFO) run at scope exit. +-- - Scope:run_ev(ev) wraps an Event with failure + cancellation. -- -- @module fibers.scope -local runtime = require 'fibers.runtime' +local runtime = require 'fibers.runtime' +local op = require 'fibers.op' +local waitgroup = require 'fibers.waitgroup' local unpack = rawget(table, "unpack") or _G.unpack local pack = rawget(table, "pack") or function(...) @@ -35,12 +46,32 @@ local fiber_scopes = setmetatable({}, { __mode = "k" }) local root_scope local global_scope +-- Internal: current fiber object, or nil if not in a fiber. +local function current_fiber() + return runtime.current_fiber() +end + -- Internal: create a new Scope with the given parent. local function new_scope(parent) local s = setmetatable({ - _parent = parent, - _children = {}, + _parent = parent, + _children = {}, + + -- Status and failure tracking + _status = "running", -- "running" | "ok" | "failed" | "cancelled" + _error = nil, -- primary error / cancellation cause + _failures = {}, -- additional failures + failure_mode = "fail_fast", -- only fail_fast for now + + -- Concurrency tracking + _wg = waitgroup.new(), -- waitgroup for child fibers + _defers = {}, -- LIFO list of deferred handlers + + -- Cancellation and join + _cancel_cond = op.new_cond(), -- one-shot cond; signalled on cancel/failure + _join_cond = op.new_cond(), -- one-shot cond; signalled on scope exit }, Scope) + if parent then local children = parent._children children[#children + 1] = s @@ -57,14 +88,6 @@ local function root() return root_scope end --- Internal: current fiber object, or nil if not in a fiber. -local function current_fiber() - if runtime.current_fiber then - return runtime.current_fiber() - end - return nil -end - --- Return the current Scope. -- Inside a fiber: the fiber's mapped scope, or the root if none. -- Outside a fiber: the process-wide current scope, defaulting to root. @@ -77,51 +100,131 @@ local function current() end --- Internal helper: run fn(scope, ...) with 'scope' as current in this context. -local function with_scope(scope, fn, ...) +-- Returns a packed result table: { n = ..., [1] = ok, [2..n] = values }. +local function with_scope(scope_obj, fn, ...) local fib = current_fiber() if fib then local prev = fiber_scopes[fib] - fiber_scopes[fib] = scope + fiber_scopes[fib] = scope_obj - local res = pack(pcall(fn, scope, ...)) + local res = pack(pcall(fn, scope_obj, ...)) fiber_scopes[fib] = prev - if not res[1] then error(res[2]) end - return unpack(res, 2, res.n) + return res else local prev = global_scope or root() - global_scope = scope + global_scope = scope_obj - local res = pack(pcall(fn, scope, ...)) + local res = pack(pcall(fn, scope_obj, ...)) global_scope = prev - if not res[1] then error(res[2]) end - return unpack(res, 2, res.n) + return res end end +---------------------------------------------------------------------- +-- Core scope API +---------------------------------------------------------------------- + --- Run a function inside a fresh child scope of the current scope. -- Synchronous: runs in the current fiber or process context. +-- Returns the body_fn results on success. +-- On failure or cancellation, raises the scope's primary error. -- body_fn :: function(Scope, ...): ... local function run(body_fn, ...) local parent = current() local child = new_scope(parent) - return with_scope(child, body_fn, ...) + + -- Run the body with 'child' as current scope. + local res = with_scope(child, body_fn, ...) + local ok = res[1] + + -- If the body itself raised (outside Event machinery), + -- mark failure and cancel the scope (to trigger done_ev, etc.). + if child._status == "running" and not ok then + child._status = "failed" + child._error = res[2] + child:cancel(child._error) -- signals _cancel_cond, cancels children + end + + -- Wait for child fibers to complete (even after fail_fast). + op.perform(child._wg:wait_op()) + + -- If still running and not cancelled/failed, mark as ok. + if child._status == "running" then + child._status = "ok" + child._error = nil + end + + -- Run defers in LIFO order. + local defers = child._defers + for i = #defers, 1, -1 do + local f = defers[i] + defers[i] = nil + pcall(f, child) + end + + -- Signal join completion. + child._join_cond.signal() + + -- Propagate outcome to caller. + if child._status == "ok" then + return unpack(res, 2, res.n) + else + error(child._error) + end end ---- Create a new child scope of this scope. --- Does not change the current scope or run any code. +--- Create a new child scope of this scope (no body, no current() change). function Scope:new_child() return new_scope(self) end +--- Register a deferred handler to run at scope exit (LIFO). +-- handler :: function(Scope) +function Scope:defer(handler) + local defers = self._defers + defers[#defers + 1] = handler +end + +--- Cancel this scope and its children with an optional reason. +-- Idempotent: multiple calls are safe. +function Scope:cancel(reason) + local r = reason or self._error or "scope cancelled" + + if self._status == "running" then + self._status = "cancelled" + if self._error == nil then + self._error = r + end + elseif self._status == "ok" then + -- Explicit cancellation after success: treat as cancelled. + self._status = "cancelled" + self._error = r + end + + -- Signal cancellation to any waiters. + self._cancel_cond.signal() + + -- Propagate to children. + local children = self._children + for i = 1, #children do + local c = children[i] + if c then + c:cancel(r) + end + end +end + --- Spawn a child fiber attached to this scope. -- fn :: function(Scope, ...): () -- ... :: arguments passed to fn -- --- The new fiber's current scope is set to 'self' for the duration --- of fn, and any previous mapping for that fiber (normally none) --- is restored afterwards. +-- Fail-fast semantics: +-- - If fn raises, this scope's status becomes "failed", its primary +-- error is set (if not already), and cancel() is invoked. function Scope:spawn(fn, ...) local args = { ... } + self._wg:add(1) + runtime.spawn(function() local fib = current_fiber() local prev = fib and fiber_scopes[fib] or nil @@ -141,10 +244,23 @@ function Scope:spawn(fn, ...) end if not ok then - -- Stage 2: preserve existing behaviour (unhandled errors - -- still crash the process via runtime/Fiber:resume). - error(err) + -- Fail-fast policy: record failure and cancel the scope. + if self._status == "running" then + self._status = "failed" + if self._error == nil then + self._error = err + end + self:cancel(self._error) + else + -- Additional failures are recorded but do not change + -- the primary status at this stage. + local failures = self._failures + failures[#failures + 1] = err + end + -- Do not rethrow; errors are handled via scope status. end + + self._wg:done() end) end @@ -163,9 +279,168 @@ function Scope:children() return out end +--- Return this scope's status and primary error. +-- status :: "running" | "ok" | "failed" | "cancelled" +-- err :: any | nil +function Scope:status() + return self._status, self._error +end + +--- Return a shallow copy of additional failures. +function Scope:failures() + local out = {} + local f = self._failures or {} + for i, v in ipairs(f) do + out[i] = v + end + return out +end + +--- Event that fires once the scope has reached a terminal status. +-- Returns (status, error) when synchronised. +function Scope:join_ev() + local ev = self._join_cond.wait_op() + return ev:wrap(function() + return self._status, self._error + end) +end + +--- Event that fires when the scope is cancelled or fails. +-- Returns the cancellation/failure reason when synchronised. +function Scope:done_ev() + local ev = self._cancel_cond.wait_op() + return ev:wrap(function() + return self._error or "scope cancelled" + end) +end + +---------------------------------------------------------------------- +-- Failure + cancellation wrapping for Events +---------------------------------------------------------------------- + +-- Internal: cancellation event used when running events under this scope. +local function cancel_event(self) + local ev = self._cancel_cond.wait_op() + return ev:wrap(function() + error(self._error or "scope cancelled") + end) +end + +--- Transform an event to obey this scope's failure and cancellation policy. +-- Returns a new Event; does not perform it. +function Scope:run_ev(ev) + local cancel_ev = cancel_event(self) + return op.choice(ev, cancel_ev) +end + +--- Synchronise on an event under this scope. +-- Equivalent to op.perform(self:run_ev(ev)). +function Scope:sync(ev) + return op.perform(self:run_ev(ev)) +end + +---------------------------------------------------------------------- +-- Scope as an *event*: scope.with_ev +---------------------------------------------------------------------- + +--- Event-level API: create a child scope whose body is an Event. +-- +-- build_ev :: function(child_scope :: Scope) -> Event +-- +-- The returned Event, when performed, will: +-- * create a child scope of scope.current(); +-- * install it as the current scope while build_ev runs; +-- * run build_ev(child) as an Event under normal CML semantics +-- (i.e. whoever performs this Event controls cancellation etc.); +-- * on conclusion or abort, wait for child fibers, run defers, +-- and signal join_ev(); +-- * propagate the inner Event's result or error. +local function with_ev(build_ev) + return op.guard(function() + local parent = current() + local child = new_scope(parent) + + -- bracket acquires "current scope = child", and guarantees + -- we restore the previous current scope and run scope cleanup + -- exactly once, whether the event wins, errors, or is aborted. + local function acquire() + -- Install child as current, remember what to restore. + local fib = current_fiber() + if fib then + local prev = fiber_scopes[fib] + fiber_scopes[fib] = child + return { kind = "fiber", fib = fib, prev = prev } + else + local prev = global_scope or root() + global_scope = child + return { kind = "global", prev = prev } + end + end + + local function release(token, aborted) + -- Restore the previous current scope. + if token.kind == "fiber" then + fiber_scopes[token.fib] = token.prev + else + global_scope = token.prev + end + + -- If the event was aborted and the scope is still running, + -- treat that as cancellation of the scope. + if aborted and child._status == "running" then + child._status = "cancelled" + child._error = child._error or "scope aborted" + child:cancel(child._error) + end + + -- Wait for child fibers to complete. + op.perform(child._wg:wait_op()) + + -- If still running and not cancelled/failed, mark as ok. + if child._status == "running" then + child._status = "ok" + child._error = nil + end + + -- Run defers in LIFO order. + local defers = child._defers + for i = #defers, 1, -1 do + local f = defers[i] + defers[i] = nil + pcall(f, child) + end + + -- Signal join completion. + child._join_cond.signal() + end + + local function use() + -- Here the child is already installed as current(). + -- build_ev must return an Event, and must not perform it. + local ok, ev = pcall(build_ev, child) + if not ok then + local ex = ev + -- mark failure & cancel the scope + if child._status == "running" then + child._status = "failed" + child._error = ex + child:cancel(ex) + end + error(ex) + end + -- The inner event itself may fail; that is handled by whoever + -- is performing this with_ev event (typically via Scope:run_ev). + return ev + end + + return op.bracket(acquire, release, use) + end) +end + return { - root = root, - current = current, - run = run, - Scope = Scope, + root = root, + current = current, + run = run, + with_ev = with_ev, + Scope = Scope, } diff --git a/src/fibers/sleep.lua b/src/fibers/sleep.lua index 115fd07..af57529 100644 --- a/src/fibers/sleep.lua +++ b/src/fibers/sleep.lua @@ -5,7 +5,7 @@ -- @module fibers.sleep local op = require 'fibers.op' -local fiber = require 'fibers.fiber' +local runtime = require 'fibers.runtime' local perform = require 'fibers.performer'.perform @@ -20,12 +20,12 @@ local perform = require 'fibers.performer'.perform -- @treturn operation The created operation. local function sleep_until_op(t) local function try() - return t <= fiber.now() + return t <= runtime.now() end local function block(suspension, wrap_fn) suspension.sched:schedule_at_time(t, suspension:complete_task(wrap_fn)) end - return op.new_base_op(nil, try, block) + return op.new_primitive(nil, try, block) end --- Put the current fiber to sleep until time t. @@ -42,7 +42,7 @@ local function sleep_op(dt) local function block(suspension, wrap_fn) suspension.sched:schedule_after_sleep(dt, suspension:complete_task(wrap_fn)) end - return op.new_base_op(nil, try, block) + return op.new_primitive(nil, try, block) end --- Put the current fiber to sleep for a duration dt. diff --git a/src/fibers/stream.lua b/src/fibers/stream.lua index ea790dd..3daa769 100644 --- a/src/fibers/stream.lua +++ b/src/fibers/stream.lua @@ -126,7 +126,7 @@ local function core_write_op(stream, buf, count, flush_needed) return ... end - return op.new_base_op(wrap, try, block) + return op.new_primitive(wrap, try, block) end @@ -217,7 +217,7 @@ local function core_read_op(stream, buf, min, max, terminator) return ... end - return op.new_base_op(wrap, try, block) + return op.new_primitive(wrap, try, block) end function Stream:partial_read() diff --git a/src/fibers/waitgroup.lua b/src/fibers/waitgroup.lua index 83c70bc..a0dbaa2 100644 --- a/src/fibers/waitgroup.lua +++ b/src/fibers/waitgroup.lua @@ -45,7 +45,7 @@ function Waitgroup:wait_op() end end - return op.new_base_op(nil, try, block) + return op.new_primitive(nil, try, block) end function Waitgroup:wait() diff --git a/tests/test.lua b/tests/test.lua index 9d43180..a7f87ae 100644 --- a/tests/test.lua +++ b/tests/test.lua @@ -12,14 +12,14 @@ local modules = { { 'stream', 'socket' }, { 'timer' }, { 'sched' }, - { 'fiber' }, + { 'runtime' }, { 'channel' }, { 'queue' }, { 'cond' }, { 'sleep' }, { 'epoll' }, { 'pollio' }, - -- { 'exec' }, + { 'exec' }, { 'waitgroup' }, { 'alarm' }, { 'context' }, diff --git a/tests/test_alarm.lua b/tests/test_alarm.lua index ebd8a9a..1a930ef 100644 --- a/tests/test_alarm.lua +++ b/tests/test_alarm.lua @@ -4,7 +4,7 @@ print('testing: fibers.alarm') -- look one level up package.path = "../src/?.lua;" .. package.path -local fiber = require 'fibers.fiber' +local fibers = require 'fibers' local alarm = require 'fibers.alarm' local sleep = require 'fibers.sleep' local sc = require 'fibers.utils.syscall' @@ -124,7 +124,7 @@ local function buffer_test() -- first absolutes - easy to test local t_sleep, t_sleep_before_realtime = 1, 2 alarm.clock_desynced() - fiber.spawn(function () + fibers.spawn(function () sleep.sleep(t_sleep_before_realtime) alarm.clock_synced() end) @@ -135,7 +135,7 @@ local function buffer_test() -- next let's do nexts local msec_target = 333 alarm.clock_desynced() - fiber.spawn(function () + fibers.spawn(function () sleep.sleep(t_sleep_before_realtime) alarm.clock_synced() end) @@ -228,7 +228,7 @@ local function test_next_calc() print("complete!") end -fiber.spawn(function () +local function main() abs_test(2) next_error() next_msec_test(666) @@ -242,7 +242,6 @@ fiber.spawn(function () buffer_test() validate_next_table_test() test_next_calc() - fiber.stop() -end) +end -fiber.main() +fibers.run(main) diff --git a/tests/test_channel.lua b/tests/test_channel.lua index 81ed78a..bb255a5 100644 --- a/tests/test_channel.lua +++ b/tests/test_channel.lua @@ -2,20 +2,20 @@ print('testing: fibers.channel') package.path = "../src/?.lua;" .. package.path -local fiber = require 'fibers.fiber' +local fibers = require 'fibers' local channel = require 'fibers.channel' local function test_unbuffered() local chan = channel.new() - fiber.spawn(function() chan:put(42) end) + fibers.spawn(function() chan:put(42) end) assert(chan:get() == 42, "basic transfer") local received, signal = true, channel.new() - fiber.spawn(function() + fibers.spawn(function() received = chan:get() signal:put(true) end) - fiber.spawn(function() + fibers.spawn(function() chan:put(nil) signal:put(true) end) @@ -28,20 +28,20 @@ end local function test_buffered() local chan, signal = channel.new(2), channel.new() - fiber.spawn(function() + fibers.spawn(function() for i = 1, 4 do chan:put(i) end signal:put(true) end) - fiber.spawn(function() + fibers.spawn(function() for i = 1, 2 do assert(chan:get() == i) end end) signal:get() - fiber.spawn(function() + fibers.spawn(function() chan:put(5) signal:put(true) end) @@ -58,7 +58,7 @@ local function test_unbounded() for i = 1, 1000 do assert(chan:get() == i) end local blocked = true - fiber.spawn(function() + fibers.spawn(function() chan:get() blocked = false end) @@ -68,11 +68,11 @@ end local function test_concurrent() local chan, signal, results = channel.new(1), channel.new(), {} - fiber.spawn(function() + fibers.spawn(function() for i = 1, 11 do chan:put(i) end signal:put() end) - fiber.spawn(function() + fibers.spawn(function() for _ = 1, 10 do table.insert(results, chan:get()) end signal:put() end) @@ -82,13 +82,12 @@ local function test_concurrent() print("Concurrent passed") end -fiber.spawn(function() +local function main() test_unbuffered() test_buffered() test_unbounded() test_concurrent() print("All channel tests passed!") - fiber.stop() -end) +end -fiber.main() +fibers.run(main) diff --git a/tests/test_cond.lua b/tests/test_cond.lua index f3d3a61..8ffd57e 100644 --- a/tests/test_cond.lua +++ b/tests/test_cond.lua @@ -5,7 +5,7 @@ print('testing: fibers.cond') package.path = "../src/?.lua;" .. package.path local cond = require 'fibers.cond' -local fiber = require 'fibers.fiber' +local runtime = require 'fibers.runtime' local sleep = require 'fibers.sleep' local sc = require 'fibers.utils.syscall' @@ -14,22 +14,22 @@ local equal = require 'fibers.utils.helper'.equal local c, log = cond.new(), {} local function record(x) table.insert(log, x) end -fiber.spawn(function() +runtime.spawn(function() record('a'); c:wait(); record('b') end) -fiber.spawn(function() +runtime.spawn(function() record('c'); c:signal(); record('d') end) assert(equal(log, {})) -fiber.current_scheduler:run() -assert(equal(log, { 'a', 'c', 'd' })) -fiber.current_scheduler:run() -assert(equal(log, { 'a', 'c', 'd', 'b' })) +runtime.current_scheduler:run() +-- assert(equal(log, { 'a', 'c', 'd' })) +runtime.current_scheduler:run() +assert(equal(log, { 'a', 'c', 'b', 'd' })) -fiber.spawn(function() +runtime.spawn(function() local fiber_count = 1e3 for _ = 1, fiber_count do - fiber.spawn(function() c:wait(); end) + runtime.spawn(function() c:wait(); end) end sleep.sleep(1) @@ -39,8 +39,8 @@ fiber.spawn(function() local end_time = sc.monotime() print("Time taken to signal fiber: ", (end_time - start_time) / fiber_count) - fiber.stop() + runtime.stop() end) -fiber.main() +runtime.main() print('test: ok') diff --git a/tests/test_context.lua b/tests/test_context.lua index 2a28dbc..1ca847a 100644 --- a/tests/test_context.lua +++ b/tests/test_context.lua @@ -5,7 +5,7 @@ print('testing: fibers.context') package.path = "../src/?.lua;" .. package.path local context = require 'fibers.context' -local fiber = require 'fibers.fiber' +local fibers = require 'fibers' local sleep = require 'fibers.sleep' local perform = require 'fibers.performer'.perform @@ -24,13 +24,13 @@ local function test_with_cancel() local child_ctx, _ = context.with_cancel(ctx) local is_cancelled = false - fiber.spawn(function() + fibers.spawn(function() perform(child_ctx:done_op()) is_cancelled = true end) cancel() - fiber.yield() -- Give time for cancellation to propagate + sleep.sleep(0.1) -- Give time for cancellation to propagate assert(is_cancelled, "Child context should be cancelled when parent is cancelled") print("test_with_cancel passed") @@ -55,7 +55,7 @@ local function test_with_timeout() local ctx, _ = context.with_timeout(parent, 0.01) local is_cancelled = false - fiber.spawn(function() + fibers.spawn(function() perform(ctx:done_op()) is_cancelled = true end) @@ -74,7 +74,7 @@ local function test_custom_cause() local custom_cause = "Custom Cancel Reason" cancel(custom_cause) - fiber.yield() -- Give time for cancellation to propagate + sleep.sleep(0.01) -- Give time for cancellation to propagate assert(ctx:err() == custom_cause, "Context should have the custom cancel cause") assert(ctx_2:err() == custom_cause, "Child context should have the custom cancel cause") @@ -88,7 +88,7 @@ local function test_cancel_on_with_value() local ctx_3, _ = context.with_value(ctx_2, "key2", "value2") cancel('cancelled') - fiber.yield() -- Give time for cancellation to propagate + sleep.sleep(0.01) -- Give time for cancellation to propagate assert(ctx:err() == 'cancelled', "Context should have cancel cause") assert(ctx_2:err() == 'cancelled', "Child context should have cancel cause") @@ -98,7 +98,7 @@ local function test_cancel_on_with_value() assert(ctx_3:value('key2') == 'value2', "Child context should have the value") end -- Run all tests -fiber.spawn(function() +local function main() test_background() test_with_cancel() test_with_value() @@ -107,7 +107,6 @@ fiber.spawn(function() test_cancel_on_with_value() print("All tests passed") - fiber.stop() -end) +end -fiber.main() +fibers.run(main) diff --git a/tests/test_exec.lua b/tests/test_exec.lua index baedeb8..6cbc10f 100644 --- a/tests/test_exec.lua +++ b/tests/test_exec.lua @@ -3,7 +3,7 @@ -- look one level up package.path = "../src/?.lua;" .. package.path -local fiber = require 'fibers.fiber' +local fibers = require 'fibers' local sleep = require 'fibers.sleep' local channel = require "fibers.channel" local exec = require 'fibers.exec' @@ -70,7 +70,7 @@ local function test_io_redirection() local err = cmd:start() assert(cmd:start(), "Expected error on starting command twice") assert(err == nil, "Expected no error but got:", err) - fiber.spawn(function () + fibers.spawn(function () for _, v in ipairs(msgs) do stdin_pipe:write(v) signal_chan:get() @@ -118,7 +118,7 @@ local function test_cancel_during_output() local cmd = exec.command_context(ctx, '/bin/sh', '-c', 'for i in $(seq 1 10000); do echo y; sleep 0.001; done') :setpgid(true) - fiber.spawn(function() + fibers.spawn(function() -- Let it run for a short moment, then cancel sleep.sleep(0.00001) cancel() @@ -149,14 +149,14 @@ local function test_cleanup_on_crash(id) local script = [[ package.path = "../src/?.lua;" .. package.path - local fiber = require 'fibers.fiber' + local fibers = require 'fibers' local exec = require 'fibers.exec' local sleep = require 'fibers.sleep' local pollio = require 'fibers.pollio' local sc = require 'fibers.utils.syscall' pollio.install_poll_io_handler() - fiber.spawn(function () + local function main() local cmd = exec.command('sleep', '1') cmd:setprdeathsig(sc.SIGKILL) local err = cmd:start() @@ -167,9 +167,9 @@ local function test_cleanup_on_crash(id) io.stdout:flush() sleep.sleep(0.01) print(obj.obj) - end) + end - fiber.main() + fibers.run(main) ]] -- Write the script to a temporary file @@ -219,6 +219,7 @@ local function test_cleanup_on_crash(id) assert(not is_running, "Child sleep command is still running") end + -- Main test function local function main() local pid = sc.getpid() @@ -241,7 +242,7 @@ local function main() local wg = waitgroup.new() for i = 1, reps do wg:add(1) - fiber.spawn(function () + fibers.spawn(function () v(i) wg:done() end) @@ -252,8 +253,6 @@ local function main() print(k..": passed!") end print("test: ok") - fiber.stop() end -fiber.spawn(main) -fiber.main() +fibers.run(main) diff --git a/tests/test_op.lua b/tests/test_op.lua index da3ce65..3715fea 100644 --- a/tests/test_op.lua +++ b/tests/test_op.lua @@ -4,8 +4,8 @@ print("testing: fibers.op") -- look one level up package.path = "../src/?.lua;" .. package.path -local op = require 'fibers.op' -local fiber = require 'fibers.fiber' +local op = require 'fibers.op' +local runtime = require 'fibers.runtime' local perform, choice = require 'fibers.performer'.perform, op.choice local always = op.always @@ -26,7 +26,7 @@ local function async_task(val) local t = suspension:complete_task(wrap_fn, val) suspension.sched:schedule(t) end - local ev = op.new_base_op(nil, try_fn, block_fn) + local ev = op.new_primitive(nil, try_fn, block_fn) return ev, function() return tries end end @@ -34,7 +34,7 @@ end -- Run all tests inside a single top-level fiber ------------------------------------------------------------ -fiber.spawn(function() +runtime.spawn(function() -------------------------------------------------------- -- 1) Base event: perform, or_else, wrap @@ -54,7 +54,7 @@ fiber.spawn(function() local palt2 = perform(ev2) assert(palt2 == 99, "base: or_else should use fallback when event can't commit") - -- or_else: async event should still win (gets a chance next turn) + -- or_else: async event is not ready now, so fallback wins do local fallback_called = false local ev_async, tries = async_task(123) @@ -63,10 +63,11 @@ fiber.spawn(function() return -1 end) local r = perform(ev) - assert(r == 123, "or_else(async): expected main event to win") - assert(tries() == 1, "async_task: try_fn not called exactly once") - assert(fallback_called == false, - "or_else(async): fallback should not run when event commits") + + assert(r == -1, "or_else(async): expected fallback to win") + assert(tries() == 1, "async_task: try_fn should be called exactly once") + assert(fallback_called == true, + "or_else(async): fallback should run when event is not ready") end -- nested wrap: ((x + 1) * 2) @@ -142,7 +143,7 @@ fiber.spawn(function() local guarded_nack = op.guard(function() guard_calls = guard_calls + 1 return op.with_nack(function(nack_ev) - fiber.spawn(function() + runtime.spawn(function() perform(nack_ev) cancelled = true end) @@ -154,7 +155,7 @@ fiber.spawn(function() local v2 = perform(ev2) assert(v2 == "OK", "guard+with_nack: wrong winner") - fiber.yield() + runtime.yield() assert(cancelled == true, "guard+with_nack: nack not fired") assert(guard_calls == 1, "guard+with_nack: builder not once") end @@ -185,7 +186,7 @@ fiber.spawn(function() do local cancelled = false local with_nack_ev = op.with_nack(function(nack_ev) - fiber.spawn(function() + runtime.spawn(function() perform(nack_ev) cancelled = true end) @@ -196,7 +197,7 @@ fiber.spawn(function() local v = perform(ev) assert(v == "WIN", "with_nack win: wrong winner") - fiber.yield() + runtime.yield() assert(cancelled == false, "with_nack win: nack fired unexpectedly") end @@ -204,7 +205,7 @@ fiber.spawn(function() do local cancelled = false local with_nack_ev = op.with_nack(function(nack_ev) - fiber.spawn(function() + runtime.spawn(function() perform(nack_ev) cancelled = true end) @@ -215,7 +216,7 @@ fiber.spawn(function() local v = perform(ev) assert(v == "OTHER", "with_nack loss: wrong winner") - fiber.yield() + runtime.yield() assert(cancelled == true, "with_nack loss: nack did not fire") end @@ -225,13 +226,13 @@ fiber.spawn(function() local outer_cancelled, inner_cancelled = false, false local outer = op.with_nack(function(outer_nack_ev) - fiber.spawn(function() + runtime.spawn(function() perform(outer_nack_ev) outer_cancelled = true end) return op.with_nack(function(inner_nack_ev) - fiber.spawn(function() + runtime.spawn(function() perform(inner_nack_ev) inner_cancelled = true end) @@ -244,7 +245,7 @@ fiber.spawn(function() assert(v == "INNER_WIN", "nested with_nack: wrong result") - fiber.yield() + runtime.yield() assert(outer_cancelled == false, "nested with_nack: outer nack fired unexpectedly") assert(inner_cancelled == false, @@ -511,7 +512,7 @@ fiber.spawn(function() end end print("fibers.op tests: ok") - fiber.stop() + runtime.stop() end) -fiber.main() +runtime.main() diff --git a/tests/test_pollio.lua b/tests/test_pollio.lua index 00f76a0..5ff04b1 100644 --- a/tests/test_pollio.lua +++ b/tests/test_pollio.lua @@ -6,7 +6,7 @@ package.path = "../src/?.lua;" .. package.path local pollio = require 'fibers.pollio' local file_stream = require 'fibers.stream.file' -local fiber = require 'fibers.fiber' +local runtime = require 'fibers.runtime' local equal = require 'fibers.utils.helper'.equal local log = {} @@ -14,21 +14,21 @@ local function record(x) table.insert(log, x) end -- local handler = new_poll_io_handler() -- file.set_blocking_handler(handler) --- fiber.current_scheduler:add_task_source(handler) +-- runtime.current_scheduler:add_task_source(handler) pollio.install_poll_io_handler() -fiber.current_scheduler:run() +runtime.current_scheduler:run() assert(equal(log, {})) local rd, wr = file_stream.pipe() local message = "hello, world\n" -fiber.spawn(function() +runtime.spawn(function() record('rd-a') local str = rd:read_some_chars() record('rd-b') record(str) end) -fiber.spawn(function() +runtime.spawn(function() record('wr-a') wr:write(message) record('wr-b') @@ -36,9 +36,9 @@ fiber.spawn(function() record('wr-c') end) -fiber.current_scheduler:run() +runtime.current_scheduler:run() assert(equal(log, { 'rd-a', 'wr-a', 'wr-b', 'wr-c' })) -fiber.current_scheduler:run() +runtime.current_scheduler:run() assert(equal(log, { 'rd-a', 'wr-a', 'wr-b', 'wr-c', 'rd-b', message })) print('test: ok') diff --git a/tests/test_queue.lua b/tests/test_queue.lua index e475b98..6d432d0 100644 --- a/tests/test_queue.lua +++ b/tests/test_queue.lua @@ -5,14 +5,14 @@ print('testing: fibers.queue') package.path = "../src/?.lua;" .. package.path local queue = require 'fibers.queue' -local fiber = require 'fibers.fiber' +local runtime = require 'fibers.runtime' local helper = require 'fibers.utils.helper' local equal = helper.equal local log = {} local function record(x) table.insert(log, x) end -fiber.spawn(function() +runtime.spawn(function() local q = queue.new() record('a') q:put('b') @@ -29,7 +29,7 @@ end) local function run(...) log = {} - fiber.current_scheduler:run() + runtime.current_scheduler:run() assert(equal(log, { ... })) end diff --git a/tests/test_fiber.lua b/tests/test_runtime.lua similarity index 71% rename from tests/test_fiber.lua rename to tests/test_runtime.lua index 2f3e31d..dae321a 100644 --- a/tests/test_fiber.lua +++ b/tests/test_runtime.lua @@ -4,25 +4,25 @@ print('testing: fibers.fiber') -- look one level up package.path = "../src/?.lua;" .. package.path -local fiber = require 'fibers.fiber' +local runtime = require 'fibers.runtime' local sc = require 'fibers.utils.syscall' local equal = require 'fibers.utils.helper'.equal local log = {} local function record(x) table.insert(log, x) end -fiber.spawn(function() - record('a'); fiber.yield(); record('b'); fiber.yield(); record('c') +runtime.spawn(function() + record('a'); runtime.yield(); record('b'); runtime.yield(); record('c') end) assert(equal(log, {})) -fiber.current_scheduler:run() +runtime.current_scheduler:run() assert(equal(log, {'a'})) -fiber.current_scheduler:run() +runtime.current_scheduler:run() assert(equal(log, {'a', 'b'})) -fiber.current_scheduler:run() +runtime.current_scheduler:run() assert(equal(log, {'a', 'b', 'c'})) -fiber.current_scheduler:run() +runtime.current_scheduler:run() assert(equal(log, {'a', 'b', 'c'})) -- Test performance @@ -33,8 +33,8 @@ local function inc() count = count + 1 end for _=1, fiber_count do - fiber.spawn(function() - inc(); fiber.yield(); inc(); fiber.yield(); inc() + runtime.spawn(function() + inc(); runtime.yield(); inc(); runtime.yield(); inc() end) end @@ -43,7 +43,7 @@ print("Fiber creation time: "..(end_time - start_time)/fiber_count) start_time = sc.monotime() for _=1,3*fiber_count do -- run fibers, each fiber yields 3 times - fiber.current_scheduler:run() + runtime.current_scheduler:run() end end_time = sc.monotime() print("Fiber operation time: "..(end_time - start_time)/(2*3*fiber_count)) diff --git a/tests/test_scope.lua b/tests/test_scope.lua index 10d9dbe..5cfa538 100644 --- a/tests/test_scope.lua +++ b/tests/test_scope.lua @@ -4,14 +4,20 @@ print("test: fibers.scope") -- look one level up package.path = "../src/?.lua;" .. package.path -local runtime = require "fibers.runtime" -local scope = require "fibers.scope" +local runtime = require "fibers.runtime" +local scope = require "fibers.scope" +local op = require "fibers.op" +local performer = require "fibers.performer" + +------------------------------------------------------------------------------- +-- 1. Structural tests (your originals, lightly generalised) +------------------------------------------------------------------------------- local function test_outside_fibers() local root = scope.root() - -- current() outside any fibre should be the root (process-wide current scope) - assert(scope.current() == root, "outside fibres, current() should be root") + -- current() outside any fiber should be the root (process-wide current scope) + assert(scope.current() == root, "outside fibers, current() should be root") local outer_scope local inner_scope @@ -25,7 +31,14 @@ local function test_outside_fibers() -- root should see this child in its children list local rc = root:children() - assert(#rc == 1 and rc[1] == s, "root:children() should contain outer scope") + local found_outer = false + for _, c in ipairs(rc) do + if c == s then + found_outer = true + break + end + end + assert(found_outer, "root:children() should contain outer scope") -- Nested run creates a grandchild of s scope.run(function(child2) @@ -34,7 +47,14 @@ local function test_outside_fibers() assert(child2:parent() == s, "nested scope parent must be outer scope") local sc = s:children() - assert(#sc == 1 and sc[1] == child2, "outer scope children() should contain nested scope") + local found_inner = false + for _, c in ipairs(sc) do + if c == child2 then + found_inner = true + break + end + end + assert(found_inner, "outer scope children() should contain nested scope") end) -- After nested run, current() should be back to the outer scope @@ -45,8 +65,8 @@ local function test_outside_fibers() assert(inner_scope ~= nil, "inner_scope should have been set") assert(outer_scope ~= inner_scope, "outer and inner scopes must differ") - -- After scope.run returns, current() outside fibres should be root again - assert(scope.current() == scope.root(), "after scope.run, current() should be root outside fibres") + -- After scope.run returns, current() outside fibers should be root again + assert(scope.current() == scope.root(), "after scope.run, current() should be root outside fibers") end local function test_inside_fibers() @@ -55,53 +75,51 @@ local function test_inside_fibers() local child_in_fiber local grandchild_in_fiber - -- Spawn a fibre anchored to the root scope. + -- Spawn a fiber anchored to the root scope. root:spawn(function(s) - -- In this fibre, s is the scope used for spawn -> root + -- In this fiber, s is the scope used for spawn -> root assert(s == root, "spawn(fn) on root should pass root as scope") - assert(scope.current() == root, "inside spawned fibre, current() should be root initially") + assert(scope.current() == root, "inside spawned fiber, current() should be root initially") - -- Create a child scope inside the fibre + -- Create a child scope inside the fiber scope.run(function(child) child_in_fiber = child - assert(scope.current() == child, "inside scope.run in fibre, current() should be child") - assert(child:parent() == root, "child-in-fibre parent must be root") + assert(scope.current() == child, "inside scope.run in fiber, current() should be child") + assert(child:parent() == root, "child-in-fiber parent must be root") -- Create a grandchild scope scope.run(function(grandchild) grandchild_in_fiber = grandchild - assert(scope.current() == grandchild, "inside nested run in fibre, current() should be grandchild") + assert(scope.current() == grandchild, "inside nested run in fiber, current() should be grandchild") assert(grandchild:parent() == child, "grandchild parent must be child") end) -- After nested run, current() should be back to child - assert(scope.current() == child, "after nested run in fibre, current() should be child again") + assert(scope.current() == child, "after nested run in fiber, current() should be child again") end) - -- After inner run, current() should be back to root for this fibre - assert(scope.current() == root, "after scope.run in fibre, current() should be root again") + -- After inner run, current() should be back to root for this fiber + assert(scope.current() == root, "after scope.run in fiber, current() should be root again") - -- Stop the scheduler once all fibre-local tests have run + -- Stop the scheduler once all fiber-local tests have run runtime.stop() end) - -- Drive the scheduler so the spawned fibre runs + -- Drive the scheduler so the spawned fiber runs runtime.main() - -- After main() returns we are back outside fibres; + -- After main() returns we are back outside fibers; -- current() should again be the process-wide current scope (root). - assert(scope.current() == root, "after runtime.main, current() outside fibres should be root") + assert(scope.current() == root, "after runtime.main, current() outside fibers should be root") - -- Check that scopes created inside the fibre were recorded + -- Check that scopes created inside the fiber were recorded assert(child_in_fiber ~= nil, "child_in_fiber should have been set") assert(grandchild_in_fiber ~= nil, "grandchild_in_fiber should have been set") assert(child_in_fiber:parent() == root, "child_in_fiber parent must be root") assert(grandchild_in_fiber:parent() == child_in_fiber, "grandchild_in_fiber parent must be child_in_fiber") - -- Check that root children include both the outer test scope - -- (from test_outside_fibers) and the child created in this fibre. + -- Check that root children include the child created in this fiber. local rc = root:children() - assert(#rc >= 2, "root should have at least two children by now") local found_child = false for _, s in ipairs(rc) do if s == child_in_fiber then @@ -112,10 +130,277 @@ local function test_inside_fibers() assert(found_child, "root:children() should contain child_in_fiber") end +------------------------------------------------------------------------------- +-- 1b. New: basic scope.with_ev behaviour +------------------------------------------------------------------------------- + +local function test_with_ev_basic() + local parent = scope.current() + local child_scope + + local ev = scope.with_ev(function(child) + child_scope = child + -- inside build_ev, current scope should be the child + assert(scope.current() == child, "inside with_ev build_ev, current() should be child scope") + assert(child:parent() == parent, "with_ev child parent should be current scope") + + -- simple event that returns two values + return op.always(true):wrap(function() + return 99, "ok" + end) + end) + + local a, b = op.perform(ev) + assert(a == 99 and b == "ok", "with_ev should propagate child event results") + + assert(child_scope ~= nil, "with_ev should have created a child scope") + local st, err = child_scope:status() + assert(st == "ok" and err == nil, "with_ev child scope should end ok on success") +end + +------------------------------------------------------------------------------- +-- 2. Status transitions for scope.run (success, failure, cancellation) +------------------------------------------------------------------------------- + +local function test_run_success_and_failure() + local root = scope.root() + + -- Success case: scope.run returns body results, status becomes "ok". + local success_scope + local a, b = scope.run(function(s) + success_scope = s + local st, err = s:status() + assert(st == "running" and err == nil, "inside body, status should be running") + return 42, "x" + end) + + assert(a == 42 and b == "x", "scope.run should return body results on success") + local st_ok, err_ok = success_scope:status() + assert(st_ok == "ok" and err_ok == nil, "successful scope should end with status ok and no error") + assert(success_scope:parent() == root, "success scope parent should be root") + + -- Failure case: body error propagates, status becomes "failed". + local fail_scope + local ok = pcall(function() + scope.run(function(s) + fail_scope = s + error("body failure") + end) + end) + assert(not ok, "scope.run should rethrow body error on failure") + local st_fail, err_fail = fail_scope:status() + assert(st_fail == "failed", "failed scope should have status 'failed'") + assert(type(err_fail) == "string" or err_fail ~= nil, + "failed scope should have a primary error recorded") + assert(tostring(err_fail):find("body failure", 1, true), + "failed scope primary error should mention the body failure") +end + +local function test_run_explicit_cancel() + -- If the body explicitly cancels the scope, scope.run should raise + -- the cancellation reason and status should be "cancelled". + local cancelled_scope + local ok = pcall(function() + scope.run(function(s) + cancelled_scope = s + s:cancel("stop here") + end) + end) + assert(not ok, "scope.run should raise when scope is cancelled inside body") + local st, serr = cancelled_scope:status() + assert(st == "cancelled", "cancelled scope should have status 'cancelled'") + assert(serr == "stop here", "cancelled scope error should be the cancellation reason") +end + +------------------------------------------------------------------------------- +-- 3. Defers: LIFO ordering and execution on failure +------------------------------------------------------------------------------- + +local function test_defers_lifo_and_failure() + local order = {} + local scope_ref + + local ok = pcall(function() + scope.run(function(s) + scope_ref = s + s:defer(function() table.insert(order, "first") end) + s:defer(function() table.insert(order, "second") end) + error("boom in body") + end) + end) + + assert(not ok, "scope.run should propagate body failure") + local st, serr = scope_ref:status() + assert(st == "failed", "scope should be failed after body error") + assert(tostring(serr):find("boom in body", 1, true), + "primary error should mention the body error") + assert(#order == 2, "two defers should have run") + assert(order[1] == "second" and order[2] == "first", + "defers should run in LIFO order even on failure") +end + +------------------------------------------------------------------------------- +-- 4. Scope:sync via performer.perform: failure and cancellation paths +------------------------------------------------------------------------------- + +local function test_sync_wraps_event_failure() + -- Event whose post-wrap raises: tests wrap_failure path. + local ev = op.always(123):wrap(function(v) + assert(v == 123, "inner always should pass its value") + error("event post-wrap failure") + end) + + local failed_scope + local ok = pcall(function() + scope.run(function(s) + failed_scope = s + -- This synchronisation should trigger fail-fast handling via performer. + performer.perform(ev) + end) + end) + + assert(not ok, "performer.perform on failing event should raise") + local st, serr = failed_scope:status() + assert(st == "failed", "scope should be failed after event failure") + assert(tostring(serr):find("event post-wrap failure", 1, true), + "scope error should mention the event failure") +end + +local function test_sync_respects_cancellation() + -- Race a never-ready event against cancellation. + local ev = op.never() + + local cancelled_scope + local ok = pcall(function() + scope.run(function(s) + cancelled_scope = s + s:cancel("cancel before sync") + -- This should immediately raise via the cancellation event, + -- rather than blocking on never(). + performer.perform(ev) + end) + end) + + assert(not ok, "performer.perform on never() after cancel should raise") + local st, serr = cancelled_scope:status() + assert(st == "cancelled", "scope should be cancelled") + assert(serr == "cancel before sync", "cancellation reason should be preserved") +end + +------------------------------------------------------------------------------- +-- 5. join_ev and done_ev (on failed/cancelled scopes) +------------------------------------------------------------------------------- + +local function test_join_and_done_events() + -- Failed scope: body error. + local failed_scope + local ok_fail = pcall(function() + scope.run(function(s) + failed_scope = s + error("join test failure") + end) + end) + assert(not ok_fail, "failed scope.run should raise") + + do + local ev = failed_scope:join_ev() + local st, jerr = op.perform(ev) + assert(st == "failed", "join_ev on failed scope should report 'failed'") + assert(tostring(jerr):find("join test failure", 1, true), + "join_ev error should mention the body failure") + end + + do + local ev = failed_scope:done_ev() + local reason = op.perform(ev) + -- For a failed scope we also call cancel(error), so done_ev + -- should be triggered and report the same error. + assert(tostring(reason):find("join test failure", 1, true), + "done_ev on failed scope should report the failure reason") + end + + -- Cancelled scope (explicit cancel, not body error). + local cancelled_scope + local ok_cancel = pcall(function() + scope.run(function(s) + cancelled_scope = s + s:cancel("stop again") + end) + end) + assert(not ok_cancel, "cancelled scope.run should raise") + + do + local ev = cancelled_scope:join_ev() + local st, jerr = op.perform(ev) + assert(st == "cancelled" and jerr == "stop again", + "join_ev on cancelled scope should report 'cancelled' and reason") + end + + do + local ev = cancelled_scope:done_ev() + local reason = op.perform(ev) + assert(reason == "stop again", + "done_ev on cancelled scope should report cancellation reason") + end +end + +------------------------------------------------------------------------------- +-- 6. Fail-fast from child fibres (via performer.perform) +------------------------------------------------------------------------------- + +local function test_fail_fast_from_child_fibre() + local root = scope.root() + local test_scope + + root:spawn(function() + -- Create a child scope under root in this fibre. + local ok = pcall(function() + scope.run(function(s) + test_scope = s + + -- Use a condition to ensure the child fibre runs before we exit the body. + local cond = op.new_cond() + + -- Spawn a child fibre that signals, then fails. + s:spawn(function(_) + cond.signal() + error("child fibre failure") + end) + + -- Wait for the cond via performer, so we do not exit + -- the body until after the child has signalled. + performer.perform(cond.wait_op()) + end) + end) + + assert(not ok, "scope.run should raise when a child fibre fails") + local st, serr = test_scope:status() + assert(st == "failed", "scope status should be failed after child fibre failure") + assert(tostring(serr):find("child fibre failure", 1, true), + "primary error should mention child fibre failure") + + runtime.stop() + end) + + runtime.main() +end + +------------------------------------------------------------------------------- +-- Main +------------------------------------------------------------------------------- + local function main() io.stdout:write("Running scope tests...\n") test_outside_fibers() test_inside_fibers() + test_with_ev_basic() + test_run_success_and_failure() + test_run_explicit_cancel() + test_defers_lifo_and_failure() + test_sync_wraps_event_failure() + test_sync_respects_cancellation() + test_join_and_done_events() + test_fail_fast_from_child_fibre() io.stdout:write("OK\n") end diff --git a/tests/test_sleep.lua b/tests/test_sleep.lua index fd16308..bcdf4c6 100644 --- a/tests/test_sleep.lua +++ b/tests/test_sleep.lua @@ -5,32 +5,25 @@ print('testing: fibers.sleep') package.path = "../src/?.lua;" .. package.path local sleep = require 'fibers.sleep' -local fiber = require 'fibers.fiber' +local runtime = require 'fibers.runtime' local done = 0 -- local wakeup_times = {} local count = 1e3 for _ = 1, count do local function fn() - local start, dt = fiber.now(), math.random() + local start, dt = runtime.now(), math.random() sleep.sleep(dt) - local wakeup_time = fiber.now() + local wakeup_time = runtime.now() assert(wakeup_time >= start + dt) done = done + 1 -- table.insert(wakeup_times, wakeup_time - (start + dt)) end - fiber.spawn(fn) + runtime.spawn(fn) end -for t = fiber.now(), fiber.now() + 1.5, 0.01 do - fiber.current_scheduler:run(t) +for t = runtime.now(), runtime.now() + 1.5, 0.01 do + runtime.current_scheduler:run(t) end assert(done == count) --- -- Calculate maximum error --- local max_error = 0 --- for _, error in ipairs(wakeup_times) do --- if error > max_error then max_error = error end --- end --- print("Maximum sleep error: ", max_error) - print('test: ok') diff --git a/tests/test_stream-file.lua b/tests/test_stream-file.lua index 3141b7c..0680e04 100644 --- a/tests/test_stream-file.lua +++ b/tests/test_stream-file.lua @@ -4,9 +4,8 @@ print('testing: fibers.stream.file') -- look one level up package.path = "../src/?.lua;" .. package.path -local fiber = require 'fibers.fiber' +local fibers = require 'fibers' local waitgroup = require "fibers.waitgroup" -local op = require 'fibers.op' local sleep = require 'fibers.sleep' local channel = require 'fibers.channel' local file = require 'fibers.stream.file' @@ -14,7 +13,7 @@ local compat = require 'fibers.stream.compat' compat.install() -local perform, choice = require 'fibers.performer'.perform, op.choice +local perform, choice = fibers.perform, fibers.choice local function test() local rd, wr = file.pipe() @@ -48,7 +47,7 @@ local function test_long_read_first() local rd, wr = file.pipe() local message = string.rep("a", 2^24) - fiber.spawn(function () + fibers.spawn(function () wr:write_chars(message) wr:close() end) @@ -69,7 +68,7 @@ local function test_read_op() local wg = waitgroup.new() wg:add(1) - fiber.spawn(function () + fibers.spawn(function () local count, err = wr:write_chars(msg1) assert(count==#msg1 and not err) sleep.sleep(0.05) @@ -111,7 +110,7 @@ local function test_write_op() local wg = waitgroup.new() wg:add(1) - fiber.spawn(function () + fibers.spawn(function () sleep.sleep(0.1) local chars, err = rd:read_chars(2^16) assert(chars==string.rep("a", 2^16) and not err) @@ -151,7 +150,7 @@ local function test_long_write_first() local message2 - fiber.spawn(function () + fibers.spawn(function () message2 = rd:read_all_chars() rd:close() chan:put(1) @@ -170,7 +169,7 @@ local function test_tiny_writes() local rd, wr = file.pipe() local message = string.rep("a", 2^16) - fiber.spawn(function () + fibers.spawn(function () for c in message:gmatch"." do wr:write(c) end @@ -188,7 +187,7 @@ local function test_single() local rd, wr = file.pipe() local message = "aa" - fiber.spawn(function () + fibers.spawn(function () wr:write_chars(message) wr:close() end) @@ -225,7 +224,7 @@ local function test_lua() rd:close() end -fiber.spawn(function () +local function main() test() test_read_op() test_write_op() @@ -234,8 +233,8 @@ fiber.spawn(function () test_tiny_writes() test_single() test_lua() - fiber.stop() -end) -fiber.main() +end + +fibers.run(main) print('test: ok') diff --git a/tests/test_stream-socket.lua b/tests/test_stream-socket.lua index bf1b4ad..11dee3a 100644 --- a/tests/test_stream-socket.lua +++ b/tests/test_stream-socket.lua @@ -4,7 +4,7 @@ print('testing: fibers.stream.socket') -- look one level up package.path = "../src/?.lua;" .. package.path -local fiber = require 'fibers.fiber' +local fibers = require 'fibers' local socket = require 'fibers.stream.socket' local sc = require 'fibers.utils.syscall' @@ -33,10 +33,10 @@ local function test() end -fiber.spawn(function () +local function main() test() - fiber.stop() -end) -fiber.main() +end + +fibers.run(main) print('test: ok') diff --git a/tests/test_stream.lua b/tests/test_stream.lua index ce11d74..70083e7 100644 --- a/tests/test_stream.lua +++ b/tests/test_stream.lua @@ -4,7 +4,7 @@ print('testing: fibers.stream') -- look one level up package.path = "../src/?.lua;" .. package.path -local fiber = require 'fibers.fiber' +local fibers = require 'fibers' local stream = require 'fibers.stream' local function test() @@ -32,11 +32,10 @@ local function test() rd:close(); wr:close() end -fiber.spawn(function () +local function main() test() - fiber.stop() -end) +end -fiber.main() +fibers.run(main) print('selftest: ok') diff --git a/tests/test_waitgroup.lua b/tests/test_waitgroup.lua index 27b4c14..9bea596 100644 --- a/tests/test_waitgroup.lua +++ b/tests/test_waitgroup.lua @@ -5,13 +5,12 @@ print('testing: fibers.waitgroup') package.path = "../src/?.lua;" .. package.path -- test_waitgroup.lua -local fiber = require 'fibers.fiber' +local fibers = require 'fibers' local sleep = require 'fibers.sleep' -local op = require 'fibers.op' local waitgroup = require 'fibers.waitgroup' local sc = require 'fibers.utils.syscall' -local perform, choice = require 'fibers.performer'.perform, op.choice +local perform, choice = fibers.perform, fibers.choice local function test_nowait() local wg = waitgroup.new() @@ -29,7 +28,7 @@ local function test_simple() -- Spawn fibers and add to the waitgroup for _ = 1, numFibers do wg:add(1) - fiber.spawn(function() + fibers.spawn(function() sleep.sleep(math.random()) -- Simulate some work wg:done() end) @@ -52,7 +51,7 @@ local function test_complex() local function one_sec_work(w) w:add(1) - fiber.spawn(function() + fibers.spawn(function() sleep.sleep(1) -- Simulate some work w:done() end) @@ -91,9 +90,7 @@ local function main() test_nowait() test_simple() test_complex() - fiber.stop() end -- Start the main function in fiber context -fiber.spawn(main) -fiber.main() +fibers.run(main) From 9adc96c0ae79a2bea0afa3c1bb0cfbd71237760a Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Tue, 11 Nov 2025 02:54:36 +0000 Subject: [PATCH 013/138] updates channels to use on_abort for cleanup --- src/fibers/channel.lua | 187 +++++++++++++++++++++++------------------ tests/test_channel.lua | 6 ++ 2 files changed, 111 insertions(+), 82 deletions(-) diff --git a/src/fibers/channel.lua b/src/fibers/channel.lua index f665da3..696f114 100644 --- a/src/fibers/channel.lua +++ b/src/fibers/channel.lua @@ -1,24 +1,15 @@ --- Use of this source code is governed by the Apache 2.0 license; see COPYING. - ---- fibers.channel module +-- fibers.channel -- Provides Concurrent ML style channels for communication between fibers. --- @module fibers.channel - -local op = require 'fibers.op' -local fifo = require 'fibers.utils.fifo' +local op = require 'fibers.op' +local fifo = require 'fibers.utils.fifo' local perform = require 'fibers.performer'.perform ---- Channel class --- Represents a communication channel between fibers. --- @type Channel local Channel = {} Channel.__index = Channel ---- Create a new Channel. --- @treturn Channel The created Channel. local function new(buffer_size) - buffer_size = buffer_size or 0 -- Default to unbuffered + buffer_size = buffer_size or 0 local buffer = nil if buffer_size > 0 then @@ -26,111 +17,143 @@ local function new(buffer_size) end return setmetatable({ - buffer = buffer, + buffer = buffer, buffer_size = buffer_size, - getq = fifo.new(), -- Queue of waiting receivers - putq = fifo.new(), -- Queue of waiting senders + getq = fifo.new(), -- waiting receivers + putq = fifo.new(), -- waiting senders }, Channel) end ---- Create a put operation for the Channel. --- Make an operation that if and when it completes will rendezvous with --- a receiver fiber to send VAL over the channel. --- @param val The value to put into the Channel. --- @treturn BaseOp The created put operation. +---------------------------------------------------------------------- +-- Helpers: pop active entries (skip cancelled ones) +---------------------------------------------------------------------- + +local function pop_active(q) + while not q:empty() do + local entry = q:pop() + if not entry.cancelled then + return entry + end + end + return nil +end + +---------------------------------------------------------------------- +-- PUT side +---------------------------------------------------------------------- + function Channel:put_op(val) - local getq, putq, buffer, buffer_size = self.getq, self.putq, self.buffer, self.buffer_size + local getq, putq = self.getq, self.putq + local buffer, buffer_size = self.buffer, self.buffer_size + + -- Per-operation state for this event instance. + local entry = { + val = val, + cancelled = false, + suspension = nil, + wrap = nil, + } + local function try() - -- Case 1: If there's a waiting receiver, complete the rendezvous immediately - while not getq:empty() do - local remote = getq:pop() - if remote.suspension:waiting() then - remote.suspension:complete(remote.wrap, val) - return true - end - -- Otherwise the remote suspension is already completed, pop and continue + -- Case 1: rendezvous with a waiting receiver. + local recv = pop_active(getq) + if recv then + recv.suspension:complete(recv.wrap, val) + return true end - -- Case 2: If we have a buffer with space, add the value to the buffer + -- Case 2: buffered channel with available space. if buffer and buffer:length() < buffer_size then buffer:push(val) return true end - -- Case 3: No receivers and no buffer space + -- Case 3: no receiver and no buffer space. return false end + local function block(suspension, wrap_fn) - -- First, GC for canceled operations - while not putq:empty() and not putq:peek().suspension:waiting() do - putq:pop() - end - -- No space in buffer and no receivers, so block - putq:push({ suspension = suspension, wrap = wrap_fn, val = val }) + entry.suspension = suspension + entry.wrap = wrap_fn + entry.cancelled = false + putq:push(entry) end - return op.new_primitive(nil, try, block) + + local prim = op.new_primitive(nil, try, block) + + -- Mark this entry as cancelled if this put loses in a choice / is aborted. + return prim:on_abort(function() + entry.cancelled = true + end) end ---- Create a get operation for the Channel. --- Make an operation that if and when it completes will rendezvous with --- a sender fiber to receive one value from the channel. --- @treturn BaseOp The created get operation. +---------------------------------------------------------------------- +-- GET side +---------------------------------------------------------------------- + function Channel:get_op() - local getq, putq, buffer = self.getq, self.putq, self.buffer - -- Attempt to service one sender from putq - local function pop_from_putq() - while not putq:empty() do - local remote = putq:pop() - if remote.suspension:waiting() then - remote.suspension:complete(remote.wrap) - return remote - end + local getq, putq = self.getq, self.putq + local buffer = self.buffer + + local entry = { + cancelled = false, + suspension = nil, + wrap = nil, + } + + local function pop_sender() + local sender = pop_active(putq) + if not sender then + return nil end - return nil + -- Having chosen this sender, complete its suspension immediately. + sender.suspension:complete(sender.wrap) + return sender end + local function try() - local remote = pop_from_putq() - -- Case 1: Take from buffer if available + local remote = pop_sender() + -- Case 1: take from buffer if there is a buffered value. if buffer and buffer:length() > 0 then - local val = buffer:pop() - -- Attempt to refill buffer with one sender - if remote then buffer:push(remote.val) end - return true, val + local v = buffer:pop() + -- If there was a sender waiting, refill the buffer with its value. + if remote then + buffer:push(remote.val) + end + return true, v + end + -- Case 2: no buffered value; take directly from a sender. + if remote then + return true, remote.val end - -- Case 2: No buffer value; try to take directly from a sender - if remote then return true, remote.val end - -- Case 3: Nothing available so block + -- Case 3: nothing available. return false end + local function block(suspension, wrap_fn) - -- Clear any stale entries - while not getq:empty() and not getq:peek().suspension:waiting() do - getq:pop() - end - -- Block this receiver - getq:push({ suspension = suspension, wrap = wrap_fn }) + entry.suspension = suspension + entry.wrap = wrap_fn + entry.cancelled = false + getq:push(entry) end - return op.new_primitive(nil, try, block) + + local prim = op.new_primitive(nil, try, block) + + return prim:on_abort(function() + entry.cancelled = true + end) end ---- Put a message into the Channel. --- Send message on the channel. If there is already another fiber --- waiting to receive a message on this channel, give it our message and --- continue. Otherwise, block until a receiver becomes available. --- @tparam any message The message to put into the Channel. +---------------------------------------------------------------------- +-- Synchronous wrappers +---------------------------------------------------------------------- + function Channel:put(message) return perform(self:put_op(message)) end ---- Get a message from the Channel. --- Receive a message from the channel and return it. If there is --- already another fiber waiting to send a message on this channel, take --- its message directly. Otherwise, block until a sender becomes --- available. --- @treturn any The message retrieved from the Channel. function Channel:get() return perform(self:get_op()) end ---- @export return { - new = new + new = new, } diff --git a/tests/test_channel.lua b/tests/test_channel.lua index bb255a5..1258e6d 100644 --- a/tests/test_channel.lua +++ b/tests/test_channel.lua @@ -62,7 +62,13 @@ local function test_unbounded() chan:get() blocked = false end) + + -- At this point, there is no value available, so get() must have blocked. assert(blocked, "get should block") + + -- Now provide a value so the spawned fibre can complete. + chan:put(123) + print("Unbounded passed") end From 4d040502c7c917402448bb0b13934d1e39ceca80 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Tue, 11 Nov 2025 03:20:20 +0000 Subject: [PATCH 014/138] corrects waitgroup reuse bug + adds test --- src/fibers/waitgroup.lua | 58 +++++++++++++++++++++++++++------------- tests/test_waitgroup.lua | 39 ++++++++++++++++++++++++--- 2 files changed, 74 insertions(+), 23 deletions(-) diff --git a/src/fibers/waitgroup.lua b/src/fibers/waitgroup.lua index a0dbaa2..bd75faa 100644 --- a/src/fibers/waitgroup.lua +++ b/src/fibers/waitgroup.lua @@ -1,26 +1,41 @@ --- waitgroup.lua -local op = require 'fibers.op' - +-- fibers/waitgroup.lua +local op = require 'fibers.op' local perform = require 'fibers.performer'.perform local Waitgroup = {} Waitgroup.__index = Waitgroup local function new() - -- Use the core condition primitive directly: { wait_op = ..., signal = ... } - local wg = setmetatable({ + return setmetatable({ _counter = 0, - _cond = op.new_cond(), + _cond = nil, -- per-generation condition; nil when idle }, Waitgroup) - return wg end -function Waitgroup:add(count) - self._counter = self._counter + count - if self._counter < 0 then +function Waitgroup:add(delta) + if delta == 0 then + return + end + + local old_waiters = self._counter + local new_waiters = old_waiters + delta + + if new_waiters < 0 then error("waitgroup counter goes negative") - elseif self._counter == 0 then - self._cond.signal() + end + + self._counter = new_waiters + + if new_waiters == 0 then + -- This generation completes: wake any waiters. + if self._cond then + self._cond.signal() + end + -- _cond remains a triggered cond for this generation; a new + -- generation will allocate a fresh cond when counter rises from 0. + elseif old_waiters == 0 and new_waiters > 0 then + -- Starting a new generation: new condition for new work. + self._cond = op.new_cond() end end @@ -29,20 +44,25 @@ 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) + -- Re-check after try(): counter may have become zero meanwhile. 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) + return end + + -- At this point we are in an active generation. + local cond = self._cond + if not cond then + error("waitgroup internal error: missing condition for active generation") + end + + local cond_op = cond.wait_op() + cond_op.block_fn(suspension, wrap_fn) end return op.new_primitive(nil, try, block) @@ -53,5 +73,5 @@ function Waitgroup:wait() end return { - new = new + new = new, } diff --git a/tests/test_waitgroup.lua b/tests/test_waitgroup.lua index 9bea596..e61c2a8 100644 --- a/tests/test_waitgroup.lua +++ b/tests/test_waitgroup.lua @@ -29,7 +29,7 @@ local function test_simple() for _ = 1, numFibers do wg:add(1) fibers.spawn(function() - sleep.sleep(math.random()) -- Simulate some work + sleep.sleep(0.1) -- Simulate some work wg:done() end) end @@ -45,6 +45,36 @@ local function test_simple() print("Simple test: ok") end +local function test_reuse() + local wg = waitgroup.new() + -- Spawn fibers and add to the waitgroup + wg:add(1) + fibers.spawn(function() + sleep.sleep(0.1) -- Simulate some work + wg:done() + end) + + wg:wait() + + wg:add(1) + local blocked = false + fibers.spawn(function() + sleep.sleep(0.1) -- Simulate some work + wg:done() + end) + + perform( + wg:wait_op() + :or_else(function () blocked = true end) + ) + assert(blocked, "Reused Waitgroup should block.") + + wg:wait() + + print("Reuse test: ok") +end + + local function test_complex() local wg = waitgroup.new() local numFibers = 5 @@ -52,7 +82,7 @@ local function test_complex() local function one_sec_work(w) w:add(1) fibers.spawn(function() - sleep.sleep(1) -- Simulate some work + sleep.sleep(0.1) -- Simulate some work w:done() end) end @@ -76,12 +106,12 @@ local function test_complex() perform( choice( wg:wait_op():wrap(function() done = true end), - sleep.sleep_op(0.9):wrap(extra_work) + sleep.sleep_op(0.09):wrap(extra_work) ) ) end - assert(sc.monotime() - start > 1.5) + assert(sc.monotime() - start > 0.05) print("Complex test: ok") end @@ -89,6 +119,7 @@ end local function main() test_nowait() test_simple() + test_reuse() test_complex() end From ad35dfd782ea08bace394e18f285fc216cf7f880 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sun, 16 Nov 2025 03:04:51 +0000 Subject: [PATCH 015/138] make op.cond less eager --- src/fibers/op.lua | 27 +++++++++++++++++---------- tests/test_cond.lua | 4 ++-- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/fibers/op.lua b/src/fibers/op.lua index c51599b..3f61485 100644 --- a/src/fibers/op.lua +++ b/src/fibers/op.lua @@ -219,40 +219,47 @@ end local function new_cond(opts) local state = { triggered = false, - waiters = {}, -- optional + waiters = {}, -- list of { suspension = ..., wrap = ... } abort_fn = opts and opts.abort_fn or nil, } local function wait_op() assert(not state.abort_fn, "abort-only cond has no wait_op") + local function try() return state.triggered end + local function block(suspension, wrap_fn) if state.triggered then + -- Already triggered: complete immediately via the scheduler. suspension:complete(wrap_fn) else - state.waiters[#state.waiters + 1] = - suspension:complete_task(wrap_fn) + -- Record this suspension + wrap for later signalling. + state.waiters[#state.waiters + 1] = { + suspension = suspension, + wrap = wrap_fn, + } end end + return new_primitive(nil, try, block) end local function signal() if state.triggered then return end state.triggered = true + + -- Complete all recorded waiters via the scheduler. for i = 1, #state.waiters do - local task = state.waiters[i] + local remote = state.waiters[i] state.waiters[i] = nil - if task - and task.suspension - and task.suspension:waiting() - then - -- Run the completion *now*, in this turn. - task:run() + + if remote and remote.suspension and remote.suspension:waiting() then + remote.suspension:complete(remote.wrap) end end + if state.abort_fn then pcall(state.abort_fn) end diff --git a/tests/test_cond.lua b/tests/test_cond.lua index 8ffd57e..888a629 100644 --- a/tests/test_cond.lua +++ b/tests/test_cond.lua @@ -22,9 +22,9 @@ runtime.spawn(function() end) assert(equal(log, {})) runtime.current_scheduler:run() --- assert(equal(log, { 'a', 'c', 'd' })) +assert(equal(log, { 'a', 'c', 'd' })) runtime.current_scheduler:run() -assert(equal(log, { 'a', 'c', 'b', 'd' })) +assert(equal(log, { 'a', 'c', 'd', 'b' })) runtime.spawn(function() local fiber_count = 1e3 From aef12282a44cd07619208f2064ea1cfb26f56d0f Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sun, 16 Nov 2025 04:36:10 +0000 Subject: [PATCH 016/138] adds constants to syscall --- src/fibers/utils/syscall.lua | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/fibers/utils/syscall.lua b/src/fibers/utils/syscall.lua index 8d02727..d684f81 100644 --- a/src/fibers/utils/syscall.lua +++ b/src/fibers/utils/syscall.lua @@ -71,6 +71,8 @@ M.ECONNRESET = p_errno.ECONNRESET M.ECONNREFUSED = p_errno.ECONNREFUSED M.ENETUNREACH = p_errno.ENETUNREACH M.EHOSTUNREACH = p_errno.EHOSTUNREACH +M.EBADF = p_errno.EBADF +M.ENOENT = p_errno.ENOENT M.SIGKILL = p_signal.SIGKILL M.SIGTERM = p_signal.SIGTERM From 9e4e434ac59ac352a4a20f75af0271e92093236c Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sun, 16 Nov 2025 05:30:04 +0000 Subject: [PATCH 017/138] pcall free op --- src/fibers/op.lua | 264 +++++++++++++++++++++++----------------------- tests/test_op.lua | 142 +++++-------------------- 2 files changed, 158 insertions(+), 248 deletions(-) diff --git a/src/fibers/op.lua b/src/fibers/op.lua index 3f61485..b2f50e9 100644 --- a/src/fibers/op.lua +++ b/src/fibers/op.lua @@ -1,37 +1,22 @@ --- fibers.op module --- Provides Concurrent ML style operations for managing concurrency. +-- Concurrent ML style operations for managing concurrency. +-- -- Events are CML-style: primitive leaves, choices, guards, with_nack, --- wraps, and an extra abort combinator (on_abort). +-- wraps, and an abort combinator (on_abort). -- -- Core event AST kinds: --- prim : primitive leaf { try_fn, block_fn, wrap_fn } --- choice : non-empty list of events --- guard : delayed event builder (run once per sync) --- with_nack : CML-style nack combinator --- wrap : post-commit mapper (composed at compile time) --- abort : attach abort handler to an event (run if this arm loses) --- wrap_handler : CML-style exception handler (see wrap_handler below) --- --- Semantics sketch --- ---------------- --- We keep CML-style semantics for with_nack: --- - with_nack g gets a nack event that becomes enabled iff the --- *entire* resulting event loses in an enclosing choice. --- - nested with_nack behaves correctly: outer nacks only fire when --- the outer event loses, not when internal subchoices resolve. --- --- `on_abort(ev, f)` is implemented in terms of the same "nack" machinery: --- - each abort scope behaves like a nack-cond whose signal() runs f(). --- - after a choice commits, we figure out which conds are associated --- exclusively with losing arms and signal those once. +-- prim : primitive leaf { try_fn, block_fn, wrap_fn } +-- choice : non-empty list of events +-- guard : delayed event builder (run once per sync) +-- with_nack : CML-style nack combinator +-- wrap : post-commit mapper (composed at compile time) +-- abort : attach abort handler to an event (run if this arm loses) -- --- We also provide: --- - bracket(acquire, release, use): RAII-style resource protocol. --- - wrap_handler(ev, h): CML-style wrapHandler (exn -> event). --- - finally(ev, cleanup): derived "always run cleanup" combinator. --- - else_next_turn(ev, fallback_ev): biased choice; prefer ev, but --- if it doesn't commit "by next turn", abort it cleanly and run --- fallback_ev (in a separate sync). +-- Important design note: +-- This module is *exception-neutral*. It does not interpret Lua +-- errors as part of event semantics. Any uncaught error in a wrap +-- or primitive is treated as a bug and will be surfaced by the +-- surrounding scope / fibre machinery. local runtime = require 'fibers.runtime' @@ -90,29 +75,36 @@ function CompleteTask:run() end end --- A CompleteTask can be cancelled, completing with an error. +-- A CompleteTask can be cancelled. In the non-exceptional model, this +-- completes the suspension with a special "cancel" wrap that returns +-- a tagged result (false, reason) rather than raising. function CompleteTask:cancel(reason) if self.suspension:waiting() then - self.suspension:complete(error, reason or 'cancelled') + local msg = reason or 'cancelled' + local function cancelled_wrap() + -- Convention: (ok:boolean, value_or_reason:any) + return false, msg + end + self.suspension:complete(cancelled_wrap) end end ---------------------------------------------------------------------- -- 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 } --- kind = 'abort' : { inner = Event, abort_fn = f } --- kind = 'wrap_handler' : { inner = Event, handler = function(ex) -> Event } +-- 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 } +-- kind = 'abort' : { inner = Event, abort_fn = f } ---------------------------------------------------------------------- local Event = {} Event.__index = Event --- forward declaration so compile_event can call perform +-- forward declaration so compile_event can call perform if needed in +-- future extension; currently perform does not use exceptions. local perform -- Primitive event (leaf). @@ -166,7 +158,6 @@ local function always(...) return new_primitive(nil, try, block) end - local function never() -- An event that never becomes ready return new_primitive(nil, @@ -174,15 +165,6 @@ local function never() function() end) end --- function Event:or_else(fallback_thunk) --- return choice( --- self, --- next_turn_op():wrap(function() --- return fallback_thunk() --- end) --- ) --- end - -- Wrap event with a post-processing function f (commit phase). -- This is another node in the tree; composed at compile time. function Event:wrap(f) @@ -202,16 +184,6 @@ function Event:on_abort(f) ) end --- Attach an exception handler for post-synchronisation --- actions. h(ex) must return a replacement event to synchronise on. -function Event:wrap_handler(handler) - assert(type(handler) == 'function', "wrap_handler expects a function") - return setmetatable( - { kind = 'wrap_handler', inner = self, handler = handler }, - Event - ) -end - ---------------------------------------------------------------------- -- Simple one-shot condition primitive (used for with_nack; also exported) ---------------------------------------------------------------------- @@ -255,7 +227,10 @@ local function new_cond(opts) local remote = state.waiters[i] state.waiters[i] = nil - if remote and remote.suspension and remote.suspension:waiting() then + if remote + and remote.suspension + and remote.suspension:waiting() + then remote.suspension:complete(remote.wrap) end end @@ -284,28 +259,24 @@ end -- -- Semantics: -- - Each with_nack or abort node adds a cond to the nacks list. --- - Each wrap_handler node adds a handler to the handlers list. --- - At the leaf, we build a final wrap function that: --- * runs the normal wrap chain --- * then applies the wrap_handler chain (innermost first) using pcall. +-- - wrap nodes compose their functions into the final wrap. ---------------------------------------------------------------------- -local function compile_event(ev, outer_wrap, out, nacks, handlers) +local function compile_event(ev, outer_wrap, out, nacks) out = out or {} outer_wrap = outer_wrap or id_wrap nacks = nacks or {} - handlers = handlers or {} local kind = ev.kind if kind == 'choice' then for _, sub in ipairs(ev.events) do - compile_event(sub, outer_wrap, out, nacks, handlers) + compile_event(sub, outer_wrap, out, nacks) end elseif kind == 'guard' then local inner = ev.builder() - compile_event(inner, outer_wrap, out, nacks, handlers) + compile_event(inner, outer_wrap, out, nacks) elseif kind == 'with_nack' then local cond = new_cond() @@ -314,50 +285,26 @@ local function compile_event(ev, outer_wrap, out, nacks, handlers) local child_nacks = { unpack(nacks) } child_nacks[#child_nacks + 1] = cond - compile_event(inner, outer_wrap, out, child_nacks, handlers) + 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, handlers) + compile_event(ev.inner, new_outer, out, nacks) elseif kind == 'abort' then local cond = new_cond{ abort_fn = ev.abort_fn } local child_nacks = { unpack(nacks) } child_nacks[#child_nacks + 1] = cond - compile_event(ev.inner, outer_wrap, out, child_nacks, handlers) - - elseif kind == 'wrap_handler' then - -- Accumulate handlers; innermost handler should see the exception first. - local child_handlers = { unpack(handlers) } - child_handlers[#child_handlers + 1] = ev.handler - compile_event(ev.inner, outer_wrap, out, nacks, child_handlers) + compile_event(ev.inner, outer_wrap, out, child_nacks) else -- 'prim' local function wrapped(...) - local function core(...) - return outer_wrap(ev.wrap_fn(...)) - end - - local f = core - if #handlers > 0 then - for i = #handlers, 1, -1 do - local h = handlers[i] - local prev = f - f = function(...) - local res = pack(pcall(prev, ...)) - if res[1] then - return unpack(res, 2, res.n) - end - local ex = res[2] - local hev = h(ex) - return perform(hev) - end - end - end - return f(...) + -- No exception machinery here; any Lua error is treated + -- as a bug and handled by the surrounding scope/fibre. + return outer_wrap(ev.wrap_fn(...)) end out[#out + 1] = { @@ -376,7 +323,6 @@ end ---------------------------------------------------------------------- -- Signal all conds that belong exclusively to losing arms. --- This is the original CML-style logic: -- - Build set of nacks on the winner path. -- - For each loser leaf, signal any nacks not in the winner set. -- - Each cond object is responsible for idempotence. @@ -435,6 +381,10 @@ local function apply_wrap(wrap, retval) return wrap(unpack(retval, 2, retval.n)) end +---------------------------------------------------------------------- +-- or_else: biased, non-blocking choice +---------------------------------------------------------------------- + function Event:or_else(fallback_thunk) assert(type(fallback_thunk) == "function", "or_else expects a function") @@ -476,10 +426,12 @@ end ---------------------------------------------------------------------- -- Perform this event (primitive or composite), possibly blocking. +-- Any Lua error raised during wraps or primitives is not caught here; +-- it will abort the current fibre and be handled by the scope layer. perform = function(ev) local leaves = compile_event(ev) - -- Fast path: try once using the same semantics as fast_commit(). + -- Fast path: non-blocking attempt. local idx, retval = try_ready(leaves) if idx then trigger_nacks(leaves, idx) @@ -490,7 +442,7 @@ perform = function(ev) local suspended = pack(runtime.suspend(block_choice_op, leaves)) local wrap = suspended[1] - -- Identify winning leaf by its wrap function. + -- Identify winning leaf by its wrap function, if any. local winner_index for i, leaf in ipairs(leaves) do if leaf.wrap == wrap then @@ -506,37 +458,34 @@ end ---------------------------------------------------------------------- -- finally : (ev, cleanup) -> ev' -- --- cleanup(aborted:boolean, exn:any|nil) +-- cleanup(aborted:boolean) -- -- Semantics: -- * on normal post-sync completion: --- cleanup(false, nil) is called (best-effort, protected). --- * if a post-sync action raises: --- cleanup(true, exn) is called (best-effort) and the same --- exception is re-raised. --- * Exceptions from guard/with_nack builders are not intercepted. +-- cleanup(false) is called (best-effort, protected). +-- * if the event *loses* in a choice (via on_abort): +-- cleanup(true) is called (best-effort, protected). +-- +-- Exceptions from ev's wrap or primitives are not intercepted here; +-- they are handled by the surrounding scope as fibre failures. ---------------------------------------------------------------------- function Event:finally(cleanup) - -- Success path: only runs if the entire event (including any inner - -- wrap_handler handlers) completes without raising. + assert(type(cleanup) == "function", "finally expects a function") + + -- Success path: only runs if this event wins and completes its wraps + -- without raising. local function success_wrap(...) - pcall(cleanup, false, nil) + pcall(cleanup, false) return ... end - local function handler(ex) - -- Error path: run cleanup(true, ex) and rethrow. - return always(true):wrap(function() - pcall(cleanup, true, ex) - error(ex) - end) + -- Abort path: runs if this event participates in a choice and loses. + local function abort_action() + pcall(cleanup, true) end - -- Important: wrap_handler on ev, then a wrap on top. - -- This ensures success_wrap runs only on the success path, - -- and handler is responsible for the error path. - return self:wrap_handler(handler):wrap(success_wrap) + return self:wrap(success_wrap):on_abort(abort_action) end ---------------------------------------------------------------------- @@ -554,36 +503,83 @@ end -- * if the resulting event PARTICIPATES in a choice but LOSES: -- - release(res, true) is called (via on_abort / nack machinery) -- --- This is *purely an Event combinator*; no new fiber is spawned. +-- This combinator does not interpret Lua errors from acquire/use as +-- normal control flow. Any uncaught error there fails the running +-- fibre and is recorded at the scope level. ---------------------------------------------------------------------- local function bracket(acquire, release, use) + assert(type(acquire) == "function", "bracket: acquire must be a function") + assert(type(release) == "function", "bracket: release must be a function") + assert(type(use) == "function", "bracket: use must be a function") + return guard(function() local res = acquire() - local ok, ev = pcall(use, res) - if not ok then - local ex = ev - pcall(release, res, true) - error(ex) - end + -- If use(res) throws, that is a bug; scope machinery will handle it. + local ev = use(res) - local wrapped = ev:finally(function(aborted, _) - pcall(release, res, aborted) + -- Success path: event wins and completes → release(res, false) once. + local wrapped = ev:wrap(function(...) + pcall(release, res, false) + return ... end) + -- Losing path: event participates in a choice but loses → release(res, true) once. return wrapped:on_abort(function() pcall(release, res, true) end) end) end +---------------------------------------------------------------------- +-- Higher-level choice helpers (built entirely from choice + wrap) +---------------------------------------------------------------------- + +local function race(events, on_win) + assert(type(on_win) == "function", "race expects on_win callback") + local wrapped = {} + for i, ev in ipairs(events) do + wrapped[i] = ev:wrap(function(...) + return on_win(i, ...) + end) + end + return choice(unpack(wrapped)) +end + +local function first_ready(events) + return race(events, function(i, ...) + return i, ... + end) +end + +local function named_choice(arms) + -- arms is a map { name = Event, ... } + local events, names = {}, {} + for name, ev in pairs(arms) do + names[#names + 1] = name + events[#events + 1] = ev + end + return race(events, function(i, ...) + return names[i], ... + end) +end + +local function boolean_choice(ev_true, ev_false) + return race({ ev_true, ev_false }, function(i, ...) + if i == 1 then + return true, ... + else + return false, ... + end + end) +end + ---------------------------------------------------------------------- -- Public API ---------------------------------------------------------------------- return { - perform = perform, perform_raw = perform, new_primitive = new_primitive, -- primitive event constructor choice = choice, @@ -594,5 +590,11 @@ return { always = always, never = never, Event = Event, - -- Event instances have methods: wrap, on_abort. + -- Event instances have methods: wrap, on_abort, finally, or_else. + + -- higher-level helpers + race = race, + first_ready = first_ready, + named_choice = named_choice, + boolean_choice = boolean_choice, } diff --git a/tests/test_op.lua b/tests/test_op.lua index 3715fea..befd4ba 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 = "../src/?.lua;" .. package.path -local op = require 'fibers.op' +local op = require 'fibers.op' local runtime = require 'fibers.runtime' local perform, choice = require 'fibers.performer'.perform, op.choice @@ -373,144 +373,52 @@ runtime.spawn(function() end -------------------------------------------------------- - -- 8) wrap_handler: exception in post-sync, plus pre-sync error + -- 8) finally: cleanup on success and on abort + -- + -- In the new model: + -- finally(cleanup) is about lifetime: + -- * cleanup(false) on normal success + -- * cleanup(true) if the event participates in a choice and loses + -- It does not intercept Lua errors; those are handled by scopes. -------------------------------------------------------- do - -- 8.1 error in a post-synchronisation wrap is caught and - -- mapped to a recovery event. - do - local handler_called = false - - local base = always(10) - - local ev = base - :wrap_handler(function(ex) - handler_called = true - assert(tostring(ex):match("boom"), - "wrap_handler: unexpected exception value") - return always("recovered") - end) - :wrap(function() - -- post-sync action that fails - error("boom") - end) - - local r = perform(ev) - assert(r == "recovered", - "wrap_handler: expected recovery result") - assert(handler_called, - "wrap_handler: handler was not invoked") - end - - -- 8.2 guard builder error is *not* caught by wrap_handler - do - local g_ev = op.guard(function() - error("builder-fail") - end) - - local handled = g_ev:wrap_handler(function(_) - return always("ignored") - end) - - local ok, err = pcall(function() - perform(handled) - end) - assert(not ok, "wrap_handler: should not catch guard builder errors") - assert(tostring(err):match("builder%-fail"), - "wrap_handler: wrong error propagated for guard builder") - end - - -- 8.3 wrap_handler: nesting order (innermost first) - do - local log = {} - - -- Base event whose post-sync action fails. - local base = always(1):wrap(function() - error("boom-inner") - end) - - -- Inner handler: sees the original exception and rethrows via a new event. - local ev_inner = base:wrap_handler(function(ex) - table.insert(log, "inner:" .. tostring(ex)) - -- Rethrow as a different error so the outer handler can distinguish it. - return always(true):wrap(function() - error("inner-rethrow") - end) - end) - - -- Outer handler: should see the *rethrown* exception, not the original. - local ev = ev_inner:wrap_handler(function(ex) - table.insert(log, "outer:" .. tostring(ex)) - return always("ok") - end) - - local res = perform(ev) - - assert(res == "ok", - "wrap_handler nesting: final result mismatch") - assert(#log == 2, - "wrap_handler nesting: expected two handlers invoked") - - -- Innermost handler must see the original error first. - assert(log[1]:match("^inner:.*boom%-inner"), - "wrap_handler nesting: inner handler did not see original exception first") - - -- Outermost handler must see the rethrown error. - assert(log[2]:match("^outer:.*inner%-rethrow"), - "wrap_handler nesting: outer handler did not see rethrown exception") - end - end - - - -------------------------------------------------------- - -- 9) finally: cleanup on success and on failure - -------------------------------------------------------- - do - -- 9.1 success path: cleanup(false, nil) once, result propagated + -- 8.1 success path: cleanup(false) once, result propagated do local calls = {} local base = always(7) - local ev = base:finally(function(aborted, exn) - calls[#calls + 1] = { aborted = aborted, exn = exn } + local ev = base:finally(function(aborted) + calls[#calls + 1] = aborted end) local r = perform(ev) assert(r == 7, "finally(success): wrong result") assert(#calls == 1, "finally(success): cleanup not called once") - assert(calls[1].aborted == false, + assert(calls[1] == false, "finally(success): aborted should be false") - assert(calls[1].exn == nil, - "finally(success): exn should be nil") end - -- 9.2 failure in post-sync action: cleanup(true, exn), then rethrow + -- 8.2 abort path: event loses in a choice → cleanup(true) do local calls = {} - local base = always(1):wrap(function(_) - -- simulate user post-sync failure - error("post-sync-fail") + -- This event never commits, so in choice it always loses. + local base = never():finally(function(aborted) + calls[#calls + 1] = aborted end) - local ev = base:finally(function(aborted, exn) - calls[#calls + 1] = { aborted = aborted, exn = exn } - end) + local ev = choice(base, always("WIN")) + local r = perform(ev) - local ok, err = pcall(function() - perform(ev) - end) - assert(not ok, "finally(error): expected re-raise of exception") - assert(tostring(err):match("post%-sync%-fail"), - "finally(error): wrong exception propagated") - - assert(#calls == 1, "finally(error): cleanup not called once") - assert(calls[1].aborted == true, - "finally(error): aborted should be true") - assert(calls[1].exn ~= nil, - "finally(error): exn should be non-nil") + assert(r == "WIN", + "finally(abort): wrong winner") + assert(#calls == 1, + "finally(abort): cleanup not called once") + assert(calls[1] == true, + "finally(abort): aborted should be true") end end + print("fibers.op tests: ok") runtime.stop() end) From af8d2ae192433a8d5c657b284b4f5e646aac37c7 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sun, 16 Nov 2025 10:18:43 +0000 Subject: [PATCH 018/138] remove the use of pcall/exception to trigger scope failure --- src/fibers.lua | 18 +-- src/fibers/op.lua | 8 +- src/fibers/runtime.lua | 68 +++++++- src/fibers/scope.lua | 342 +++++++++++++++++++++++------------------ tests/test_scope.lua | 305 +++++++++++++++++++----------------- 5 files changed, 432 insertions(+), 309 deletions(-) diff --git a/src/fibers.lua b/src/fibers.lua index 8a61e1b..2dbb381 100644 --- a/src/fibers.lua +++ b/src/fibers.lua @@ -33,23 +33,19 @@ fibers.perform = performer.perform fibers.now = runtime.now --- Run a main function under the scheduler's root scope. --- main_fn :: function(Scope, ...): () +-- main_fn :: function(Scope, ...): ... function fibers.run(main_fn, ...) local root = scope_mod.root() local args = { ... } - root:spawn(function() - -- Run main_fn inside a child scope of the current scope (root). - local res = pack( - pcall(function() - return scope_mod.run(main_fn, unpack(args)) - end) - ) + -- Run main_fn inside a child scope of the root, in its own fibre. + root:spawn(function(s) + local status, err = scope_mod.run(main_fn, unpack(args)) -- In all cases, stop the scheduler so runtime.main() returns. runtime.stop() - -- If the main scope failed, treat as fatal for the process. - if not res[1] then - print(unpack(res, 2, res.n)) + -- Treat non-ok main scope as fatal for the process. + if status ~= "ok" then + print(err) os.exit(255) end end) diff --git a/src/fibers/op.lua b/src/fibers/op.lua index b2f50e9..8696595 100644 --- a/src/fibers/op.lua +++ b/src/fibers/op.lua @@ -476,13 +476,13 @@ function Event:finally(cleanup) -- Success path: only runs if this event wins and completes its wraps -- without raising. local function success_wrap(...) - pcall(cleanup, false) + cleanup(false) return ... end -- Abort path: runs if this event participates in a choice and loses. local function abort_action() - pcall(cleanup, true) + cleanup(true) end return self:wrap(success_wrap):on_abort(abort_action) @@ -521,13 +521,13 @@ local function bracket(acquire, release, use) -- Success path: event wins and completes → release(res, false) once. local wrapped = ev:wrap(function(...) - pcall(release, res, false) + release(res, false) return ... end) -- Losing path: event participates in a choice but loses → release(res, true) once. return wrapped:on_abort(function() - pcall(release, res, true) + release(res, true) end) end) end diff --git a/src/fibers/runtime.lua b/src/fibers/runtime.lua index 055351a..fb1da7c 100644 --- a/src/fibers/runtime.lua +++ b/src/fibers/runtime.lua @@ -7,6 +7,23 @@ local sched = require 'fibers.sched' +local function id(...) + return ... +end + +-- Queue of uncaught fibre errors to be consumed by supervisors. +local error_queue = {} +local error_waiters = {} + +-- Task object used to wake a fibre waiting for an error. +local WaiterTask = {} +WaiterTask.__index = WaiterTask + +function WaiterTask:run() + -- Resume the waiting fibre with (wrap, fiber, err). + self.waiter:resume(id, self.err_fiber, self.err) +end + local _current_fiber local current_scheduler = sched.new() @@ -48,11 +65,24 @@ function Fiber:resume(wrap, ...) -- current_fiber = saved_current_fiber the KEY bit, we only get here when the coroutine above has yielded, -- but we then pop back in the fiber we previously displaced _current_fiber = saved_current_fiber + if coroutine.status(self.coroutine) == "dead" then + self.alive = false + end if not ok then - print('Error while running fiber: ' .. tostring(err)) - print(debug.traceback(self.coroutine)) - print('fibers history:\n' .. self.traceback) - os.exit(255) + -- Report uncaught error to error consumers. + if #error_waiters > 0 then + local waiter = table.remove(error_waiters, 1) + current_scheduler:schedule(setmetatable({ + waiter = waiter.fiber, + err_fiber = self, + err = err, + }, WaiterTask)) + else + error_queue[#error_queue + 1] = { + fiber = self, + err = err, + } + end end end @@ -98,6 +128,29 @@ local function stop() current_scheduler:stop() end +local function wait_fiber_error() + -- Fast path: if an error is already queued, return it immediately. + if #error_queue > 0 then + local rec = table.remove(error_queue, 1) + return rec.fiber, rec.err + end + + -- Otherwise, we must be in a fibre and suspend until an error arrives. + assert(_current_fiber, "wait_fiber_error must be called from within a fiber") + + local function block_fn(sched, fib) + -- Record this fibre as waiting for an error. When an error + -- arrives, the failing fibre will arrange to schedule a task + -- that resumes this fibre with (fiber, err). + error_waiters[#error_waiters + 1] = { fiber = fib } + end + + local wrap, err_fiber, err = _current_fiber:suspend(block_fn) + -- wrap should be the identity function; ignore it. + return err_fiber, err +end + + --- Runs the main event loop of the current scheduler. -- The scheduler will continue to run tasks and wait for events until stopped. -- @function main @@ -111,9 +164,10 @@ return { current_fiber = current_fiber, -- time and suspension - now = now, - suspend = suspend, - yield = yield, + now = now, + suspend = suspend, + yield = yield, + wait_fiber_error = wait_fiber_error, -- fiber management spawn = spawn, diff --git a/src/fibers/scope.lua b/src/fibers/scope.lua index 9827ace..bd714ed 100644 --- a/src/fibers/scope.lua +++ b/src/fibers/scope.lua @@ -1,29 +1,39 @@ --- fibers/scope.lua --- -- Scope module (structured concurrency). -- --- Provides a tree of Scope objects and a per-fiber “current scope”. +-- Scopes form a tree of supervision domains. -- -- Semantics: -- - A single root scope exists for the process. --- - Each fiber has an associated current scope (defaulting to root). --- - scope.current() returns the current scope for the fiber, --- or the process-wide current scope when not in a fiber. --- - scope.run(fn, ...) runs fn in a fresh child scope in the --- current context (fiber or non-fiber), waits for its children, --- runs defers, and then returns or raises based on scope status. --- - s:spawn(fn, ...) spawns a new fiber whose current scope is s +-- - Each fibre has an associated current scope (defaulting to root). +-- - scope.current() returns the current scope for the fibre, +-- or the process-wide current scope when not in a fibre. +-- - scope.run(fn, ...) runs fn in a fresh child scope in its *own* +-- fibre and then returns (status, error, ...body_results). +-- - s:spawn(fn, ...) spawns a new fibre whose current scope is s -- for the duration of fn. --- - scope.with_ev(build_ev) creates a *first-class Event* that +-- - scope.with_ev(build_ev) creates a first-class Event that -- represents running a child scope whose body is build_ev(child). -- --- Policies implemented: --- - Status: "running" | "ok" | "failed" | "cancelled". --- - Fail-fast failure propagation by default. --- - Cancellation via Scope:cancel(reason) and a cancellation event. --- - Child fibers tracked via a waitgroup; scope exit waits for them. --- - Scope-level defers (LIFO) run at scope exit. --- - Scope:run_ev(ev) wraps an Event with failure + cancellation. +-- Status model: +-- - "running" : scope is active; children may be running. +-- - "ok" : all fibres finished without uncaught errors and +-- no explicit cancellation. +-- - "failed" : at least one fibre ended with an uncaught error. +-- - "cancelled" : explicit cancellation, or abort via with_ev. +-- +-- Failure handling: +-- - Uncaught Lua errors from any fibre are reported by runtime. +-- - The scope owning that fibre records a failure and cancels +-- its children (fail-fast). +-- - Callers observe this via status(), join_ev(), run(), etc. +-- +-- Cancellation in events: +-- - Scope:run_ev(ev) races ev against a cancellation event. +-- - If cancellation wins, the event result is: +-- ok = false, +-- value1 = reason, +-- value2 = nil. -- -- @module fibers.scope @@ -42,11 +52,15 @@ Scope.__index = Scope -- Weak-keyed table mapping Fiber objects to their current Scope. local fiber_scopes = setmetatable({}, { __mode = "k" }) --- Process-wide root scope and “current scope” when *not* in a fiber. +-- Process-wide root scope and “current scope” when *not* in a fibre. local root_scope local global_scope --- Internal: current fiber object, or nil if not in a fiber. +---------------------------------------------------------------------- +-- Internal helpers +---------------------------------------------------------------------- + +-- Internal: current fibre object, or nil if not in a fibre. local function current_fiber() return runtime.current_fiber() end @@ -61,21 +75,25 @@ local function new_scope(parent) _status = "running", -- "running" | "ok" | "failed" | "cancelled" _error = nil, -- primary error / cancellation cause _failures = {}, -- additional failures - failure_mode = "fail_fast", -- only fail_fast for now + failure_mode = "fail_fast", -- placeholder for future policies -- Concurrency tracking - _wg = waitgroup.new(), -- waitgroup for child fibers + _wg = waitgroup.new(), -- waitgroup for child fibres _defers = {}, -- LIFO list of deferred handlers - -- Cancellation and join - _cancel_cond = op.new_cond(), -- one-shot cond; signalled on cancel/failure - _join_cond = op.new_cond(), -- one-shot cond; signalled on scope exit + -- Cancellation and join conditions + _cancel_cond = op.new_cond(), -- signalled on cancel/failure + _join_cond = op.new_cond(), -- signalled when scope is closed + + -- Join worker flag + _join_worker_started = false, }, Scope) if parent then local children = parent._children children[#children + 1] = s end + return s end @@ -84,13 +102,36 @@ local function root() if not root_scope then root_scope = new_scope(nil) global_scope = root_scope + + -- Supervisor fibre: translate uncaught fibre errors into + -- scope failures and cancellations. + runtime.spawn(function() + while true do + local fib, err = runtime.wait_fiber_error() + if not fib then + -- If runtime chooses to return nil, terminate. + break + end + local s = fiber_scopes[fib] + if s then + -- mark failure + cancel children + s:_record_failure(err) + -- ensure the scope's waitgroup is decremented for this fibre + s._wg:done() + else + -- Unscoped fibre failure: treat as fatal for now. + print("Unscoped fibre error: " .. tostring(err)) + os.exit(255) + end + end + end) end return root_scope end --- Return the current Scope. --- Inside a fiber: the fiber's mapped scope, or the root if none. --- Outside a fiber: the process-wide current scope, defaulting to root. +-- Inside a fibre: the fibre's mapped scope, or the root if none. +-- Outside a fibre: the process-wide current scope, defaulting to root. local function current() local fib = current_fiber() if fib then @@ -100,76 +141,42 @@ local function current() end --- Internal helper: run fn(scope, ...) with 'scope' as current in this context. --- Returns a packed result table: { n = ..., [1] = ok, [2..n] = values }. +-- Returns the raw multiple values from fn. local function with_scope(scope_obj, fn, ...) local fib = current_fiber() if fib then local prev = fiber_scopes[fib] fiber_scopes[fib] = scope_obj - local res = pack(pcall(fn, scope_obj, ...)) + local res = { fn(scope_obj, ...) } fiber_scopes[fib] = prev - return res + return unpack(res) else local prev = global_scope or root() global_scope = scope_obj - local res = pack(pcall(fn, scope_obj, ...)) + local res = { fn(scope_obj, ...) } global_scope = prev - return res + return unpack(res) end end ---------------------------------------------------------------------- --- Core scope API +-- Scope methods: lifecycle and failure ---------------------------------------------------------------------- ---- Run a function inside a fresh child scope of the current scope. --- Synchronous: runs in the current fiber or process context. --- Returns the body_fn results on success. --- On failure or cancellation, raises the scope's primary error. --- body_fn :: function(Scope, ...): ... -local function run(body_fn, ...) - local parent = current() - local child = new_scope(parent) - - -- Run the body with 'child' as current scope. - local res = with_scope(child, body_fn, ...) - local ok = res[1] - - -- If the body itself raised (outside Event machinery), - -- mark failure and cancel the scope (to trigger done_ev, etc.). - if child._status == "running" and not ok then - child._status = "failed" - child._error = res[2] - child:cancel(child._error) -- signals _cancel_cond, cancels children - end - - -- Wait for child fibers to complete (even after fail_fast). - op.perform(child._wg:wait_op()) - - -- If still running and not cancelled/failed, mark as ok. - if child._status == "running" then - child._status = "ok" - child._error = nil - end - - -- Run defers in LIFO order. - local defers = child._defers - for i = #defers, 1, -1 do - local f = defers[i] - defers[i] = nil - pcall(f, child) - end - - -- Signal join completion. - child._join_cond.signal() - - -- Propagate outcome to caller. - if child._status == "ok" then - return unpack(res, 2, res.n) +--- Internal: record a failure in this scope and cancel children. +function Scope:_record_failure(err) + if self._status == "running" or self._status == "cancelled" then + self._status = "failed" + if self._error == nil then + self._error = err + end + -- Fail-fast: cancel this scope and descendants. + self:cancel(self._error) else - error(child._error) + local failures = self._failures + failures[#failures + 1] = err end end @@ -178,7 +185,7 @@ function Scope:new_child() return new_scope(self) end ---- Register a deferred handler to run at scope exit (LIFO). +--- Register a deferred handler to run at scope close (LIFO). -- handler :: function(Scope) function Scope:defer(handler) local defers = self._defers @@ -190,15 +197,11 @@ end function Scope:cancel(reason) local r = reason or self._error or "scope cancelled" - if self._status == "running" then + if self._status == "running" or self._status == "ok" then self._status = "cancelled" if self._error == nil then self._error = r end - elseif self._status == "ok" then - -- Explicit cancellation after success: treat as cancelled. - self._status = "cancelled" - self._error = r end -- Signal cancellation to any waiters. @@ -214,13 +217,14 @@ function Scope:cancel(reason) end end ---- Spawn a child fiber attached to this scope. +--- Spawn a child fibre attached to this scope. -- fn :: function(Scope, ...): () -- ... :: arguments passed to fn -- -- Fail-fast semantics: --- - If fn raises, this scope's status becomes "failed", its primary --- error is set (if not already), and cancel() is invoked. +-- - If fn raises, the fibre dies, runtime reports the failure, +-- and this scope's status becomes "failed" with cancellation +-- propagated to children. function Scope:spawn(fn, ...) local args = { ... } self._wg:add(1) @@ -232,34 +236,16 @@ function Scope:spawn(fn, ...) fiber_scopes[fib] = self end - local ok, err if #args > 0 then - ok, err = pcall(fn, self, unpack(args)) + fn(self, unpack(args)) else - ok, err = pcall(fn, self) + fn(self) end if fib then fiber_scopes[fib] = prev end - if not ok then - -- Fail-fast policy: record failure and cancel the scope. - if self._status == "running" then - self._status = "failed" - if self._error == nil then - self._error = err - end - self:cancel(self._error) - else - -- Additional failures are recorded but do not change - -- the primary status at this stage. - local failures = self._failures - failures[#failures + 1] = err - end - -- Do not rethrow; errors are handled via scope status. - end - self._wg:done() end) end @@ -296,9 +282,49 @@ function Scope:failures() return out end +---------------------------------------------------------------------- +-- Join and done events +---------------------------------------------------------------------- + +-- Internal: start a join worker fibre that: +-- - waits for this scope's waitgroup to reach zero; +-- - sets final status to "ok" if still "running"; +-- - runs defers; and +-- - signals _join_cond. +function Scope:_start_join_worker() + if self._join_worker_started then return end + self._join_worker_started = true + + -- System fibre: not attached to this scope, so its operation is + -- not affected by this scope's cancellation. It runs under the + -- root scope's cancellation policy. + runtime.spawn(function() + -- Wait for child fibres of this scope to complete. + op.perform_raw(self._wg:wait_op()) + + -- If still running and not cancelled/failed, mark as ok. + if self._status == "running" then + self._status = "ok" + self._error = nil + end + + -- Run defers in LIFO order. + local defers = self._defers + for i = #defers, 1, -1 do + local f = defers[i] + defers[i] = nil + f(self) + end + + -- Signal join completion. + self._join_cond.signal() + end) +end + --- Event that fires once the scope has reached a terminal status. -- Returns (status, error) when synchronised. function Scope:join_ev() + self:_start_join_worker() local ev = self._join_cond.wait_op() return ev:wrap(function() return self._status, self._error @@ -319,10 +345,12 @@ end ---------------------------------------------------------------------- -- Internal: cancellation event used when running events under this scope. +-- Convention for cancellable events: +-- ok:boolean, value1_or_reason, value2 local function cancel_event(self) local ev = self._cancel_cond.wait_op() return ev:wrap(function() - error(self._error or "scope cancelled") + return false, self._error or "scope cancelled", nil end) end @@ -334,9 +362,9 @@ function Scope:run_ev(ev) end --- Synchronise on an event under this scope. --- Equivalent to op.perform(self:run_ev(ev)). +-- Equivalent to op.perform_raw(self:run_ev(ev)). function Scope:sync(ev) - return op.perform(self:run_ev(ev)) + return op.perform_raw(self:run_ev(ev)) end ---------------------------------------------------------------------- @@ -351,20 +379,16 @@ end -- * create a child scope of scope.current(); -- * install it as the current scope while build_ev runs; -- * run build_ev(child) as an Event under normal CML semantics --- (i.e. whoever performs this Event controls cancellation etc.); --- * on conclusion or abort, wait for child fibers, run defers, +-- (whoever performs this Event controls cancellation etc.); +-- * on conclusion or abort, wait for child fibres, run defers, -- and signal join_ev(); --- * propagate the inner Event's result or error. +-- * propagate the inner Event's result or error as usual. local function with_ev(build_ev) return op.guard(function() local parent = current() local child = new_scope(parent) - -- bracket acquires "current scope = child", and guarantees - -- we restore the previous current scope and run scope cleanup - -- exactly once, whether the event wins, errors, or is aborted. local function acquire() - -- Install child as current, remember what to restore. local fib = current_fiber() if fib then local prev = fiber_scopes[fib] @@ -393,50 +417,70 @@ local function with_ev(build_ev) child:cancel(child._error) end - -- Wait for child fibers to complete. - op.perform(child._wg:wait_op()) - - -- If still running and not cancelled/failed, mark as ok. - if child._status == "running" then - child._status = "ok" - child._error = nil - end - - -- Run defers in LIFO order. - local defers = child._defers - for i = #defers, 1, -1 do - local f = defers[i] - defers[i] = nil - pcall(f, child) - end - - -- Signal join completion. - child._join_cond.signal() + -- Ensure the child scope is closed and defers run. + op.perform_raw(child:join_ev()) end local function use() -- Here the child is already installed as current(). -- build_ev must return an Event, and must not perform it. - local ok, ev = pcall(build_ev, child) - if not ok then - local ex = ev - -- mark failure & cancel the scope - if child._status == "running" then - child._status = "failed" - child._error = ex - child:cancel(ex) - end - error(ex) - end - -- The inner event itself may fail; that is handled by whoever - -- is performing this with_ev event (typically via Scope:run_ev). - return ev + return build_ev(child) end return op.bracket(acquire, release, use) end) end +---------------------------------------------------------------------- +-- scope.run: run a child scope in its own fibre +---------------------------------------------------------------------- + +--- Run a function inside a fresh child scope of the current scope. +-- +-- body_fn :: function(Scope, ...): ...results... +-- +-- Behaviour: +-- * A child scope of scope.current() is created. +-- * body_fn is run in a *separate fibre* with that child as current. +-- * All fibres spawned under that child are tracked. +-- * When the child scope has closed (ok/failed/cancelled, defers run), +-- this function returns: +-- status, error, ...results_from_body_fn... +-- +-- status :: "ok" | "failed" | "cancelled" +-- error :: primary error / cancellation reason, or nil on "ok". +-- +-- On failure or cancellation, no Lua error is thrown here; callers +-- should branch on status. +local function run(body_fn, ...) + local parent = current() + local child = new_scope(parent) + local args = { ... } + + -- store body results on the child scope + child._result = nil + + -- body fibre under the child scope + child:spawn(function(s) + local res = { body_fn(s, unpack(args)) } + s._result = res + end) + + -- wait for the child scope to reach a terminal state + local status, err = op.perform_raw(child:join_ev()) + + local res = child._result + if res then + return status, err, unpack(res) + else + return status, err + end +end + +---------------------------------------------------------------------- +-- Public API +---------------------------------------------------------------------- + return { root = root, current = current, diff --git a/tests/test_scope.lua b/tests/test_scope.lua index 5cfa538..bdfc77a 100644 --- a/tests/test_scope.lua +++ b/tests/test_scope.lua @@ -10,19 +10,19 @@ local op = require "fibers.op" local performer = require "fibers.performer" ------------------------------------------------------------------------------- --- 1. Structural tests (your originals, lightly generalised) +-- 1. Structural tests ------------------------------------------------------------------------------- local function test_outside_fibers() local root = scope.root() - -- current() outside any fiber should be the root (process-wide current scope) - assert(scope.current() == root, "outside fibers, current() should be root") + -- current() outside any fibre should be the root (process-wide current scope) + assert(scope.current() == root, "outside fibres, current() should be root") local outer_scope local inner_scope - scope.run(function(s) + local st, err = scope.run(function(s) outer_scope = s -- Inside run, current() should be this child scope @@ -41,7 +41,7 @@ local function test_outside_fibers() assert(found_outer, "root:children() should contain outer scope") -- Nested run creates a grandchild of s - scope.run(function(child2) + local st2, err2 = scope.run(function(child2) inner_scope = child2 assert(scope.current() == child2, "inside nested run, current() should be nested child") assert(child2:parent() == s, "nested scope parent must be outer scope") @@ -57,16 +57,21 @@ local function test_outside_fibers() assert(found_inner, "outer scope children() should contain nested scope") end) + assert(st2 == "ok" and err2 == nil, + "nested scope.run should complete with status ok") + -- After nested run, current() should be back to the outer scope assert(scope.current() == s, "after nested run, current() should be outer scope again") end) + assert(st == "ok" and err == nil, "outer scope.run should complete with status ok") + assert(outer_scope ~= nil, "outer_scope should have been set") assert(inner_scope ~= nil, "inner_scope should have been set") assert(outer_scope ~= inner_scope, "outer and inner scopes must differ") - -- After scope.run returns, current() outside fibers should be root again - assert(scope.current() == scope.root(), "after scope.run, current() should be root outside fibers") + -- After scope.run returns, current() outside fibres should be root again + assert(scope.current() == scope.root(), "after scope.run, current() should be root outside fibres") end local function test_inside_fibers() @@ -75,50 +80,57 @@ local function test_inside_fibers() local child_in_fiber local grandchild_in_fiber - -- Spawn a fiber anchored to the root scope. + -- Use a cond to wait for the spawned fibre to finish. + local done = op.new_cond() + + -- Spawn a fibre anchored to the root scope. root:spawn(function(s) - -- In this fiber, s is the scope used for spawn -> root + -- In this fibre, s is the scope used for spawn -> root assert(s == root, "spawn(fn) on root should pass root as scope") - assert(scope.current() == root, "inside spawned fiber, current() should be root initially") + assert(scope.current() == root, "inside spawned fibre, current() should be root initially") - -- Create a child scope inside the fiber - scope.run(function(child) + -- Create a child scope inside the fibre + local st, err = scope.run(function(child) child_in_fiber = child - assert(scope.current() == child, "inside scope.run in fiber, current() should be child") - assert(child:parent() == root, "child-in-fiber parent must be root") + assert(scope.current() == child, "inside scope.run in fibre, current() should be child") + assert(child:parent() == root, "child-in-fibre parent must be root") -- Create a grandchild scope - scope.run(function(grandchild) + local st2, err2 = scope.run(function(grandchild) grandchild_in_fiber = grandchild - assert(scope.current() == grandchild, "inside nested run in fiber, current() should be grandchild") + assert(scope.current() == grandchild, "inside nested run in fibre, current() should be grandchild") assert(grandchild:parent() == child, "grandchild parent must be child") end) + assert(st2 == "ok" and err2 == nil, + "nested scope.run in fibre should complete with status ok") + -- After nested run, current() should be back to child - assert(scope.current() == child, "after nested run in fiber, current() should be child again") + assert(scope.current() == child, "after nested run in fibre, current() should be child again") end) - -- After inner run, current() should be back to root for this fiber - assert(scope.current() == root, "after scope.run in fiber, current() should be root again") + assert(st == "ok" and err == nil, + "scope.run in fibre should complete with status ok") - -- Stop the scheduler once all fiber-local tests have run - runtime.stop() + -- After inner run, current() should be back to root for this fibre + assert(scope.current() == root, "after scope.run in fibre, current() should be root again") + + done.signal() end) - -- Drive the scheduler so the spawned fiber runs - runtime.main() + -- Drive until the child fibre finishes. + performer.perform(done.wait_op()) - -- After main() returns we are back outside fibers; - -- current() should again be the process-wide current scope (root). - assert(scope.current() == root, "after runtime.main, current() outside fibers should be root") + -- After that, we are still inside the test fibre; current() should be root. + assert(scope.current() == root, "after inner fibre completes, current() should be root in test fibre") - -- Check that scopes created inside the fiber were recorded + -- Check that scopes created inside the fibre were recorded assert(child_in_fiber ~= nil, "child_in_fiber should have been set") assert(grandchild_in_fiber ~= nil, "grandchild_in_fiber should have been set") assert(child_in_fiber:parent() == root, "child_in_fiber parent must be root") assert(grandchild_in_fiber:parent() == child_in_fiber, "grandchild_in_fiber parent must be child_in_fiber") - -- Check that root children include the child created in this fiber. + -- Check that root children include the child created in this fibre. local rc = root:children() local found_child = false for _, s in ipairs(rc) do @@ -131,7 +143,7 @@ local function test_inside_fibers() end ------------------------------------------------------------------------------- --- 1b. New: basic scope.with_ev behaviour +-- 1b. basic scope.with_ev behaviour ------------------------------------------------------------------------------- local function test_with_ev_basic() @@ -150,7 +162,7 @@ local function test_with_ev_basic() end) end) - local a, b = op.perform(ev) + local a, b = performer.perform(ev) assert(a == 99 and b == "ok", "with_ev should propagate child event results") assert(child_scope ~= nil, "with_ev should have created a child scope") @@ -165,51 +177,51 @@ end local function test_run_success_and_failure() local root = scope.root() - -- Success case: scope.run returns body results, status becomes "ok". + -- Success case: scope.run returns status ok and body results. local success_scope - local a, b = scope.run(function(s) + local st, err, a, b = scope.run(function(s) success_scope = s - local st, err = s:status() - assert(st == "running" and err == nil, "inside body, status should be running") + local st0, err0 = s:status() + assert(st0 == "running" and err0 == nil, "inside body, status should be running") return 42, "x" end) + assert(st == "ok" and err == nil, + "scope.run should report status ok on success") assert(a == 42 and b == "x", "scope.run should return body results on success") + local st_ok, err_ok = success_scope:status() assert(st_ok == "ok" and err_ok == nil, "successful scope should end with status ok and no error") assert(success_scope:parent() == root, "success scope parent should be root") - -- Failure case: body error propagates, status becomes "failed". + -- Failure case: body error becomes scope failure; scope.run does not throw. local fail_scope - local ok = pcall(function() - scope.run(function(s) - fail_scope = s - error("body failure") - end) + local st_fail, err_fail = scope.run(function(s) + fail_scope = s + error("body failure") end) - assert(not ok, "scope.run should rethrow body error on failure") - local st_fail, err_fail = fail_scope:status() - assert(st_fail == "failed", "failed scope should have status 'failed'") - assert(type(err_fail) == "string" or err_fail ~= nil, - "failed scope should have a primary error recorded") + + assert(st_fail == "failed", "scope.run should report status failed on body error") + assert(err_fail ~= nil, "failed scope should have a primary error recorded") assert(tostring(err_fail):find("body failure", 1, true), "failed scope primary error should mention the body failure") end local function test_run_explicit_cancel() - -- If the body explicitly cancels the scope, scope.run should raise - -- the cancellation reason and status should be "cancelled". + -- If the body explicitly cancels the scope, scope.run should + -- report status 'cancelled' and the cancellation reason. local cancelled_scope - local ok = pcall(function() - scope.run(function(s) - cancelled_scope = s - s:cancel("stop here") - end) + local st, serr = scope.run(function(s) + cancelled_scope = s + s:cancel("stop here") end) - assert(not ok, "scope.run should raise when scope is cancelled inside body") - local st, serr = cancelled_scope:status() - assert(st == "cancelled", "cancelled scope should have status 'cancelled'") + + assert(st == "cancelled", "scope.run should report cancelled when scope is cancelled inside body") assert(serr == "stop here", "cancelled scope error should be the cancellation reason") + + local st2, serr2 = cancelled_scope:status() + assert(st2 == "cancelled", "cancelled scope should have status 'cancelled'") + assert(serr2 == "stop here", "cancelled scope error should be the cancellation reason") end ------------------------------------------------------------------------------- @@ -220,20 +232,22 @@ local function test_defers_lifo_and_failure() local order = {} local scope_ref - local ok = pcall(function() - scope.run(function(s) - scope_ref = s - s:defer(function() table.insert(order, "first") end) - s:defer(function() table.insert(order, "second") end) - error("boom in body") - end) + local st, serr = scope.run(function(s) + scope_ref = s + s:defer(function() table.insert(order, "first") end) + s:defer(function() table.insert(order, "second") end) + error("boom in body") end) - assert(not ok, "scope.run should propagate body failure") - local st, serr = scope_ref:status() - assert(st == "failed", "scope should be failed after body error") + assert(st == "failed", "scope.run should report failure when body errors") assert(tostring(serr):find("boom in body", 1, true), "primary error should mention the body error") + + local st2, serr2 = scope_ref:status() + assert(st2 == "failed", "scope should be failed after body error") + assert(tostring(serr2):find("boom in body", 1, true), + "scope error should mention the body error") + assert(#order == 2, "two defers should have run") assert(order[1] == "second" and order[2] == "first", "defers should run in LIFO order even on failure") @@ -244,47 +258,58 @@ end ------------------------------------------------------------------------------- local function test_sync_wraps_event_failure() - -- Event whose post-wrap raises: tests wrap_failure path. + -- Event whose post-wrap raises: tests that scope sees failure. local ev = op.always(123):wrap(function(v) assert(v == 123, "inner always should pass its value") error("event post-wrap failure") end) local failed_scope - local ok = pcall(function() - scope.run(function(s) - failed_scope = s - -- This synchronisation should trigger fail-fast handling via performer. - performer.perform(ev) - end) + local st, serr = scope.run(function(s) + failed_scope = s + -- This synchronisation will cause this fibre to fail; + -- scope should record status 'failed'. + performer.perform(ev) end) - assert(not ok, "performer.perform on failing event should raise") - local st, serr = failed_scope:status() - assert(st == "failed", "scope should be failed after event failure") + assert(st == "failed", "scope.run should report failure when event post-wrap fails") assert(tostring(serr):find("event post-wrap failure", 1, true), "scope error should mention the event failure") + + local st2, serr2 = failed_scope:status() + assert(st2 == "failed", "scope should be failed after event failure") + assert(tostring(serr2):find("event post-wrap failure", 1, true), + "scope error should mention the event failure") end local function test_sync_respects_cancellation() - -- Race a never-ready event against cancellation. + -- Race a never-ready event against cancellation; cancellation should win + -- and be reflected as (ok=false, reason, nil) at the event level, and + -- as status 'cancelled' at the scope level. local ev = op.never() local cancelled_scope - local ok = pcall(function() - scope.run(function(s) - cancelled_scope = s - s:cancel("cancel before sync") - -- This should immediately raise via the cancellation event, - -- rather than blocking on never(). - performer.perform(ev) - end) + local st, serr, ok_ev, reason_ev = scope.run(function(s) + cancelled_scope = s + s:cancel("cancel before sync") + + local ok2, reason2 = performer.perform(ev) + assert(ok2 == false, "performer.perform should return ok=false after cancellation") + assert(reason2 == "cancel before sync", + "performer.perform should return cancellation reason") + return ok2, reason2 end) - assert(not ok, "performer.perform on never() after cancel should raise") - local st, serr = cancelled_scope:status() assert(st == "cancelled", "scope should be cancelled") assert(serr == "cancel before sync", "cancellation reason should be preserved") + + assert(ok_ev == false, "scope.run should return the event ok flag from body") + assert(reason_ev == "cancel before sync", + "scope.run should return the cancellation reason from body") + + local st2, serr2 = cancelled_scope:status() + assert(st2 == "cancelled", "cancelled_scope should be cancelled") + assert(serr2 == "cancel before sync", "cancelled_scope error should be the cancellation reason") end ------------------------------------------------------------------------------- @@ -294,17 +319,18 @@ end local function test_join_and_done_events() -- Failed scope: body error. local failed_scope - local ok_fail = pcall(function() - scope.run(function(s) - failed_scope = s - error("join test failure") - end) + local st_fail, err_fail = scope.run(function(s) + failed_scope = s + error("join test failure") end) - assert(not ok_fail, "failed scope.run should raise") + + assert(st_fail == "failed", "failed scope.run should report failed") + assert(tostring(err_fail):find("join test failure", 1, true), + "failed scope error should mention the body failure") do local ev = failed_scope:join_ev() - local st, jerr = op.perform(ev) + local st, jerr = performer.perform(ev) assert(st == "failed", "join_ev on failed scope should report 'failed'") assert(tostring(jerr):find("join test failure", 1, true), "join_ev error should mention the body failure") @@ -312,7 +338,7 @@ local function test_join_and_done_events() do local ev = failed_scope:done_ev() - local reason = op.perform(ev) + local reason = performer.perform(ev) -- For a failed scope we also call cancel(error), so done_ev -- should be triggered and report the same error. assert(tostring(reason):find("join test failure", 1, true), @@ -321,24 +347,25 @@ local function test_join_and_done_events() -- Cancelled scope (explicit cancel, not body error). local cancelled_scope - local ok_cancel = pcall(function() - scope.run(function(s) - cancelled_scope = s - s:cancel("stop again") - end) + local st_cancel, err_cancel = scope.run(function(s) + cancelled_scope = s + s:cancel("stop again") end) - assert(not ok_cancel, "cancelled scope.run should raise") + + assert(st_cancel == "cancelled", "cancelled scope.run should report cancelled") + assert(err_cancel == "stop again", + "cancelled scope.run should report the cancellation reason") do local ev = cancelled_scope:join_ev() - local st, jerr = op.perform(ev) + local st, jerr = performer.perform(ev) assert(st == "cancelled" and jerr == "stop again", "join_ev on cancelled scope should report 'cancelled' and reason") end do local ev = cancelled_scope:done_ev() - local reason = op.perform(ev) + local reason = performer.perform(ev) assert(reason == "stop again", "done_ev on cancelled scope should report cancellation reason") end @@ -352,37 +379,31 @@ local function test_fail_fast_from_child_fibre() local root = scope.root() local test_scope - root:spawn(function() - -- Create a child scope under root in this fibre. - local ok = pcall(function() - scope.run(function(s) - test_scope = s + local st, serr = scope.run(function(s) + test_scope = s - -- Use a condition to ensure the child fibre runs before we exit the body. - local cond = op.new_cond() + -- Use a condition to ensure the child fibre runs before we exit the body. + local cond = op.new_cond() - -- Spawn a child fibre that signals, then fails. - s:spawn(function(_) - cond.signal() - error("child fibre failure") - end) - - -- Wait for the cond via performer, so we do not exit - -- the body until after the child has signalled. - performer.perform(cond.wait_op()) - end) + -- Spawn a child fibre that signals, then fails. + s:spawn(function(_) + cond.signal() + error("child fibre failure") end) - assert(not ok, "scope.run should raise when a child fibre fails") - local st, serr = test_scope:status() - assert(st == "failed", "scope status should be failed after child fibre failure") - assert(tostring(serr):find("child fibre failure", 1, true), - "primary error should mention child fibre failure") - - runtime.stop() + -- Wait for the cond via performer, so we do not exit + -- the body until after the child has signalled. + performer.perform(cond.wait_op()) end) - runtime.main() + assert(st == "failed", "scope.run should report failed when a child fibre fails") + assert(tostring(serr):find("child fibre failure", 1, true), + "primary error should mention child fibre failure") + + local st2, serr2 = test_scope:status() + assert(st2 == "failed", "scope status should be failed after child fibre failure") + assert(tostring(serr2):find("child fibre failure", 1, true), + "scope primary error should mention child fibre failure") end ------------------------------------------------------------------------------- @@ -391,17 +412,25 @@ end local function main() io.stdout:write("Running scope tests...\n") - test_outside_fibers() - test_inside_fibers() - test_with_ev_basic() - test_run_success_and_failure() - test_run_explicit_cancel() - test_defers_lifo_and_failure() - test_sync_wraps_event_failure() - test_sync_respects_cancellation() - test_join_and_done_events() - test_fail_fast_from_child_fibre() - io.stdout:write("OK\n") + + -- Run all tests inside a single top-level fibre so that scope.run + -- and performer.perform are always called from within the scheduler. + runtime.spawn(function() + test_outside_fibers() + test_inside_fibers() + test_with_ev_basic() + test_run_success_and_failure() + test_run_explicit_cancel() + test_defers_lifo_and_failure() + test_sync_wraps_event_failure() + test_sync_respects_cancellation() + test_join_and_done_events() + test_fail_fast_from_child_fibre() + io.stdout:write("OK\n") + runtime.stop() + end) + + runtime.main() end main() From 7c01d995492909d9755fd7d98af9f46d7b1bb289 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Mon, 17 Nov 2025 02:34:45 +0000 Subject: [PATCH 019/138] adds coxpcall, protects core lib calls only outside scopes, prevents dead scopes from spawning --- src/coxpcall.lua | 106 +++++++++++++++++++++++++++++++++++++++++++ src/fibers/op.lua | 3 +- src/fibers/scope.lua | 57 ++++++++++++----------- 3 files changed, 136 insertions(+), 30 deletions(-) create mode 100644 src/coxpcall.lua diff --git a/src/coxpcall.lua b/src/coxpcall.lua new file mode 100644 index 0000000..e0d9ac5 --- /dev/null +++ b/src/coxpcall.lua @@ -0,0 +1,106 @@ +-- coxpcall.lua + +local M = {} + +------------------------------------------------------------------------------- +-- Checks if (x)pcall function is coroutine safe +------------------------------------------------------------------------------- +local function isCoroutineSafe(func) + local co = coroutine.create(function() + return func(coroutine.yield, function() end) + end) + + coroutine.resume(co) + return coroutine.resume(co) +end + +-- Fast path: environment already has coroutine-safe pcall/xpcall +if isCoroutineSafe(pcall) and isCoroutineSafe(xpcall) then + -- No globals; just return plain ones + M.pcall = pcall + M.xpcall = xpcall + M.running = coroutine.running + return M +end + +------------------------------------------------------------------------------- +-- Implements xpcall with coroutines +------------------------------------------------------------------------------- + +local performResume, handleReturnValue +local oldpcall, oldxpcall = pcall, xpcall +local pack = table.pack or function(...) return { n = select("#", ...), ... } end +local unpack = table.unpack or unpack +local running = coroutine.running +local coromap = setmetatable({}, { __mode = "k" }) + +function handleReturnValue(err, co, status, ...) + if not status then + return false, err(debug.traceback(co, (...)), ...) + end + if coroutine.status(co) == 'suspended' then + return performResume(err, co, coroutine.yield(...)) + else + return true, ... + end +end + +function performResume(err, co, ...) + return handleReturnValue(err, co, coroutine.resume(co, ...)) +end + +local function id(trace, ...) + return trace +end + +local function coxpcall(f, err, ...) + local current = running() + if not current then + -- Not in a coroutine: fall back to normal pcall/xpcall + if err == id then + return oldpcall(f, ...) + else + if select("#", ...) > 0 then + local oldf, params = f, pack(...) + f = function() return oldf(unpack(params, 1, params.n)) end + end + return oldxpcall(f, err) + end + else + local res, co = oldpcall(coroutine.create, f) + if not res then + local newf = function(...) return f(...) end + co = coroutine.create(newf) + end + coromap[co] = current + return performResume(err, co, ...) + end +end + +local function corunning(coro) + if coro ~= nil then + assert(type(coro) == "thread", + "Bad argument; expected thread, got: " .. type(coro)) + else + coro = running() + end + while coromap[coro] do + coro = coromap[coro] + end + if coro == "mainthread" then return nil end + return coro +end + +------------------------------------------------------------------------------- +-- Implements pcall with coroutines +------------------------------------------------------------------------------- + +local function copcall(f, ...) + return coxpcall(f, id, ...) +end + +M.pcall = copcall +M.xpcall = coxpcall +M.running = corunning + +return M diff --git a/src/fibers/op.lua b/src/fibers/op.lua index 8696595..e8144e5 100644 --- a/src/fibers/op.lua +++ b/src/fibers/op.lua @@ -19,6 +19,7 @@ -- surrounding scope / fibre machinery. local runtime = require 'fibers.runtime' +local safe = require 'coxpcall' local unpack = rawget(table, "unpack") or _G.unpack local pack = rawget(table, "pack") or function(...) @@ -236,7 +237,7 @@ local function new_cond(opts) end if state.abort_fn then - pcall(state.abort_fn) + safe.pcall(state.abort_fn) end end diff --git a/src/fibers/scope.lua b/src/fibers/scope.lua index bd714ed..2422edf 100644 --- a/src/fibers/scope.lua +++ b/src/fibers/scope.lua @@ -41,10 +41,9 @@ local runtime = require 'fibers.runtime' local op = require 'fibers.op' local waitgroup = require 'fibers.waitgroup' +local safe = require 'coxpcall' + local unpack = rawget(table, "unpack") or _G.unpack -local pack = rawget(table, "pack") or function(...) - return { n = select("#", ...), ... } -end local Scope = {} Scope.__index = Scope @@ -140,27 +139,6 @@ local function current() return global_scope or root() end ---- Internal helper: run fn(scope, ...) with 'scope' as current in this context. --- Returns the raw multiple values from fn. -local function with_scope(scope_obj, fn, ...) - local fib = current_fiber() - if fib then - local prev = fiber_scopes[fib] - fiber_scopes[fib] = scope_obj - - local res = { fn(scope_obj, ...) } - fiber_scopes[fib] = prev - return unpack(res) - else - local prev = global_scope or root() - global_scope = scope_obj - - local res = { fn(scope_obj, ...) } - global_scope = prev - return unpack(res) - end -end - ---------------------------------------------------------------------- -- Scope methods: lifecycle and failure ---------------------------------------------------------------------- @@ -226,6 +204,7 @@ end -- and this scope's status becomes "failed" with cancellation -- propagated to children. function Scope:spawn(fn, ...) + assert(self._status == "running", "cannot spawn on a non-running scope") local args = { ... } self._wg:add(1) @@ -295,10 +274,14 @@ function Scope:_start_join_worker() if self._join_worker_started then return end self._join_worker_started = true - -- System fibre: not attached to this scope, so its operation is - -- not affected by this scope's cancellation. It runs under the - -- root scope's cancellation policy. runtime.spawn(function() + -- Attach this worker to the scope + local fib = current_fiber() + local prev = fib and fiber_scopes[fib] + if fib then + fiber_scopes[fib] = self + end + -- Wait for child fibres of this scope to complete. op.perform_raw(self._wg:wait_op()) @@ -308,12 +291,28 @@ function Scope:_start_join_worker() self._error = nil end - -- Run defers in LIFO order. + -- Run defers in LIFO order, protected. local defers = self._defers for i = #defers, 1, -1 do local f = defers[i] defers[i] = nil - f(self) + + local ok, err = safe.pcall(f, self) + if not ok then + -- Treat defer failures as scope failures, but do not crash process. + if self._status == "ok" then + self._status = "failed" + self._error = self._error or err + else + local failures = self._failures + failures[#failures + 1] = err + end + end + end + + -- Restore previous scope mapping for this fibre + if fib then + fiber_scopes[fib] = prev end -- Signal join completion. From 85a79389e23dc60f77462f58f0b3304763385d18 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Mon, 17 Nov 2025 02:36:29 +0000 Subject: [PATCH 020/138] small tidyups --- src/fibers.lua | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/fibers.lua b/src/fibers.lua index 2dbb381..1ae6b09 100644 --- a/src/fibers.lua +++ b/src/fibers.lua @@ -21,9 +21,6 @@ local channel = require 'fibers.channel' local op = require 'fibers.op' local unpack = rawget(table, "unpack") or _G.unpack -local pack = rawget(table, "pack") or function(...) - return { n = select("#", ...), ... } -end local fibers = {} @@ -39,7 +36,7 @@ function fibers.run(main_fn, ...) local args = { ... } -- Run main_fn inside a child scope of the root, in its own fibre. - root:spawn(function(s) + root:spawn(function() local status, err = scope_mod.run(main_fn, unpack(args)) -- In all cases, stop the scheduler so runtime.main() returns. runtime.stop() From ee10e6660282930d53774eb743b64c0810ea6e0e Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Mon, 17 Nov 2025 02:37:56 +0000 Subject: [PATCH 021/138] errors on performing non-events --- src/fibers/performer.lua | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/fibers/performer.lua b/src/fibers/performer.lua index 269ec62..5bcf48b 100644 --- a/src/fibers/performer.lua +++ b/src/fibers/performer.lua @@ -20,7 +20,15 @@ local function current_scope() return scope_mod.current and scope_mod.current() or nil end +local function assert_event(ev) + if type(ev) ~= "table" or getmetatable(ev) ~= op.Event then + error(("perform: expected Event, got %s (%s)"):format(type(ev), tostring(ev)), 3) + end +end + function M.perform(ev) + assert_event(ev) + local s = current_scope() if s and s.sync then return s:sync(ev) From 1924ab66b5ec16cc853f1da18eed7b9a71332156 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Mon, 17 Nov 2025 03:10:56 +0000 Subject: [PATCH 022/138] yes Mr Linter --- src/coxpcall.lua | 8 +++++--- src/fibers/runtime.lua | 4 ++-- tests/test_scope.lua | 8 ++++---- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/coxpcall.lua b/src/coxpcall.lua index e0d9ac5..466d817 100644 --- a/src/coxpcall.lua +++ b/src/coxpcall.lua @@ -29,8 +29,10 @@ end local performResume, handleReturnValue local oldpcall, oldxpcall = pcall, xpcall -local pack = table.pack or function(...) return { n = select("#", ...), ... } end -local unpack = table.unpack or unpack +local unpack = rawget(table, "unpack") or _G.unpack +local pack = rawget(table, "pack") or function(...) + return { n = select("#", ...), ... } +end local running = coroutine.running local coromap = setmetatable({}, { __mode = "k" }) @@ -49,7 +51,7 @@ function performResume(err, co, ...) return handleReturnValue(err, co, coroutine.resume(co, ...)) end -local function id(trace, ...) +local function id(trace) return trace end diff --git a/src/fibers/runtime.lua b/src/fibers/runtime.lua index fb1da7c..6c5774f 100644 --- a/src/fibers/runtime.lua +++ b/src/fibers/runtime.lua @@ -138,14 +138,14 @@ local function wait_fiber_error() -- Otherwise, we must be in a fibre and suspend until an error arrives. assert(_current_fiber, "wait_fiber_error must be called from within a fiber") - local function block_fn(sched, fib) + local function block_fn(_, fib) -- Record this fibre as waiting for an error. When an error -- arrives, the failing fibre will arrange to schedule a task -- that resumes this fibre with (fiber, err). error_waiters[#error_waiters + 1] = { fiber = fib } end - local wrap, err_fiber, err = _current_fiber:suspend(block_fn) + local _, err_fiber, err = _current_fiber:suspend(block_fn) -- wrap should be the identity function; ignore it. return err_fiber, err end diff --git a/tests/test_scope.lua b/tests/test_scope.lua index bdfc77a..c88369c 100644 --- a/tests/test_scope.lua +++ b/tests/test_scope.lua @@ -195,9 +195,9 @@ local function test_run_success_and_failure() assert(success_scope:parent() == root, "success scope parent should be root") -- Failure case: body error becomes scope failure; scope.run does not throw. - local fail_scope - local st_fail, err_fail = scope.run(function(s) - fail_scope = s + -- local fail_scope + local st_fail, err_fail = scope.run(function() + -- fail_scope = s error("body failure") end) @@ -376,7 +376,7 @@ end ------------------------------------------------------------------------------- local function test_fail_fast_from_child_fibre() - local root = scope.root() + -- local root = scope.root() local test_scope local st, serr = scope.run(function(s) From e74d3789f83f1395f9684667d44055502f7d24c5 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Mon, 17 Nov 2025 23:25:08 +0000 Subject: [PATCH 023/138] rebuild sleep_op as guarded sleep_until --- src/fibers/sleep.lua | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/fibers/sleep.lua b/src/fibers/sleep.lua index af57529..21f1648 100644 --- a/src/fibers/sleep.lua +++ b/src/fibers/sleep.lua @@ -38,11 +38,9 @@ end -- @tparam number dt The duration to sleep. -- @treturn operation The created operation. local function sleep_op(dt) - local function try() return dt <= 0 end - local function block(suspension, wrap_fn) - suspension.sched:schedule_after_sleep(dt, suspension:complete_task(wrap_fn)) - end - return op.new_primitive(nil, try, block) + return op.guard(function () + return sleep_until_op(runtime.now() + dt) + end) end --- Put the current fiber to sleep for a duration dt. From 81f498397b3c605a691e97962e39cde8481b9cb2 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Tue, 18 Nov 2025 07:29:49 +0000 Subject: [PATCH 024/138] express finally in terms of bracket --- src/fibers/op.lua | 58 ++++++++++++++++++++--------------------------- 1 file changed, 25 insertions(+), 33 deletions(-) diff --git a/src/fibers/op.lua b/src/fibers/op.lua index e8144e5..0e919c5 100644 --- a/src/fibers/op.lua +++ b/src/fibers/op.lua @@ -456,39 +456,6 @@ perform = function(ev) return wrap(unpack(suspended, 2, suspended.n)) end ----------------------------------------------------------------------- --- finally : (ev, cleanup) -> ev' --- --- cleanup(aborted:boolean) --- --- Semantics: --- * on normal post-sync completion: --- cleanup(false) is called (best-effort, protected). --- * if the event *loses* in a choice (via on_abort): --- cleanup(true) is called (best-effort, protected). --- --- Exceptions from ev's wrap or primitives are not intercepted here; --- they are handled by the surrounding scope as fibre failures. ----------------------------------------------------------------------- - -function Event:finally(cleanup) - assert(type(cleanup) == "function", "finally expects a function") - - -- Success path: only runs if this event wins and completes its wraps - -- without raising. - local function success_wrap(...) - cleanup(false) - return ... - end - - -- Abort path: runs if this event participates in a choice and loses. - local function abort_action() - cleanup(true) - end - - return self:wrap(success_wrap):on_abort(abort_action) -end - ---------------------------------------------------------------------- -- bracket : (acquire, release, use) -> 'a event -- @@ -533,6 +500,31 @@ local function bracket(acquire, release, use) end) end +---------------------------------------------------------------------- +-- finally : (ev, cleanup) -> ev' +-- +-- cleanup(aborted:boolean) +-- +-- Semantics: +-- * on normal post-sync completion: +-- cleanup(false) is called (best-effort, protected). +-- * if the event *loses* in a choice (via on_abort): +-- cleanup(true) is called (best-effort, protected). +-- +-- Exceptions from ev's wrap or primitives are not intercepted here; +-- they are handled by the surrounding scope as fibre failures. +---------------------------------------------------------------------- + +function Event:finally(cleanup) + assert(type(cleanup) == "function", "finally expects a function") + + return bracket( + function() return nil end, -- no real resource + function(_, aborted) cleanup(aborted) end, -- release + function() return self end -- use: just this event + ) +end + ---------------------------------------------------------------------- -- Higher-level choice helpers (built entirely from choice + wrap) ---------------------------------------------------------------------- From c4ab47e8576b0dc1e4d6e9ccfdeba439f89ffb84 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Tue, 18 Nov 2025 07:31:07 +0000 Subject: [PATCH 025/138] revert channel design --- src/fibers/channel.lua | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/src/fibers/channel.lua b/src/fibers/channel.lua index 696f114..ed2059b 100644 --- a/src/fibers/channel.lua +++ b/src/fibers/channel.lua @@ -25,13 +25,13 @@ local function new(buffer_size) end ---------------------------------------------------------------------- --- Helpers: pop active entries (skip cancelled ones) +-- Helpers: pop active entries based on suspension state ---------------------------------------------------------------------- local function pop_active(q) while not q:empty() do local entry = q:pop() - if not entry.cancelled then + if not entry.suspension or entry.suspension:waiting() then return entry end end @@ -49,7 +49,6 @@ function Channel:put_op(val) -- Per-operation state for this event instance. local entry = { val = val, - cancelled = false, suspension = nil, wrap = nil, } @@ -73,16 +72,10 @@ function Channel:put_op(val) local function block(suspension, wrap_fn) entry.suspension = suspension entry.wrap = wrap_fn - entry.cancelled = false putq:push(entry) end - local prim = op.new_primitive(nil, try, block) - - -- Mark this entry as cancelled if this put loses in a choice / is aborted. - return prim:on_abort(function() - entry.cancelled = true - end) + return op.new_primitive(nil, try, block) end ---------------------------------------------------------------------- @@ -94,7 +87,6 @@ function Channel:get_op() local buffer = self.buffer local entry = { - cancelled = false, suspension = nil, wrap = nil, } @@ -131,15 +123,10 @@ function Channel:get_op() local function block(suspension, wrap_fn) entry.suspension = suspension entry.wrap = wrap_fn - entry.cancelled = false getq:push(entry) end - local prim = op.new_primitive(nil, try, block) - - return prim:on_abort(function() - entry.cancelled = true - end) + return op.new_primitive(nil, try, block) end ---------------------------------------------------------------------- From 29eaf955667da798e5fcfd08097775d95f9e4ae6 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Wed, 19 Nov 2025 13:37:53 +0000 Subject: [PATCH 026/138] simplify and improve cond, waitgroups and layering for scopes --- src/fibers/cond.lua | 52 +++++++++++++++++++++------------------- src/fibers/scope.lua | 17 ++++++------- src/fibers/signaller.lua | 48 +++++++++++++++++++++++++++++++++++++ src/fibers/waitgroup.lua | 52 ++++++++++++++++------------------------ 4 files changed, 105 insertions(+), 64 deletions(-) create mode 100644 src/fibers/signaller.lua diff --git a/src/fibers/cond.lua b/src/fibers/cond.lua index 41255c8..38345c5 100644 --- a/src/fibers/cond.lua +++ b/src/fibers/cond.lua @@ -1,38 +1,40 @@ ---- fibers.cond module. --- 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' - -local perform = require 'fibers.performer'.perform +-- fibers/cond.lua +--- +-- Generic condition events built on top of signaller and op. +-- +-- A cond has: +-- cond.wait_op() -> Event +-- cond.signal() -- idempotent + +local op = require 'fibers.op' +local signaller = require 'fibers.signaller' +local perform = require 'fibers.performer'.perform local Cond = {} Cond.__index = Cond ---- Create a new condition variable. --- @treturn Cond The new condition variable. -local function new() - -- op.new_cond() returns a table with wait_op() and signal(). - local prim = op.new_cond() - return setmetatable(prim, Cond) +function Cond:wait_op() + return op.new_primitive( + nil, + function() return self.sig.triggered end, + function(resumer, wrap_fn) self.sig:add_waiter(resumer, wrap_fn) end + ) end ---- 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() ... end -- inherited from prim - ---- Put the fiber into a wait state on the condition variable. function Cond:wait() return perform(self:wait_op()) end ---- Wake up all fibers that are waiting on this condition variable. --- This is provided by op.new_cond() as prim.signal(). --- function Cond:signal() ... end -- inherited from prim +function Cond:signal() + return self.sig:signal() +end + +local function new() + return setmetatable({ + sig = signaller.new(), + }, Cond) +end return { - new = new + new = new, } diff --git a/src/fibers/scope.lua b/src/fibers/scope.lua index 2422edf..5b6b133 100644 --- a/src/fibers/scope.lua +++ b/src/fibers/scope.lua @@ -40,6 +40,7 @@ local runtime = require 'fibers.runtime' local op = require 'fibers.op' local waitgroup = require 'fibers.waitgroup' +local cond = require 'fibers.cond' local safe = require 'coxpcall' @@ -80,9 +81,9 @@ local function new_scope(parent) _wg = waitgroup.new(), -- waitgroup for child fibres _defers = {}, -- LIFO list of deferred handlers - -- Cancellation and join conditions - _cancel_cond = op.new_cond(), -- signalled on cancel/failure - _join_cond = op.new_cond(), -- signalled when scope is closed + -- Cancellation and join conditions (generic conds) + _cancel_cond = cond.new(), -- signalled on cancel/failure + _join_cond = cond.new(), -- signalled when scope is closed -- Join worker flag _join_worker_started = false, @@ -183,7 +184,7 @@ function Scope:cancel(reason) end -- Signal cancellation to any waiters. - self._cancel_cond.signal() + self._cancel_cond:signal() -- Propagate to children. local children = self._children @@ -316,7 +317,7 @@ function Scope:_start_join_worker() end -- Signal join completion. - self._join_cond.signal() + self._join_cond:signal() end) end @@ -324,7 +325,7 @@ end -- Returns (status, error) when synchronised. function Scope:join_ev() self:_start_join_worker() - local ev = self._join_cond.wait_op() + local ev = self._join_cond:wait_op() return ev:wrap(function() return self._status, self._error end) @@ -333,7 +334,7 @@ end --- Event that fires when the scope is cancelled or fails. -- Returns the cancellation/failure reason when synchronised. function Scope:done_ev() - local ev = self._cancel_cond.wait_op() + local ev = self._cancel_cond:wait_op() return ev:wrap(function() return self._error or "scope cancelled" end) @@ -347,7 +348,7 @@ end -- Convention for cancellable events: -- ok:boolean, value1_or_reason, value2 local function cancel_event(self) - local ev = self._cancel_cond.wait_op() + local ev = self._cancel_cond:wait_op() return ev:wrap(function() return false, self._error or "scope cancelled", nil end) diff --git a/src/fibers/signaller.lua b/src/fibers/signaller.lua new file mode 100644 index 0000000..8706388 --- /dev/null +++ b/src/fibers/signaller.lua @@ -0,0 +1,48 @@ +-- fibers/signaller.lua + +local Signaller = {} +Signaller.__index = Signaller + +local function new() + return setmetatable({ + triggered = false, + waiters = {}, -- array of { resumer = ..., wrap = ... } + }, Signaller) +end + +function Signaller:add_waiter(resumer, wrap_fn) + if self.triggered then + if resumer:waiting() then + resumer:complete(wrap_fn) + end + return + end + + local waiters = self.waiters + waiters[#waiters + 1] = { + resumer = resumer, + wrap = wrap_fn, + } +end + +function Signaller:signal() + if self.triggered then return end + self.triggered = true + + local waiters = self.waiters + for i = 1, #waiters do + local w = waiters[i] + waiters[i] = nil + if w + and w.resumer + and w.resumer:waiting() + then + w.resumer:complete(w.wrap) + end + end +end + +return { + new = new, + Signaller = Signaller, +} diff --git a/src/fibers/waitgroup.lua b/src/fibers/waitgroup.lua index bd75faa..05910e9 100644 --- a/src/fibers/waitgroup.lua +++ b/src/fibers/waitgroup.lua @@ -1,6 +1,7 @@ -- fibers/waitgroup.lua -local op = require 'fibers.op' -local perform = require 'fibers.performer'.perform +local op = require 'fibers.op' +local perform = require 'fibers.performer'.perform +local cond_mod = require 'fibers.cond' local Waitgroup = {} Waitgroup.__index = Waitgroup @@ -17,25 +18,24 @@ function Waitgroup:add(delta) return end - local old_waiters = self._counter - local new_waiters = old_waiters + delta + local old = self._counter + local new = old + delta - if new_waiters < 0 then + if new < 0 then error("waitgroup counter goes negative") end - self._counter = new_waiters + self._counter = new - if new_waiters == 0 then - -- This generation completes: wake any waiters. + if new == 0 then + -- This generation completes: wake any waiters and drop the cond. if self._cond then - self._cond.signal() + self._cond:signal() + self._cond = nil end - -- _cond remains a triggered cond for this generation; a new - -- generation will allocate a fresh cond when counter rises from 0. - elseif old_waiters == 0 and new_waiters > 0 then + elseif old == 0 and new > 0 then -- Starting a new generation: new condition for new work. - self._cond = op.new_cond() + self._cond = cond_mod.new() end end @@ -44,28 +44,18 @@ function Waitgroup:done() end function Waitgroup:wait_op() - local function try() - return self._counter == 0 - end - - local function block(suspension, wrap_fn) - -- Re-check after try(): counter may have become zero meanwhile. + -- Build the event lazily at sync time. + return op.guard(function() + -- If there is nothing outstanding, fire immediately. if self._counter == 0 then - suspension:complete(wrap_fn) - return + return op.always() end - -- At this point we are in an active generation. - local cond = self._cond - if not cond then - error("waitgroup internal error: missing condition for active generation") - end - - local cond_op = cond.wait_op() - cond_op.block_fn(suspension, wrap_fn) - end + -- Active generation: delegate to the generation's condition. + local cond = assert(self._cond, "waitgroup internal error: missing condition for active generation") - return op.new_primitive(nil, try, block) + return cond:wait_op() + end) end function Waitgroup:wait() From 25d660335260ede9ea6aeb49bab489d6b6c66636 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Wed, 19 Nov 2025 13:40:24 +0000 Subject: [PATCH 027/138] neaten sleep --- src/fibers/sleep.lua | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/src/fibers/sleep.lua b/src/fibers/sleep.lua index 21f1648..47d5041 100644 --- a/src/fibers/sleep.lua +++ b/src/fibers/sleep.lua @@ -9,18 +9,10 @@ local runtime = require 'fibers.runtime' local perform = require 'fibers.performer'.perform ---- Timeout class. --- Represents a timeout for a fiber. --- @type Timeout --- local Timeout = {} --- Timeout.__index = Timeout - ---- Create a new operation that puts the current fiber to sleep until the time t. --- @tparam number t The time to sleep until. --- @treturn operation The created operation. -local function sleep_until_op(t) +-- Primitive: wait until absolute time `t`. +local function deadline_op(t) local function try() - return t <= runtime.now() + return runtime.now() >= t end local function block(suspension, wrap_fn) suspension.sched:schedule_at_time(t, suspension:complete_task(wrap_fn)) @@ -28,6 +20,13 @@ local function sleep_until_op(t) return op.new_primitive(nil, try, block) end +--- Create a new operation that puts the current fiber to sleep until the time t. +-- @tparam number t The time to sleep until. +-- @treturn operation The created operation. +local function sleep_until_op(t) + return deadline_op(t) +end + --- Put the current fiber to sleep until time t. -- @tparam number t The time to sleep until. local function sleep_until(t) @@ -38,8 +37,8 @@ end -- @tparam number dt The duration to sleep. -- @treturn operation The created operation. local function sleep_op(dt) - return op.guard(function () - return sleep_until_op(runtime.now() + dt) + return op.guard(function() + return deadline_op(runtime.now() + dt) end) end @@ -50,8 +49,8 @@ local function sleep(dt) end return { - sleep = sleep, - sleep_op = sleep_op, - sleep_until = sleep_until, - sleep_until_op = sleep_until_op + sleep = sleep, + sleep_op = sleep_op, + sleep_until = sleep_until, + sleep_until_op = sleep_until_op, } From e82e7f0e254d6a6ffc192d45c676dd855792376f Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Wed, 19 Nov 2025 18:51:57 +0000 Subject: [PATCH 028/138] waitgroup.lua: removes variabl shadowing --- src/fibers/waitgroup.lua | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/fibers/waitgroup.lua b/src/fibers/waitgroup.lua index 05910e9..1c19421 100644 --- a/src/fibers/waitgroup.lua +++ b/src/fibers/waitgroup.lua @@ -18,22 +18,22 @@ function Waitgroup:add(delta) return end - local old = self._counter - local new = old + delta + local old_count = self._counter + local new_count = old_count + delta - if new < 0 then + if new_count < 0 then error("waitgroup counter goes negative") end - self._counter = new + self._counter = new_count - if new == 0 then + if new_count == 0 then -- This generation completes: wake any waiters and drop the cond. if self._cond then self._cond:signal() self._cond = nil end - elseif old == 0 and new > 0 then + elseif old_count == 0 and new_count > 0 then -- Starting a new generation: new condition for new work. self._cond = cond_mod.new() end From 1b9e03bac76924930e651883bb65ac29e4d6b5d5 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Fri, 21 Nov 2025 16:56:09 +0000 Subject: [PATCH 029/138] unexports op.new_cond and revises test_scope.lua to use fibers.cond --- src/fibers/op.lua | 1 - tests/test_scope.lua | 13 +++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/fibers/op.lua b/src/fibers/op.lua index 0e919c5..f9c16d3 100644 --- a/src/fibers/op.lua +++ b/src/fibers/op.lua @@ -578,7 +578,6 @@ return { choice = choice, guard = guard, with_nack = with_nack, - new_cond = new_cond, bracket = bracket, always = always, never = never, diff --git a/tests/test_scope.lua b/tests/test_scope.lua index c88369c..cca9511 100644 --- a/tests/test_scope.lua +++ b/tests/test_scope.lua @@ -8,6 +8,7 @@ local runtime = require "fibers.runtime" local scope = require "fibers.scope" local op = require "fibers.op" local performer = require "fibers.performer" +local cond_mod = require "fibers.cond" ------------------------------------------------------------------------------- -- 1. Structural tests @@ -81,7 +82,7 @@ local function test_inside_fibers() local grandchild_in_fiber -- Use a cond to wait for the spawned fibre to finish. - local done = op.new_cond() + local done = cond_mod.new() -- Spawn a fibre anchored to the root scope. root:spawn(function(s) @@ -115,11 +116,11 @@ local function test_inside_fibers() -- After inner run, current() should be back to root for this fibre assert(scope.current() == root, "after scope.run in fibre, current() should be root again") - done.signal() + done:signal() end) -- Drive until the child fibre finishes. - performer.perform(done.wait_op()) + performer.perform(done:wait_op()) -- After that, we are still inside the test fibre; current() should be root. assert(scope.current() == root, "after inner fibre completes, current() should be root in test fibre") @@ -383,17 +384,17 @@ local function test_fail_fast_from_child_fibre() test_scope = s -- Use a condition to ensure the child fibre runs before we exit the body. - local cond = op.new_cond() + local cond = cond_mod.new() -- Spawn a child fibre that signals, then fails. s:spawn(function(_) - cond.signal() + cond:signal() error("child fibre failure") end) -- Wait for the cond via performer, so we do not exit -- the body until after the child has signalled. - performer.perform(cond.wait_op()) + performer.perform(cond:wait_op()) end) assert(st == "failed", "scope.run should report failed when a child fibre fails") From 586483724f81c3d0452cd33374ed4858327ae949 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Fri, 21 Nov 2025 18:18:20 +0000 Subject: [PATCH 030/138] op.lua: corrects documentation --- src/fibers/op.lua | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/fibers/op.lua b/src/fibers/op.lua index f9c16d3..3879b15 100644 --- a/src/fibers/op.lua +++ b/src/fibers/op.lua @@ -465,15 +465,18 @@ end -- -- Semantics: -- * acquire is run once, at sync time (inside a guard). +-- * use(res) must return an Event; it is not performed here. -- * if the resulting event `ev` WINS: -- - its result is returned --- - release(res, false) is called (best-effort, pcall) +-- - release(res, false) is called exactly once -- * if the resulting event PARTICIPATES in a choice but LOSES: --- - release(res, true) is called (via on_abort / nack machinery) +-- - release(res, true) is called exactly once, via on_abort -- --- This combinator does not interpret Lua errors from acquire/use as --- normal control flow. Any uncaught error there fails the running --- fibre and is recorded at the scope level. +-- Error handling: +-- * Errors raised by acquire, use, or release propagate as normal +-- Lua errors from the performing fibre. +-- * This combinator does not interpret or catch such errors; they +-- are handled by the surrounding scope / supervision machinery. ---------------------------------------------------------------------- local function bracket(acquire, release, use) @@ -506,13 +509,16 @@ end -- cleanup(aborted:boolean) -- -- Semantics: --- * on normal post-sync completion: --- cleanup(false) is called (best-effort, protected). --- * if the event *loses* in a choice (via on_abort): --- cleanup(true) is called (best-effort, protected). +-- * On normal post-sync completion (this event wins): +-- cleanup(false) is called once. +-- * If the event participates in a choice but LOSES (via on_abort): +-- cleanup(true) is called once. -- --- Exceptions from ev's wrap or primitives are not intercepted here; --- they are handled by the surrounding scope as fibre failures. +-- This is expressed in terms of bracket, with no additional policy: +-- * cleanup is run in the performing fibre. +-- * Errors raised by cleanup propagate as normal Lua errors and +-- are not intercepted here; they are handled by the surrounding +-- scope / supervision machinery. ---------------------------------------------------------------------- function Event:finally(cleanup) From 899efc5f21abfd32285ed66817c23a97f8b0358e Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Fri, 21 Nov 2025 18:18:57 +0000 Subject: [PATCH 031/138] scope.lua: prevents failures from overwriting cancellation status --- src/fibers/scope.lua | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/fibers/scope.lua b/src/fibers/scope.lua index 5b6b133..025417e 100644 --- a/src/fibers/scope.lua +++ b/src/fibers/scope.lua @@ -146,14 +146,12 @@ end --- Internal: record a failure in this scope and cancel children. function Scope:_record_failure(err) - if self._status == "running" or self._status == "cancelled" then + if self._status == "running" then self._status = "failed" - if self._error == nil then - self._error = err - end - -- Fail-fast: cancel this scope and descendants. + self._error = self._error or err self:cancel(self._error) else + -- already "cancelled" or "failed": just record it local failures = self._failures failures[#failures + 1] = err end @@ -209,6 +207,16 @@ function Scope:spawn(fn, ...) local args = { ... } self._wg:add(1) + -- Note: + -- * On normal completion of fn, this wrapper calls _wg:done(). + -- * If fn raises and the fibre aborts, this wrapper is not run. + -- In that case the supervisor fibre installed in scope.root() + -- observes the failure via runtime.wait_fiber_error() and calls + -- _wg:done() on this scope on the failing fibre's behalf. + -- * Do not call _wg:done() from fn or from other error paths: + -- every child fibre must account for exactly one decrement, + -- either here or via the supervisor, but never both. + runtime.spawn(function() local fib = current_fiber() local prev = fib and fiber_scopes[fib] or nil From 384edb19fbe8c08c19bc532ee857a09a90ba9855 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Fri, 21 Nov 2025 18:20:07 +0000 Subject: [PATCH 032/138] sched.lua: corrects documentation --- src/fibers/sched.lua | 136 +++++++++++++++++++++++-------------------- 1 file changed, 74 insertions(+), 62 deletions(-) diff --git a/src/fibers/sched.lua b/src/fibers/sched.lua index c132076..e2b40ce 100644 --- a/src/fibers/sched.lua +++ b/src/fibers/sched.lua @@ -3,106 +3,101 @@ -- Use of this source code is governed by the XXXXXXXXX license; see COPYING. ---- Scheduler module. --- Implements the core scheduler for managing tasks. +--- Core scheduler for fibre tasks. -- @module fibers.sched --- Required modules -local sc = require 'fibers.utils.syscall' +local sc = require 'fibers.utils.syscall' local timer = require 'fibers.timer' --- Constants local MAX_SLEEP_TIME = 10 local Scheduler = {} Scheduler.__index = Scheduler ---- Creates a new Scheduler. --- @function new --- @return A new Scheduler. +--- Create a new scheduler. +-- @tparam[opt] function get_time monotonic time source (defaults to sc.monotime) local function new(get_time) - -- get_time is an optional monotonic time source (defaults to sc.monotime) local now_src = get_time or sc.monotime - local ret = setmetatable( - { - next = {}, -- runnable tasks (will run next turn) - cur = {}, -- tasks being run this turn - sources = {}, -- task sources (timer, poller, etc.) - wheel = timer.new(now_src()), -- timer wheel seeded from the same clock - maxsleep = MAX_SLEEP_TIME, -- upper bound on sleep between polls - get_time = now_src, -- single source of "current" monotonic time - event_waiter = nil -- (set by add_task_source when poller present) - }, - Scheduler - ) - - -- Timer task source: advances the wheel and schedules due tasks. + local now = now_src() + + local ret = setmetatable({ + next = {}, -- tasks runnable next turn + cur = {}, -- tasks being run this turn + sources = {}, -- timer, poller, etc. + wheel = timer.new(now), -- timer wheel on same clock + maxsleep = MAX_SLEEP_TIME, + get_time = now_src, + event_waiter = nil, -- optional poller + done = false, + }, Scheduler) + + -- Timer source: advances wheel and schedules due tasks. local timer_task_source = { wheel = ret.wheel } - function timer_task_source:schedule_tasks(sched, now) - self.wheel:advance(now, sched) + + function timer_task_source:schedule_tasks(sched, now_) + self.wheel:advance(now_, sched) end + + -- Timers are not cleared; future timers simply never run once + -- the scheduler stops. function timer_task_source:cancel_all_tasks() - -- No-op: advancing with 'now' drains due tasks; nothing to cancel explicitly. - -- (Keep for Scheduler:shutdown() symmetry.) end ret:add_task_source(timer_task_source) return ret end ---- Adds a task source to the scheduler. --- @param source The source to add. +--- Register a task source. +-- A source must support :schedule_tasks(sched, now). function Scheduler:add_task_source(source) table.insert(self.sources, source) - if source.wait_for_events then self.event_waiter = source end + if source.wait_for_events then + self.event_waiter = source + end end ---- Schedules a task. --- @param task Task to be scheduled. +--- Schedule a task object with a :run() method. function Scheduler:schedule(task) table.insert(self.next, task) end --- Helper for "current monotonic time" function Scheduler:monotime() return self.get_time() end ---- Gets the current time from the timer wheel. --- @return Current time. +--- Last time seen by the timer wheel. function Scheduler:now() return self.wheel.now end ---- Schedules a task to be run at a specific time. --- @tparam number t The time to run the task. --- @tparam function task The task to run. +--- Schedule at an absolute time. function Scheduler:schedule_at_time(t, task) self.wheel:add_absolute(t, task) end ---- Schedules a task to be run after a certain delay. --- @tparam number dt The delay after which to run the task. --- @tparam function task The task to run. +--- Schedule after a delay from the wheel's current time. function Scheduler:schedule_after_sleep(dt, task) self.wheel:add_delta(dt, task) end ---- Schedules tasks from all sources to the scheduler. --- @tparam number now The current time. +--- Ask all sources to queue ready tasks. function Scheduler:schedule_tasks_from_sources(now) for i = 1, #self.sources do self.sources[i]:schedule_tasks(self, now) end end ---- Runs all scheduled tasks in the scheduler. --- If a specific time is provided, tasks scheduled for that time are run. --- @tparam number now (optional) The time to run tasks for. +--- Run all tasks currently scheduled. +-- If now is nil, uses monotonic time. function Scheduler:run(now) - if now == nil then now = self:monotime() end + if now == nil then + now = self:monotime() + end + self:schedule_tasks_from_sources(now) + self.cur, self.next = self.next, self.cur + for i = 1, #self.cur do local task = self.cur[i] self.cur[i] = nil @@ -110,18 +105,28 @@ function Scheduler:run(now) end end ---- Returns the time of the next scheduled task. --- @treturn number The time of the next task. +--- Time of the next thing that may need attention. +-- If there are runnable tasks, returns now() (i.e. do not sleep). +-- Otherwise delegates to the timer wheel, which returns either a time +-- or math.huge when empty. function Scheduler:next_wake_time() - if #self.next > 0 then return self:now() end + if #self.next > 0 then + return self:now() + end return self.wheel:next_entry_time() end ---- Waits for the next scheduled event. +--- Block until the next event or timeout. +-- Uses an event_waiter (poller) if present, otherwise sleeps. function Scheduler:wait_for_events() - local now, next_time = self:monotime(), self:next_wake_time() + local now = self:monotime() + local next_time = self:next_wake_time() + local timeout = math.min(self.maxsleep, next_time - now) - timeout = math.max(timeout, 0) + if timeout < 0 then + timeout = 0 + end + if self.event_waiter then self.event_waiter:wait_for_events(self, now, timeout) else @@ -129,13 +134,12 @@ function Scheduler:wait_for_events() end end ---- Stops the main loop of the Scheduler. +--- Stop the main loop. function Scheduler:stop() self.done = true end ---- Runs the main event loop of the scheduler. --- The scheduler will continue to run tasks and wait for events until stopped. +--- Main event loop. function Scheduler:main() self.done = false repeat @@ -144,14 +148,22 @@ function Scheduler:main() until self.done end ---- Shuts down the scheduler. --- Cancels all tasks from all sources and runs remaining tasks. --- If there are still tasks after 100 attempts, returns false. --- @treturn boolean Whether the shutdown was successful. +--- Attempt to drain runnable work and ask sources to cancel. +-- Does not clear timers; future timers remain queued but will never fire +-- once the scheduler is no longer driven. function Scheduler:shutdown() for _ = 1, 100 do - for i = 1, #self.sources do self.sources[i]:cancel_all_tasks(self) end - if #self.next == 0 then return true end + for i = 1, #self.sources do + local src = self.sources[i] + if src.cancel_all_tasks then + src:cancel_all_tasks(self) + end + end + + if #self.next == 0 then + return true + end + self:run() end return false From 0080f27b2a62e0f82b30a2bb1e77005bb87643a5 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Fri, 21 Nov 2025 18:20:42 +0000 Subject: [PATCH 033/138] timer.lua: corrects documentation + small efficiencies --- src/fibers/timer.lua | 81 +++++++++++++++++--------------------------- 1 file changed, 32 insertions(+), 49 deletions(-) diff --git a/src/fibers/timer.lua b/src/fibers/timer.lua index 04d7a99..1645171 100644 --- a/src/fibers/timer.lua +++ b/src/fibers/timer.lua @@ -1,55 +1,47 @@ -- (c) Jangala - +-- -- Use of this source code is governed by the XXXXXXXXX license; see COPYING. ---- Binary Heap based timer. --- Implements a Binary Heap based timer. This is a time based event scheduler, --- used for efficiently scheduling and managing events. +--- Binary-heap timer. -- @module fibers.timer --- Required packages local sc = require 'fibers.utils.syscall' ---- BinaryHeap class. --- @type BinaryHeap +--- Simple min-heap keyed by node.time. local BinaryHeap = {} BinaryHeap.__index = BinaryHeap ---- BinaryHeap constructor. --- @treturn BinaryHeap BinaryHeap instance. function BinaryHeap:new() - return setmetatable({heap = {}, size = 0}, BinaryHeap) + return setmetatable({ heap = {}, size = 0 }, BinaryHeap) end ---- Pushes a node into the heap and heapify it. --- @tparam table node The node to be pushed into the heap. function BinaryHeap:push(node) self.size = self.size + 1 self.heap[self.size] = node self:heapify_up(self.size) end ---- Pops a node from the underlying heap and reheapifies. Does not advance the timer! --- @treturn table|nil The root node popped from the heap, nil if the heap is empty. function BinaryHeap:pop() if self.size == 0 then return nil end local root = self.heap[1] - self.heap[1] = self.heap[self.size] - self.size = self.size - 1 + if self.size == 1 then + self.heap[1] = nil + self.size = 0 + return root + end + + self.heap[1] = self.heap[self.size] + self.heap[self.size] = nil + self.size = self.size - 1 self:heapify_down(1) return root end ---- Maintains the heap property by moving a node up the heap. --- @tparam number idx The index of the node in the heap array. function BinaryHeap:heapify_up(idx) - if idx <= 1 then - return - end - + if idx <= 1 then return end local parent = math.floor(idx / 2) if self.heap[parent].time > self.heap[idx].time then self.heap[parent], self.heap[idx] = self.heap[idx], self.heap[parent] @@ -57,12 +49,10 @@ function BinaryHeap:heapify_up(idx) end end ---- Maintains the heap property by moving a node down the heap. --- @tparam number idx The index of the node in the heap array. function BinaryHeap:heapify_down(idx) local smallest = idx - local left = 2 * idx - local right = 2 * idx + 1 + local left = 2 * idx + local right = 2 * idx + 1 if left <= self.size and self.heap[left].time < self.heap[smallest].time then smallest = left @@ -76,55 +66,48 @@ function BinaryHeap:heapify_down(idx) end end ---- Timer class. --- @type Timer +--- Timer built on top of BinaryHeap. local Timer = {} Timer.__index = Timer ---- Timer constructor. --- @tparam[opt=now] number now The current time. --- @treturn Timer New Timer instance. +--- Create a new timer. +-- @tparam[opt] number now initial time (defaults to sc.monotime()). local function new(now) now = now or sc.monotime() - return setmetatable({now = now, heap = BinaryHeap:new()}, Timer) + return setmetatable({ now = now, heap = BinaryHeap:new() }, Timer) end ---- Adds an object to the timer with an absolute time. --- @tparam number t The absolute time. --- @tparam any obj The object to add to the timer. +--- Schedule at absolute time t. function Timer:add_absolute(t, obj) - self.heap:push({time = t, obj = obj}) + self.heap:push({ time = t, obj = obj }) end ---- Adds an object to the timer with a delta time. --- @tparam number dt The delta time. --- @tparam any obj The object to add to the timer. +--- Schedule after delay dt from current timer time. function Timer:add_delta(dt, obj) return self:add_absolute(self.now + dt, obj) end ---- Returns the time of the next entry in the timer. --- @treturn number The time of the next entry in the timer, or infinity if the heap is empty. +--- Time of next entry, or math.huge if none. +-- This is the only API relied on by the scheduler. function Timer:next_entry_time() if self.heap.size == 0 then - return 1/0 -- infinity + return math.huge end return self.heap.heap[1].time end ---- Returns the time of the next entry in the timer. --- @treturn number The time of the next entry in the timer, or infinity if the heap is empty. +--- Low-level pop of the next node. +-- Returns { time = ..., obj = ... } or nil. +-- Not used by the scheduler; kept for possible callers that want manual control. function Timer:pop() return self.heap:pop() end ---- Advances the timer, popping and scheduling objects from the heap as necessary. --- @tparam number t The time to advance the timer to. --- @tparam table sched The scheduler to use for scheduling objects. +--- Advance to time t, scheduling all entries <= t on sched. function Timer:advance(t, sched) while self.heap.size > 0 and t >= self.heap.heap[1].time do local node = self.heap:pop() - self.now = node.time + self.now = node.time sched:schedule(node.obj) end self.now = t @@ -132,4 +115,4 @@ end return { new = new -} \ No newline at end of file +} From abfaf84e7f6ba8ff24270572ce3c7b66dfb6ba04 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sat, 22 Nov 2025 01:10:58 +0000 Subject: [PATCH 034/138] extract oneshot --- src/fibers/cond.lua | 30 ++++++++++++++------ src/fibers/oneshot.lua | 48 ++++++++++++++++++++++++++++++++ src/fibers/op.lua | 59 ++++++++++++++-------------------------- src/fibers/signaller.lua | 48 -------------------------------- 4 files changed, 90 insertions(+), 95 deletions(-) create mode 100644 src/fibers/oneshot.lua delete mode 100644 src/fibers/signaller.lua diff --git a/src/fibers/cond.lua b/src/fibers/cond.lua index 38345c5..a92a4b7 100644 --- a/src/fibers/cond.lua +++ b/src/fibers/cond.lua @@ -1,23 +1,35 @@ --- fibers/cond.lua +--- fibers/cond.lua --- --- Generic condition events built on top of signaller and op. +-- Generic condition events built on top of oneshot and op. -- -- A cond has: --- cond.wait_op() -> Event --- cond.signal() -- idempotent +-- cond:wait_op() -> Event +-- cond:wait() -- blocking, via performer +-- cond:signal() -- idempotent local op = require 'fibers.op' -local signaller = require 'fibers.signaller' +local oneshot = require 'fibers.oneshot' local perform = require 'fibers.performer'.perform local Cond = {} Cond.__index = Cond function Cond:wait_op() + local os = self._os + return op.new_primitive( nil, - function() return self.sig.triggered end, - function(resumer, wrap_fn) self.sig:add_waiter(resumer, wrap_fn) end + function() + return os:is_triggered() + end, + function(resumer, wrap_fn) + -- Arrange to complete this suspension when the condition fires. + os:add_waiter(function() + if resumer:waiting() then + resumer:complete(wrap_fn) + end + end) + end ) end @@ -26,12 +38,12 @@ function Cond:wait() end function Cond:signal() - return self.sig:signal() + return self._os:signal() end local function new() return setmetatable({ - sig = signaller.new(), + _os = oneshot.new(), -- no extra callback }, Cond) end diff --git a/src/fibers/oneshot.lua b/src/fibers/oneshot.lua new file mode 100644 index 0000000..0f80d9f --- /dev/null +++ b/src/fibers/oneshot.lua @@ -0,0 +1,48 @@ +-- fibers/oneshot.lua + +local OneShot = {} +OneShot.__index = OneShot + +local function new(on_after_signal) + return setmetatable({ + triggered = false, + waiters = {}, -- array of functions() + on_after_signal = on_after_signal -- optional + }, OneShot) +end + +function OneShot:add_waiter(thunk) + if self.triggered then + -- Already triggered: run immediately. + thunk() + return + end + + local ws = self.waiters + ws[#ws + 1] = thunk +end + +function OneShot:signal() + if self.triggered then return end + self.triggered = true + + local ws = self.waiters + for i = 1, #ws do + local f = ws[i] + ws[i] = nil + if f then f() end + end + + local cb = self.on_after_signal + if cb then + cb() + end +end + +function OneShot:is_triggered() + return self.triggered +end + +return { + new = new, +} diff --git a/src/fibers/op.lua b/src/fibers/op.lua index 3879b15..6e85b97 100644 --- a/src/fibers/op.lua +++ b/src/fibers/op.lua @@ -20,6 +20,7 @@ local runtime = require 'fibers.runtime' local safe = require 'coxpcall' +local oneshot = require 'fibers.oneshot' local unpack = rawget(table, "unpack") or _G.unpack local pack = rawget(table, "pack") or function(...) @@ -186,63 +187,44 @@ function Event:on_abort(f) end ---------------------------------------------------------------------- --- Simple one-shot condition primitive (used for with_nack; also exported) +-- Simple one-shot condition primitive (used for with_nack) ---------------------------------------------------------------------- local function new_cond(opts) - local state = { - triggered = false, - waiters = {}, -- list of { suspension = ..., wrap = ... } - abort_fn = opts and opts.abort_fn or nil, - } + local abort_fn = opts and opts.abort_fn or nil + + -- Oneshot runs abort_fn (if any) after all waiters have been invoked. + local os = oneshot.new(function() + if abort_fn then + safe.pcall(abort_fn) + end + end) local function wait_op() - assert(not state.abort_fn, "abort-only cond has no wait_op") + assert(not abort_fn, "abort-only cond has no wait_op") local function try() - return state.triggered + return os:is_triggered() end local function block(suspension, wrap_fn) - if state.triggered then - -- Already triggered: complete immediately via the scheduler. - suspension:complete(wrap_fn) - else - -- Record this suspension + wrap for later signalling. - state.waiters[#state.waiters + 1] = { - suspension = suspension, - wrap = wrap_fn, - } - end + -- If already triggered, add_waiter will run the thunk immediately. + os:add_waiter(function() + if suspension:waiting() then + suspension:complete(wrap_fn) + end + end) end return new_primitive(nil, try, block) end local function signal() - if state.triggered then return end - state.triggered = true - - -- Complete all recorded waiters via the scheduler. - for i = 1, #state.waiters do - local remote = state.waiters[i] - state.waiters[i] = nil - - if remote - and remote.suspension - and remote.suspension:waiting() - then - remote.suspension:complete(remote.wrap) - end - end - - if state.abort_fn then - safe.pcall(state.abort_fn) - end + os:signal() end return { - wait_op = state.abort_fn and nil or wait_op, + wait_op = abort_fn and nil or wait_op, signal = signal, } end @@ -289,6 +271,7 @@ local function compile_event(ev, outer_wrap, out, nacks) compile_event(inner, outer_wrap, out, child_nacks) elseif kind == 'wrap' then + -- Wraps compose in declaration order: ev:wrap(f1):wrap(f2) → f2(f1(...)). local f = ev.wrap_fn local new_outer = function(...) return outer_wrap(f(...)) diff --git a/src/fibers/signaller.lua b/src/fibers/signaller.lua deleted file mode 100644 index 8706388..0000000 --- a/src/fibers/signaller.lua +++ /dev/null @@ -1,48 +0,0 @@ --- fibers/signaller.lua - -local Signaller = {} -Signaller.__index = Signaller - -local function new() - return setmetatable({ - triggered = false, - waiters = {}, -- array of { resumer = ..., wrap = ... } - }, Signaller) -end - -function Signaller:add_waiter(resumer, wrap_fn) - if self.triggered then - if resumer:waiting() then - resumer:complete(wrap_fn) - end - return - end - - local waiters = self.waiters - waiters[#waiters + 1] = { - resumer = resumer, - wrap = wrap_fn, - } -end - -function Signaller:signal() - if self.triggered then return end - self.triggered = true - - local waiters = self.waiters - for i = 1, #waiters do - local w = waiters[i] - waiters[i] = nil - if w - and w.resumer - and w.resumer:waiting() - then - w.resumer:complete(w.wrap) - end - end -end - -return { - new = new, - Signaller = Signaller, -} From cb042e06ed2eabc7675ffbd5e61c06bce434fa04 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sat, 22 Nov 2025 01:14:32 +0000 Subject: [PATCH 035/138] non-exit by default, neatened scope --- src/fibers.lua | 120 +++++++++++++++++++++++++++-------------- src/fibers/runtime.lua | 1 + src/fibers/scope.lua | 72 ++++++++++++++----------- 3 files changed, 121 insertions(+), 72 deletions(-) diff --git a/src/fibers.lua b/src/fibers.lua index 1ae6b09..bbe0fb0 100644 --- a/src/fibers.lua +++ b/src/fibers.lua @@ -1,71 +1,109 @@ --- fibers.lua --- -- Top-level facade for the fibers library. +-- -- Provides a small, convenient surface over the lower-level modules: --- - runtime (scheduler and fibers), --- - op (CML engine), --- - scope (structured concurrency), +-- - runtime (scheduler and fibers), +-- - op (CML engine), +-- - scope (structured concurrency), -- - primitives (sleep, channel, etc.). -- --- At this stage, scopes carry no policies; they simply form a tree --- and track the current scope per fiber. +-- Scopes currently carry no policies; they form a tree and track the +-- current scope per fiber. -- -- @module fibers -local runtime = require 'fibers.runtime' -local scope_mod = require 'fibers.scope' -local performer = require 'fibers.performer' - -local sleep_mod = require 'fibers.sleep' -local channel = require 'fibers.channel' -local op = require 'fibers.op' +local Runtime = require 'fibers.runtime' +local Scope = require 'fibers.scope' +local Performer = require 'fibers.performer' local unpack = rawget(table, "unpack") or _G.unpack +local pack = rawget(table, "pack") or function(...) + return { n = select("#", ...), ... } +end local fibers = {} --- Perform an event under the current scope. -fibers.perform = performer.perform +---------------------------------------------------------------------- +-- Core entry points +---------------------------------------------------------------------- -fibers.now = runtime.now +--- Perform an event under the current scope (if any). +-- Delegates to the scope-aware performer. +fibers.perform = Performer.perform + +--- Monotonic time source from the underlying scheduler. +fibers.now = Runtime.now --- Run a main function under the scheduler's root scope. --- main_fn :: function(Scope, ...): ... +-- +-- main_fn :: function(Scope, ...): ...results... +-- +-- Behaviour: +-- * A process-wide root scope is created on first use. +-- * A child scope of that root is created via scope.run. +-- * main_fn is run in its own fibre under that child scope. +-- * All fibres spawned under that child are tracked. +-- * When the child scope has closed (ok/failed/cancelled, defers run), +-- this function returns: +-- status, err, ...results_from_main_fn... +-- +-- status :: "ok" | "failed" | "cancelled" +-- err :: primary error / cancellation reason, or nil on "ok". +-- +-- This function does not exit the process. It stops the scheduler and +-- hands the status and error back to the caller. function fibers.run(main_fn, ...) - local root = scope_mod.root() + local root = Scope.root() local args = { ... } - -- Run main_fn inside a child scope of the root, in its own fibre. + local status, err + local results -- may be nil or a packed table + root:spawn(function() - local status, err = scope_mod.run(main_fn, unpack(args)) - -- In all cases, stop the scheduler so runtime.main() returns. - runtime.stop() - -- Treat non-ok main scope as fatal for the process. - if status ~= "ok" then - print(err) - os.exit(255) + -- scope.run creates a child scope of the current scope, + -- runs main_fn(body_scope, ...) in its own fibre, and returns + -- (status, err, ...results_from_body_fn...). + local packed = pack(Scope.run(main_fn, unpack(args))) + status, err = packed[1], packed[2] + + if packed.n > 2 then + -- Preserve any results from main_fn, including nils. + local out = { n = packed.n - 2 } + local j = 1 + for i = 3, packed.n do + out[j] = packed[i] + j = j + 1 + end + results = out + else + results = nil end + + -- In all cases, stop the scheduler so runtime.main() returns. + Runtime.stop() end) - runtime.main() + -- Drive the scheduler until stopped by the main scope. + Runtime.main() + + if results then + return status, err, unpack(results, 1, results.n or #results) + else + return status, err + end end ---- Spawn a fiber under the current scope. +---------------------------------------------------------------------- +-- Spawn +---------------------------------------------------------------------- + +--- Spawn a fibre under the current scope. +-- +-- fn :: function(Scope, ...): () +-- ... :: arguments passed to fn function fibers.spawn(fn, ...) - local s = scope_mod.current() + local s = Scope.current() return s:spawn(fn, ...) end -fibers.sleep = sleep_mod.sleep -fibers.channel = channel.new - -fibers.scope = scope_mod -fibers.op = op - -fibers.choice = op.choice -fibers.guard = op.guard -fibers.with_nack = op.with_nack -fibers.always = op.always -fibers.never = op.never - return fibers diff --git a/src/fibers/runtime.lua b/src/fibers/runtime.lua index 6c5774f..a154623 100644 --- a/src/fibers/runtime.lua +++ b/src/fibers/runtime.lua @@ -117,6 +117,7 @@ end -- The fiber will be resumed when the scheduler is ready to run it again. -- @function yield local function yield() + assert(current_fiber(), "can only yield from inside a fiber") return suspend(function(scheduler, fiber) scheduler:schedule(fiber) end) diff --git a/src/fibers/scope.lua b/src/fibers/scope.lua index 025417e..6c57e71 100644 --- a/src/fibers/scope.lua +++ b/src/fibers/scope.lua @@ -69,29 +69,28 @@ end local function new_scope(parent) local s = setmetatable({ _parent = parent, - _children = {}, + _children = setmetatable({}, { __mode = "k" }), -- weak keys -- Status and failure tracking - _status = "running", -- "running" | "ok" | "failed" | "cancelled" - _error = nil, -- primary error / cancellation cause - _failures = {}, -- additional failures - failure_mode = "fail_fast", -- placeholder for future policies + _status = "running", + _error = nil, + _failures = {}, + failure_mode = "fail_fast", -- Concurrency tracking - _wg = waitgroup.new(), -- waitgroup for child fibres - _defers = {}, -- LIFO list of deferred handlers + _wg = waitgroup.new(), + _defers = {}, - -- Cancellation and join conditions (generic conds) - _cancel_cond = cond.new(), -- signalled on cancel/failure - _join_cond = cond.new(), -- signalled when scope is closed + -- Cancellation and join conditions + _cancel_cond = cond.new(), + _join_cond = cond.new(), - -- Join worker flag _join_worker_started = false, }, Scope) if parent then - local children = parent._children - children[#children + 1] = s + -- store child as a weak key + parent._children[s] = true end return s @@ -108,10 +107,6 @@ local function root() runtime.spawn(function() while true do local fib, err = runtime.wait_fiber_error() - if not fib then - -- If runtime chooses to return nil, terminate. - break - end local s = fiber_scopes[fib] if s then -- mark failure + cancel children @@ -149,7 +144,8 @@ function Scope:_record_failure(err) if self._status == "running" then self._status = "failed" self._error = self._error or err - self:cancel(self._error) + -- Failure implies cancellation of children and done_ev. + self:_propagate_cancel(self._error) else -- already "cancelled" or "failed": just record it local failures = self._failures @@ -169,29 +165,40 @@ function Scope:defer(handler) defers[#defers + 1] = handler end +function Scope:_propagate_cancel(reason) + local r = reason or self._error or "scope cancelled" + + -- Wake done_ev waiters. + self._cancel_cond:signal() + + -- Propagate to children (weak-key set). + local children = self._children + for child in pairs(children) do + if child then + child:cancel(r) + end + end +end + --- Cancel this scope and its children with an optional reason. -- Idempotent: multiple calls are safe. function Scope:cancel(reason) + -- Once a scope is "ok", it is terminal: ignore or assert. + assert(self._status ~= "ok", "cannot cancel a scope that has already completed ok") + local r = reason or self._error or "scope cancelled" - if self._status == "running" or self._status == "ok" then + if self._status == "running" then self._status = "cancelled" if self._error == nil then self._error = r end + -- Only now do we notify others. + self:_propagate_cancel(r) end - -- Signal cancellation to any waiters. - self._cancel_cond:signal() - - -- Propagate to children. - local children = self._children - for i = 1, #children do - local c = children[i] - if c then - c:cancel(r) - end - end + -- For "failed" or already "cancelled" scopes we do nothing here. + -- Their propagation is handled by _record_failure or the first cancel. end --- Spawn a child fibre attached to this scope. @@ -247,8 +254,10 @@ end function Scope:children() local out = {} local ch = self._children or {} - for i, child in ipairs(ch) do + local i = 1 + for child in pairs(ch) do out[i] = child + i = i + 1 end return out end @@ -461,6 +470,7 @@ end -- On failure or cancellation, no Lua error is thrown here; callers -- should branch on status. local function run(body_fn, ...) + assert(runtime.current_fiber(), "scope.run must be called from inside a fiber") local parent = current() local child = new_scope(parent) local args = { ... } From f2df514ced65173285d599b000d385cf8a0532d7 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sat, 22 Nov 2025 01:17:21 +0000 Subject: [PATCH 036/138] adds filename comment --- src/fibers.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fibers.lua b/src/fibers.lua index bbe0fb0..9b9056b 100644 --- a/src/fibers.lua +++ b/src/fibers.lua @@ -1,4 +1,4 @@ ---- +-- fibers.lua -- Top-level facade for the fibers library. -- -- Provides a small, convenient surface over the lower-level modules: From 6df940bb1dad43dcd870cf2de5172bbad6714b15 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sat, 22 Nov 2025 11:48:05 +0000 Subject: [PATCH 037/138] rename runtime.spawn to runtime.spawn_raw --- src/fibers.lua | 6 + src/fibers/alarm.lua | 423 ++++++++++++++--------------------- src/fibers/runtime.lua | 2 +- src/fibers/sched.lua | 3 +- src/fibers/scope.lua | 10 +- tests/test.lua | 2 +- tests/test_alarm.lua | 310 ++++++------------------- tests/test_cond.lua | 8 +- tests/test_op.lua | 12 +- tests/test_pollio.lua | 4 +- tests/test_queue.lua | 2 +- tests/test_runtime.lua | 4 +- tests/test_scope.lua | 2 +- tests/test_sleep.lua | 2 +- tests/test_stream-compat.lua | 17 +- tests/test_stream-mem.lua | 45 ++-- 16 files changed, 305 insertions(+), 547 deletions(-) diff --git a/src/fibers.lua b/src/fibers.lua index 9b9056b..25763f4 100644 --- a/src/fibers.lua +++ b/src/fibers.lua @@ -12,6 +12,7 @@ -- -- @module fibers +local Op = require 'fibers.op' local Runtime = require 'fibers.runtime' local Scope = require 'fibers.scope' local Performer = require 'fibers.performer' @@ -34,6 +35,9 @@ fibers.perform = Performer.perform --- Monotonic time source from the underlying scheduler. fibers.now = Runtime.now +--- Monotonic time source from the underlying scheduler. +fibers.choice = Op.choice + --- Run a main function under the scheduler's root scope. -- -- main_fn :: function(Scope, ...): ...results... @@ -53,6 +57,8 @@ fibers.now = Runtime.now -- This function does not exit the process. It stops the scheduler and -- hands the status and error back to the caller. function fibers.run(main_fn, ...) + assert(not Runtime.current_fiber(), + "fibers.run must not be called from inside a fiber") local root = Scope.root() local args = { ... } diff --git a/src/fibers/alarm.lua b/src/fibers/alarm.lua index 2281720..f3f82aa 100644 --- a/src/fibers/alarm.lua +++ b/src/fibers/alarm.lua @@ -1,280 +1,185 @@ --- Use of this source code is governed by the Apache 2.0 license; see COPYING. - --- Alarms. - -local op = require 'fibers.op' -local runtime = require 'fibers.runtime' -local timer = require 'fibers.timer' -local sc = require 'fibers.utils.syscall' - -local perform = require 'fibers.performer'.perform - -local function days_in_year(y) - return y % 4 == 0 and (y % 100 ~= 0 or y % 400 == 0) and 366 or 365 -end - -local function to_time(t) - local new_t = {year = t.year, month=t.month, day=t.day, hour=t.hour, min=t.min, sec=t.sec} - local time = os.time(new_t) + t.msec/1e3 - local time_t = os.date("*t", time) - time_t.msec = t.msec - return time, time_t -end - --- let's define some constants -local periods = {"year", "month", "day", "hour", "min", "sec", "msec"} -local default = {month=1, day=1, hour=0, min=0, sec=0, msec=0} - --- This function validates a table t intended for scheduling an alarm, ensuring --- only appropriate fields are specified based on the scheduling type. -local function validate_next_table(t) - local inc_field - if t.year then - return nil, "year should not be specified for a relative alarm" - elseif t.yday then inc_field = "year" - if t.month or t.wday or t.day then - return nil, "neither month, weekday or day of month valid for day of year alarm" - end - elseif t.month then inc_field = "year" - if t.wday then - return nil, "day of week not valid for yearly alarm" - end - elseif t.day then inc_field = "month" - if t.wday then - return nil, "day of week not valid for monthly alarm" - end - elseif t.wday then inc_field = "day" - elseif t.hour then inc_field = "day" - elseif t.min then inc_field = "hour" - elseif t.sec then inc_field = "min" - elseif t.msec then inc_field = "sec" - else - return nil, "a next alarm must specify at least one of yday, month, day, wday, hour, minute, sec or msec" - end - - return inc_field, nil -end - --- calculates the absolute time until the next occurrence based on a given time --- structure t and the current epoch. -local function calculate_next(t, epoch) - - -- first let's make sure that the provided struct makes sense - local inc_field, _ = validate_next_table(t) -- the time table is pre-validated - - -- let's construct the new date table - local new_t = {} - - local now = os.date("*t", epoch) - now.msec = (epoch - math.floor(epoch)) * 1e3 - - local default_switch = false - for _, name in ipairs(periods) do - if not default_switch and t[name] then default_switch = true end - if (t.wday or t.yday) and name=="hour" then default_switch = true end - new_t[name] = (not default_switch and now[name]) or t[name] or default[name] - end - - -- now let's get the struct we need - local new_time, new_table = to_time(new_t) - - -- wday and yday are weird ones and we need to renormalise - if t.wday then - local increment = (t.wday - new_table.wday + 7) % 7 - new_table.day = new_table.day + increment - new_time, new_table = to_time(new_table) - elseif t.yday then - local no_days = days_in_year(new_table.year) - local increment = (t.yday - new_table.yday + no_days) % no_days - new_table.day = new_table.day + increment - new_time, new_table = to_time(new_table) +-- fibers/alarm.lua +-- +-- Wall-clock based alarms integrated with the fibres runtime. +-- +-- Each alarm is driven by a recurrence function: +-- next_time(last_fired :: epoch|nil, now :: epoch) -> next_epoch|nil +-- +-- Semantics: +-- * When next_time returns nil, the alarm becomes exhausted. +-- * When the alarm fires, callers receive: +-- true, alarm, last_fired_epoch, ... +-- * Once the alarm is exhausted, callers receive exactly once: +-- false, "no_more_recurrences", alarm, last_fired_epoch|nil +-- after which further wait_op() calls never fire. +-- * Multiple alarms scheduled for the same wall time will fire +-- in successive synchronisations (via CML choice and per-alarm state). +-- +-- Time initialisation: +-- * alarm.new() and alarm:wait_op() are safe before real time is known. +-- * No wait_op() will complete until set_time_source(...) has been called. +-- * A wait_op() started before set_time_source will first wait for time +-- to become ready, then for the next recurrence. + +local op = require 'fibers.op' +local sleep_mod = require 'fibers.sleep' +local perform = require 'fibers.performer'.perform +local cond_mod = require 'fibers.cond' + +local Alarm = {} +Alarm.__index = Alarm + +---------------------------------------------------------------------- +-- Wall-clock source and "time ready" condition +---------------------------------------------------------------------- + +-- Wall-clock "now" function (epoch seconds); replaced once real time is known. +local wall_now = os.time +local time_ready = false + +-- Generic one-shot condition for “time is ready”. +local time_ready_cond = cond_mod.new() + +--- Install the wall-clock time source. +-- May be called once, when real time is known (RTC, NTP, GNSS, etc.). +local function set_time_source(now_fn) + assert(type(now_fn) == "function", "set_time_source expects a function") + assert(not time_ready, "set_time_source may only be called once") + + wall_now = now_fn + time_ready = true + + -- Wake any fibres that were waiting for time to become ready. + time_ready_cond:signal() +end + +---------------------------------------------------------------------- +-- Alarm object API +-- +-- Internal state: +-- _state : "active" | "exhausted_pending" | "exhausted_done" +-- _last : last fired wall-clock epoch (or nil) +-- _next_wall : next scheduled wall-clock epoch (or nil) +---------------------------------------------------------------------- + +function Alarm:is_active() + return self._state == "active" +end + +--- Cancel the alarm permanently. +-- No further firings or exhaustion notification will be delivered. +function Alarm:cancel() + self._state = "exhausted_done" + self._next_wall = nil +end + +-- Internal: ensure _next_wall is populated or update state on exhaustion. +function Alarm:_ensure_next(now) + if self._next_wall or self._state ~= "active" then + return self._next_wall end - if new_time < epoch then - new_table[inc_field] = new_table[inc_field] + 1 - new_time, new_table = to_time(new_table) - end - - return new_time, new_table -end - - -local AlarmHandler = {} -AlarmHandler.__index = AlarmHandler - -local function new_alarm_handler() - local now = sc.realtime() - return setmetatable( - { - realtime = false, - abs_buffer = {}, - next_buffer = {}, - abs_timer = timer.new(now), -- Task list for absolute time scheduling - }, AlarmHandler) -end - -local installed_alarm_handler = nil - ---- Installs the Alarm Handler into the current scheduler. --- Must be called before any alarm operations are used. --- @return The installed AlarmHandler instance. -local function install_alarm_handler() - if not installed_alarm_handler then - installed_alarm_handler = new_alarm_handler() - runtime.current_scheduler:add_task_source(installed_alarm_handler) + local t = self._next_time(self._last, now) + if not t then + -- No further recurrences: schedule exhaustion notification. + self._state = "exhausted_pending" + return nil end - return installed_alarm_handler -end ---- Uninstalls the Alarm Handler from the current scheduler. --- This should be called to clean up when the Alarm Handler is no longer needed. -local function uninstall_alarm_handler() - if installed_alarm_handler then - for i, source in ipairs(runtime.current_scheduler.sources) do - if source == installed_alarm_handler then - table.remove(runtime.current_scheduler.sources, i) - break - end + self._next_wall = t + return t +end + +--- Main CML-style operation: wait for the alarm to fire once. +-- +-- Returns an Event which, when performed, yields either: +-- +-- * On successful firing: +-- true, alarm, last_fired_epoch, ... +-- +-- * Once, when the recurrence sequence is exhausted: +-- false, "no_more_recurrences", alarm, last_fired_epoch|nil +-- +-- After the exhaustion notification has been delivered, further +-- wait_op() calls return an Event that never fires. +-- +-- Before set_time_source is called, a wait_op() will first block +-- until time becomes ready, and then behave exactly as if wait_op() +-- had been called afterwards. +function Alarm:wait_op() + return op.guard(function() + -- Fully inert: no more results of any kind. + if self._state == "exhausted_done" then + return op.never() end - installed_alarm_handler = nil - end -end - -function AlarmHandler:schedule_tasks(sched) - local now = sc.realtime() - self.abs_timer:advance(now, sched) - - while true do - local next_time = self.abs_timer:next_entry_time() - now - if next_time > sched.maxsleep then break end -- an empty timer will return 'inf' here so nil check not needed - local task = self.abs_timer:pop() - sched:schedule_after_sleep(next_time, task.obj) - end -end - -function AlarmHandler:block(time_to_start, t, task) - if time_to_start < runtime.current_scheduler.maxsleep then - runtime.current_scheduler:schedule_after_sleep(time_to_start, task) - else - self.abs_timer:add_absolute(t, task) - end -end - -function AlarmHandler:clock_synced() - self.realtime = true - local now = sc.realtime() - -- Process buffered absolute tasks - for _, buffered in ipairs(self.abs_buffer) do - local time_to_start = buffered.t - now - self:block(time_to_start, buffered.t, buffered.task) - end - -- Process next tasks - for _, buffered in ipairs(self.next_buffer) do - local next_time = calculate_next(buffered.t, now) - local time_to_start = next_time - now - self:block(time_to_start, next_time, buffered.task) - end - self.abs_buffer, self.next_buffer = {}, {} -- Clear the buffer -end + -- Time not yet initialised: wait once for readiness, then recurse. + if not time_ready then + local ev = time_ready_cond:wait_op() + return ev:wrap(function() + -- At this point, time_ready is true; perform a fresh wait. + return perform(self:wait_op()) + end) + end -function AlarmHandler:clock_desynced() - self.realtime = false -end + -- Normal path: real time is available. + local now = wall_now() + self:_ensure_next(now) -function AlarmHandler:wait_absolute_op(t) - local time_to_start - local function try() - if not self.realtime then return false end - time_to_start = t - sc.realtime() - if time_to_start < 0 then return true end - end - local function block(suspension, wrap_fn) - local task = suspension:complete_task(wrap_fn) - if not self.realtime then table.insert(self.abs_buffer, {t=t, task=task}) - return + -- If the recurrence has just been exhausted, deliver the + -- one-off exhaustion notification and then become inert. + if self._state == "exhausted_pending" then + self._state = "exhausted_done" + return op.always(false, "no_more_recurrences", self, self._last) end - self:block(time_to_start, t, task) - end - return op.new_primitive(nil, try, block) -end -function AlarmHandler:wait_next_op(t) - local function try() - return false - end - local function block(suspension, wrap_fn) - local task = suspension:complete_task(wrap_fn) - if not self.realtime then table.insert(self.next_buffer, {t=t, task=task}) - return - end - local now = sc.realtime() - local target, _ = calculate_next(t, now) - self:block(target-now, target, task) - end - return op.new_primitive(nil, try, block) -end + -- We have a valid next_wall at this point. + local next_wall = assert(self._next_wall, "alarm internal error: missing next_wall") + local dt = next_wall - now + if dt < 0 then dt = 0 end ---- Indicates to the Alarm Handler that time synchronisation has been achieved (through NTP or other methods). --- Until the user calls clock_synced() all alarms will block. When called, --- `absolute` alarms will return immediately if their time has elapsed, whereas --- `next` alarms will be scheduled for their next instance -local function clock_synced() - return assert(installed_alarm_handler):clock_synced() + -- Relative sleep using monotonic time, followed by state update. + return sleep_mod.sleep_op(dt):wrap(function(...) + -- Only update state on successful firing (not on abort). + self._last = next_wall + self._next_wall = nil + return true, self, self._last, ... + end) + end) end ---- Indicates to the Alarm Handler that time synchronisation has been lost. --- All new alarms will be buffered until real-time is achieved. -local function clock_desynced() - return assert(installed_alarm_handler):clock_desynced() -end +-- Convenience alias: treat the alarm itself as an Event factory. +Alarm.event = Alarm.wait_op ---- Creates an operation for an absolute alarm. --- The operation can be performed immediately if in real-time mode, --- or buffered to be scheduled upon achieving real-time. --- @param t The absolute time (epoch) for the alarm. --- @return A BaseOp representing the absolute alarm operation. -local function wait_absolute_op(t) - return assert(installed_alarm_handler):wait_absolute_op(t) -end +---------------------------------------------------------------------- +-- Constructors +---------------------------------------------------------------------- ---- Schedules a task to run at an absolute time. --- Wrapper for `absolute_op` that immediately performs the operation. --- @param t The absolute time (epoch) for the alarm. -local function wait_absolute(t) - return perform(wait_absolute_op(t)) -end +--- Create a new alarm. +-- +-- params.next_time :: function(last_epoch|nil, now_epoch) -> next_epoch|nil +-- params.policy :: optional policy table (DST, gaps, overlaps, etc.) +-- params.label :: optional label for identification/logging +local function new(params) + assert(type(params) == "table", "alarm.new expects a parameter table") + local next_time = params.next_time + assert(type(next_time) == "function", "alarm.new: next_time function required") ---- Creates an operation for a next (relative) alarm. --- The operation is always buffered until real-time is achieved, --- then scheduled based on the calculated next time. --- @param t A table specifying the relative time for the alarm. --- @return A BaseOp representing the next alarm operation. --- @return An error if the time table is invalid. -local function wait_next_op(t) - local _, err = validate_next_table(t) - return err or assert(installed_alarm_handler):wait_next_op(t) -end + local self = setmetatable({ + _next_time = next_time, + _policy = params.policy, + _label = params.label or "", + + _last = nil, -- last fired wall-clock epoch + _next_wall = nil, -- next scheduled wall-clock epoch + _state = "active", -- lifecycle state + }, Alarm) ---- Schedules a task based on a relative next time. --- Wrapper for `next_op` that immediately performs the operation. --- @param t A table specifying the relative time for the alarm. --- @return An error if the time table is invalid. -local function wait_next(t) - local _, err = validate_next_table(t) - return err or perform(assert(installed_alarm_handler):wait_next_op(t)) + return self end --- Public API return { - install_alarm_handler = install_alarm_handler, - uninstall_alarm_handler = uninstall_alarm_handler, - clock_synced = clock_synced, - clock_desynced = clock_desynced, - wait_absolute_op = wait_absolute_op, - wait_absolute = wait_absolute, - wait_next_op = wait_next_op, - wait_next = wait_next, - validate_next_table = validate_next_table, - calculate_next = calculate_next + Alarm = Alarm, + new = new, + set_time_source = set_time_source, } diff --git a/src/fibers/runtime.lua b/src/fibers/runtime.lua index a154623..5308066 100644 --- a/src/fibers/runtime.lua +++ b/src/fibers/runtime.lua @@ -171,7 +171,7 @@ return { wait_fiber_error = wait_fiber_error, -- fiber management - spawn = spawn, + spawn_raw = spawn, stop = stop, main = main, } diff --git a/src/fibers/sched.lua b/src/fibers/sched.lua index e2b40ce..5887fcb 100644 --- a/src/fibers/sched.lua +++ b/src/fibers/sched.lua @@ -1,5 +1,4 @@ --- (c) Snabb project --- (c) Jangala +-- fibers/sched.lua -- Use of this source code is governed by the XXXXXXXXX license; see COPYING. diff --git a/src/fibers/scope.lua b/src/fibers/scope.lua index 6c57e71..6120fc3 100644 --- a/src/fibers/scope.lua +++ b/src/fibers/scope.lua @@ -104,7 +104,7 @@ local function root() -- Supervisor fibre: translate uncaught fibre errors into -- scope failures and cancellations. - runtime.spawn(function() + runtime.spawn_raw(function() while true do local fib, err = runtime.wait_fiber_error() local s = fiber_scopes[fib] @@ -183,8 +183,8 @@ end --- Cancel this scope and its children with an optional reason. -- Idempotent: multiple calls are safe. function Scope:cancel(reason) - -- Once a scope is "ok", it is terminal: ignore or assert. - assert(self._status ~= "ok", "cannot cancel a scope that has already completed ok") + -- Once a scope is "ok", it is terminal: ignore. + if self._status == "ok" then return end local r = reason or self._error or "scope cancelled" @@ -224,7 +224,7 @@ function Scope:spawn(fn, ...) -- every child fibre must account for exactly one decrement, -- either here or via the supervisor, but never both. - runtime.spawn(function() + runtime.spawn_raw(function() local fib = current_fiber() local prev = fib and fiber_scopes[fib] or nil if fib then @@ -292,7 +292,7 @@ function Scope:_start_join_worker() if self._join_worker_started then return end self._join_worker_started = true - runtime.spawn(function() + runtime.spawn_raw(function() -- Attach this worker to the scope local fib = current_fiber() local prev = fib and fiber_scopes[fib] diff --git a/tests/test.lua b/tests/test.lua index a7f87ae..2004f61 100644 --- a/tests/test.lua +++ b/tests/test.lua @@ -21,7 +21,7 @@ local modules = { { 'pollio' }, { 'exec' }, { 'waitgroup' }, - { 'alarm' }, + -- { 'alarm' }, { 'context' }, { 'scope' }, } diff --git a/tests/test_alarm.lua b/tests/test_alarm.lua index 1a930ef..f9071ab 100644 --- a/tests/test_alarm.lua +++ b/tests/test_alarm.lua @@ -1,247 +1,85 @@ ---- Tests the Alarms implementation. +-- test_alarm.lua +-- +-- Simple demonstration of fibers.alarm with guard + named_choice. + print('testing: fibers.alarm') --- look one level up package.path = "../src/?.lua;" .. package.path -local fibers = require 'fibers' -local alarm = require 'fibers.alarm' -local sleep = require 'fibers.sleep' -local sc = require 'fibers.utils.syscall' - -alarm.install_alarm_handler() -alarm.clock_synced() - -local function abs_test(secs) - io.write("Starting Absolute test ... ") - io.flush() - local starttime = sc.realtime() - alarm.wait_absolute(starttime + secs) - assert(sc.realtime() - starttime < secs + 0.1) - print("complete!") -end - -local function next_error() - io.write("Starting Next Error test ... ") - io.flush() - local err = alarm.wait_next({year=2000}) - assert(err) - print("complete!") -end - -local function next_msec_test(t) - io.write("Starting Millisecond test ... ") - io.flush() - local start = sc.realtime() - alarm.wait_next({msec=t}) - local epoch = sc.realtime() - assert(epoch%1 * 1e3 - t < 50, "alarm didn't fire within 0.05 seconds of due time") - assert(epoch - start < 1, "next Millisecond should fire within 1 second") - print("complete!") -end - -local function next_sec_test(secs) - io.write("Starting Second test ... ") - io.flush() - local starttime = sc.realtime() - local sleep_until = starttime + secs - local t_table = os.date("*t", sleep_until) - local _, err = alarm.wait_next({sec=t_table.sec}) - assert(not err) - assert(sc.realtime() - starttime < secs + 0.1) - print("complete!") -end - -local function next_min_test(secs) - io.write("Starting Minute test ... ") - io.flush() - local starttime = sc.realtime() - local sleep_until = starttime + secs - local t_table = os.date("*t", sleep_until) - alarm.wait_next({min=t_table.min, sec=t_table.sec}) - assert(sc.realtime() - starttime < secs + 0.1) - print("complete!") -end - -local function next_hour_test(secs) - io.write("Starting Hour test ... ") - io.flush() - local starttime = sc.realtime() - local sleep_until = starttime + secs - local t_table = os.date("*t", sleep_until) - alarm.wait_next({hour=t_table.hour, min=t_table.min, sec=t_table.sec}) - assert(sc.realtime() - starttime < secs + 0.1) - print("complete!") -end - -local function next_day_test(secs) - io.write("Starting Day test ... ") - io.flush() - local starttime = sc.realtime() - local sleep_until = starttime + secs - local t_table = os.date("*t", sleep_until) - alarm.wait_next({day=t_table.day,hour=t_table.hour, min=t_table.min, sec=t_table.sec}) - assert(sc.realtime() - starttime < secs + 0.1) - print("complete!") -end - -local function next_wday_test(secs) - io.write("Starting Weekday test ... ") - io.flush() - local starttime = sc.realtime() - local sleep_until = starttime + secs - local t_table = os.date("*t", sleep_until) - alarm.wait_next({wday=t_table.wday,hour=t_table.hour, min=t_table.min, sec=t_table.sec}) - assert(sc.realtime() - starttime < secs + 0.1) - print("complete!") -end +local runtime = require 'fibers.runtime' +local perform = require 'fibers.performer'.perform +local op = require 'fibers.op' +local alarmmod = require 'fibers.alarm' -local function next_month_test(secs) - io.write("Starting Month test ... ") - io.flush() - local starttime = sc.realtime() - local sleep_until = starttime + secs - local t_table = os.date("*t", sleep_until) - alarm.wait_next({month=t_table.month, day=t_table.day,hour=t_table.hour, min=t_table.min, sec=t_table.sec}) - assert(sc.realtime() - starttime < secs + 0.1) - print("complete!") -end - -local function next_yday_test(secs) - io.write("Starting Yearday test ... ") - io.flush() - local starttime = sc.realtime() - local sleep_until = starttime + secs - local t_table = os.date("*t", sleep_until) - alarm.wait_next({yday=t_table.yday,hour=t_table.hour, min=t_table.min, sec=t_table.sec}) - assert(sc.realtime() - starttime < secs + 0.1) - print("complete!") -end - -local function buffer_test() - io.write("Starting Buffer test ... ") - io.flush() - -- first absolutes - easy to test - local t_sleep, t_sleep_before_realtime = 1, 2 - alarm.clock_desynced() - fibers.spawn(function () - sleep.sleep(t_sleep_before_realtime) - alarm.clock_synced() - end) - local start = sc.realtime() - alarm.wait_absolute(t_sleep) - local finish = sc.realtime() - assert(finish - start > 1.9 or finish - start < 2.1) - -- next let's do nexts - local msec_target = 333 - alarm.clock_desynced() - fibers.spawn(function () - sleep.sleep(t_sleep_before_realtime) - alarm.clock_synced() +local function main() + -- Two one-shot alarms at the same time. + local a1 = alarmmod.after(1.0) + local a2 = alarmmod.after(1.0) + + -- One repeating alarm: first fire after 0.5s, then every 0.5s. + local a3 = alarmmod.every(0.5) + + runtime.spawn_raw(function() + local function build_arms() + local arms = {} + + if a1:is_active() then + arms.a1 = a1:event():wrap(function(al, ...) + return "a1", al, ... + end) + end + + if a2:is_active() then + arms.a2 = a2:event():wrap(function(al, ...) + return "a2", al, ... + end) + end + + if a3:is_active() then + arms.a3 = a3:event():wrap(function(al, ...) + return "a3", al, ... + end) + end + + return arms + end + + -- A single dynamic event that, on each synchronisation, + -- chooses one of the currently-active alarms. + local ev = op.guard(function() + local arms = build_arms() + if next(arms) == nil then + return op.never() + else + return op.named_choice(arms) + end + end) + + -- Run until both one-shot alarms have fired; then stop + -- the scheduler. The repeating alarm will still be active + -- but we ignore it after that. + while true do + local name, al = perform(ev) + local t = runtime.now() + print(("[%.3f] alarm %s fired"):format(t, name)) + + if not a1:is_active() and not a2:is_active() then + print("Both one-shot alarms have fired; stopping runtime.") + runtime.stop() + break + end + end end) - start = sc.realtime() - alarm.wait_next({msec=msec_target}) - finish = sc.realtime() - assert(finish - start > 2) -- the event shouldn't fire until clock_synced is called - assert(finish%1 - math.floor(finish) - msec_target < 50) - print("complete!") -end -local function validate_next_table_test() - io.write("Starting validate_next_table test ... ") - io.flush() - - local tests = { - { - input = {year=2027}, - expctd_err = "year should not be specified for a relative alarm"}, - { - input = {yday=200}, - expctd_err = nil}, - { - input = {yday=200, month=7}, - expctd_err = "neither month, weekday or day of month valid for day of year alarm"}, - { - input = {month=12}, - expctd_err = nil}, - { - input = {month=6, wday=3}, - expctd_err = "day of week not valid for yearly alarm"}, - { - input = {day=15}, - expctd_err = nil}, - { - input = {min=30, sec=45}, - expctd_err = nil}, - { - input = {}, - expctd_err = "a next alarm must specify at least one of yday, month, day, wday, hour, minute, sec or msec" - } - } - - for _, test in ipairs(tests) do - local _, result_error = alarm.validate_next_table(test.input) - assert( - result_error == test.expctd_err, - string.format("expected %s, got %s", tostring(test.expctd_err), tostring(result_error)) - ) - end - print("complete!") + runtime.main() end -local function test_next_calc() - io.write("Starting Next Calculation test ... ") - io.flush() - - local tests = { - { - description = "Testing Day Increment", - epoch = os.time{year=2027, month=5, day=24, hour=0, min=0, sec=0}, - test_table = {day=25, hour=0, min=0, sec=0}, - expected_time = os.time{year=2027, month=5, day=25, hour=0, min=0, sec=0} - }, - { - description = "Testing Month Wraparound", - epoch = os.time{year=3023, month=12, day=31, hour=23, min=59, sec=59}, - test_table = {month=1, day=1, hour=0, min=0, sec=0}, - expected_time = os.time{year=3024, month=1, day=1, hour=0, min=0, sec=0} - }, - { - description = "Testing Leap Year Day", - epoch = os.time{year=2024, month=1, day=1, hour=0, min=0, sec=0}, - test_table = {month=2, day=29, hour=0, min=0, sec=0}, - expected_time = os.time{year=2024, month=2, day=29, hour=0, min=0, sec=0} - }, - { - description = "Testing Weekday Adjustment", - epoch = os.time{year=2024, month=5, day=22, hour=12, min=43}, -- Wednesday - test_table = {wday=6, min=12}, -- Targeting Friday - expected_time = os.time{year=2024, month=5, day=24, hour=0, min=12, sec=0} -- The next Friday - } +-- Allow this file to be run directly (e.g. `lua test_alarm.lua`) +-- or required from elsewhere. +if ... == nil then + main() +else + return { + main = main, } - - for _, test in ipairs(tests) do - local calculated_time, _ = alarm.calculate_next(test.test_table, test.epoch) - assert(calculated_time == test.expected_time, test.description .. ": Failed!") - end - - print("complete!") end - -local function main() - abs_test(2) - next_error() - next_msec_test(666) - next_sec_test(2) - next_min_test(2) - next_hour_test(2) - next_day_test(2) - next_wday_test(2) - next_month_test(2) - next_yday_test(2) - buffer_test() - validate_next_table_test() - test_next_calc() -end - -fibers.run(main) diff --git a/tests/test_cond.lua b/tests/test_cond.lua index 888a629..0ff88fa 100644 --- a/tests/test_cond.lua +++ b/tests/test_cond.lua @@ -14,10 +14,10 @@ local equal = require 'fibers.utils.helper'.equal local c, log = cond.new(), {} local function record(x) table.insert(log, x) end -runtime.spawn(function() +runtime.spawn_raw(function() record('a'); c:wait(); record('b') end) -runtime.spawn(function() +runtime.spawn_raw(function() record('c'); c:signal(); record('d') end) assert(equal(log, {})) @@ -26,10 +26,10 @@ assert(equal(log, { 'a', 'c', 'd' })) runtime.current_scheduler:run() assert(equal(log, { 'a', 'c', 'd', 'b' })) -runtime.spawn(function() +runtime.spawn_raw(function() local fiber_count = 1e3 for _ = 1, fiber_count do - runtime.spawn(function() c:wait(); end) + runtime.spawn_raw(function() c:wait(); end) end sleep.sleep(1) diff --git a/tests/test_op.lua b/tests/test_op.lua index befd4ba..75b49aa 100644 --- a/tests/test_op.lua +++ b/tests/test_op.lua @@ -34,7 +34,7 @@ end -- Run all tests inside a single top-level fiber ------------------------------------------------------------ -runtime.spawn(function() +runtime.spawn_raw(function() -------------------------------------------------------- -- 1) Base event: perform, or_else, wrap @@ -143,7 +143,7 @@ runtime.spawn(function() local guarded_nack = op.guard(function() guard_calls = guard_calls + 1 return op.with_nack(function(nack_ev) - runtime.spawn(function() + runtime.spawn_raw(function() perform(nack_ev) cancelled = true end) @@ -186,7 +186,7 @@ runtime.spawn(function() do local cancelled = false local with_nack_ev = op.with_nack(function(nack_ev) - runtime.spawn(function() + runtime.spawn_raw(function() perform(nack_ev) cancelled = true end) @@ -205,7 +205,7 @@ runtime.spawn(function() do local cancelled = false local with_nack_ev = op.with_nack(function(nack_ev) - runtime.spawn(function() + runtime.spawn_raw(function() perform(nack_ev) cancelled = true end) @@ -226,13 +226,13 @@ runtime.spawn(function() local outer_cancelled, inner_cancelled = false, false local outer = op.with_nack(function(outer_nack_ev) - runtime.spawn(function() + runtime.spawn_raw(function() perform(outer_nack_ev) outer_cancelled = true end) return op.with_nack(function(inner_nack_ev) - runtime.spawn(function() + runtime.spawn_raw(function() perform(inner_nack_ev) inner_cancelled = true end) diff --git a/tests/test_pollio.lua b/tests/test_pollio.lua index 5ff04b1..abba6db 100644 --- a/tests/test_pollio.lua +++ b/tests/test_pollio.lua @@ -22,13 +22,13 @@ assert(equal(log, {})) local rd, wr = file_stream.pipe() local message = "hello, world\n" -runtime.spawn(function() +runtime.spawn_raw(function() record('rd-a') local str = rd:read_some_chars() record('rd-b') record(str) end) -runtime.spawn(function() +runtime.spawn_raw(function() record('wr-a') wr:write(message) record('wr-b') diff --git a/tests/test_queue.lua b/tests/test_queue.lua index 6d432d0..69158ae 100644 --- a/tests/test_queue.lua +++ b/tests/test_queue.lua @@ -12,7 +12,7 @@ local equal = helper.equal local log = {} local function record(x) table.insert(log, x) end -runtime.spawn(function() +runtime.spawn_raw(function() local q = queue.new() record('a') q:put('b') diff --git a/tests/test_runtime.lua b/tests/test_runtime.lua index dae321a..37504f0 100644 --- a/tests/test_runtime.lua +++ b/tests/test_runtime.lua @@ -11,7 +11,7 @@ local equal = require 'fibers.utils.helper'.equal local log = {} local function record(x) table.insert(log, x) end -runtime.spawn(function() +runtime.spawn_raw(function() record('a'); runtime.yield(); record('b'); runtime.yield(); record('c') end) @@ -33,7 +33,7 @@ local function inc() count = count + 1 end for _=1, fiber_count do - runtime.spawn(function() + runtime.spawn_raw(function() inc(); runtime.yield(); inc(); runtime.yield(); inc() end) end diff --git a/tests/test_scope.lua b/tests/test_scope.lua index cca9511..85aafee 100644 --- a/tests/test_scope.lua +++ b/tests/test_scope.lua @@ -416,7 +416,7 @@ local function main() -- Run all tests inside a single top-level fibre so that scope.run -- and performer.perform are always called from within the scheduler. - runtime.spawn(function() + runtime.spawn_raw(function() test_outside_fibers() test_inside_fibers() test_with_ev_basic() diff --git a/tests/test_sleep.lua b/tests/test_sleep.lua index bcdf4c6..01969cd 100644 --- a/tests/test_sleep.lua +++ b/tests/test_sleep.lua @@ -19,7 +19,7 @@ for _ = 1, count do done = done + 1 -- table.insert(wakeup_times, wakeup_time - (start + dt)) end - runtime.spawn(fn) + runtime.spawn_raw(fn) end for t = runtime.now(), runtime.now() + 1.5, 0.01 do runtime.current_scheduler:run(t) diff --git a/tests/test_stream-compat.lua b/tests/test_stream-compat.lua index 26192e7..46ee52c 100644 --- a/tests/test_stream-compat.lua +++ b/tests/test_stream-compat.lua @@ -4,14 +4,19 @@ print('testing: fibers.stream.compat') -- look one level up package.path = "../src/?.lua;" .. package.path +local fibers = require 'fibers' local compat = require 'fibers.stream.compat' print('selftest: lib.stream.compat') -_G.io.write('before\n') -compat.install() -_G.io.write('after\n') -assert(_G.io == io) -compat.uninstall() +local function main() + _G.io.write('before\n') + compat.install() + _G.io.write('after\n') + assert(_G.io == io) + compat.uninstall() -print('test: ok') + print('test: ok') +end + +fibers.run(main) diff --git a/tests/test_stream-mem.lua b/tests/test_stream-mem.lua index 6ce42a8..fb62c73 100644 --- a/tests/test_stream-mem.lua +++ b/tests/test_stream-mem.lua @@ -4,27 +4,32 @@ print('testing: fibers.stream.mem') -- look one level up package.path = "../src/?.lua;" .. package.path +local fibers = require 'fibers' local mem = require 'fibers.stream.mem' local sc = require 'fibers.utils.syscall' -local str = "hello, world!" -local stream = mem.open_input_string(str) -assert(stream:seek() == 0) -assert(stream:seek(sc.SEEK_END) == #str) -assert(stream:seek() == #str) -assert(stream:seek(sc.SEEK_SET) == 0) -assert(stream:read_all_chars() == str) -assert(not pcall(stream.write_chars, stream, "more chars")) -assert(stream:seek() == #str) -stream:close() +local function main() + local str = "hello, world!" + local stream = mem.open_input_string(str) + assert(stream:seek() == 0) + assert(stream:seek(sc.SEEK_END) == #str) + assert(stream:seek() == #str) + assert(stream:seek(sc.SEEK_SET) == 0) + assert(stream:read_all_chars() == str) + assert(not pcall(stream.write_chars, stream, "more chars")) + assert(stream:seek() == #str) + stream:close() -stream = mem.tmpfile() -assert(stream:seek() == 0) -assert(stream:seek(sc.SEEK_END) == 0) -stream:write_chars(str) -stream:flush() -assert(stream:seek() == #str) -assert(stream:seek(sc.SEEK_SET) == 0) -assert(stream:read_all_chars() == str) -stream:close() -print('selftest: ok') + stream = mem.tmpfile() + assert(stream:seek() == 0) + assert(stream:seek(sc.SEEK_END) == 0) + stream:write_chars(str) + stream:flush() + assert(stream:seek() == #str) + assert(stream:seek(sc.SEEK_SET) == 0) + assert(stream:read_all_chars() == str) + stream:close() + print('selftest: ok') +end + +fibers.run(main) From 062c093cc3b9f1c10f4f751ee6f982b0e4adee03 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sat, 22 Nov 2025 11:48:56 +0000 Subject: [PATCH 038/138] add guards to prioritise cancellation --- src/fibers/scope.lua | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/src/fibers/scope.lua b/src/fibers/scope.lua index 6120fc3..e01f18e 100644 --- a/src/fibers/scope.lua +++ b/src/fibers/scope.lua @@ -45,6 +45,9 @@ local cond = require 'fibers.cond' local safe = require 'coxpcall' local unpack = rawget(table, "unpack") or _G.unpack +local pack = rawget(table, "pack") or function(...) + return { n = select("#", ...), ... } +end local Scope = {} Scope.__index = Scope @@ -374,14 +377,44 @@ end --- Transform an event to obey this scope's failure and cancellation policy. -- Returns a new Event; does not perform it. function Scope:run_ev(ev) - local cancel_ev = cancel_event(self) - return op.choice(ev, cancel_ev) + -- Build a fresh event per synchronisation, so we can look at the + -- scope status at sync time rather than at definition time. + return op.guard(function() + local status, err = self:status() + + -- If not running, short-circuit to cancellation result + if status ~= "running" then + return op.always(false, err or "scope cancelled", nil) + end + + -- Normal path + local cancel_ev = cancel_event(self) + return op.choice(ev, cancel_ev) + end) end --- Synchronise on an event under this scope. -- Equivalent to op.perform_raw(self:run_ev(ev)). function Scope:sync(ev) - return op.perform_raw(self:run_ev(ev)) + -- Fast pre-check: do not start new work on a non-running scope. + local status, err = self:status() + if status ~= "running" then + return false, err or "scope cancelled", nil + end + + -- Perform the cancellable event + local results = pack(op.perform_raw(self:run_ev(ev))) + + -- Re-check the scope status once the event has completed. + status, err = self:status() + + -- If scope has failed/cancelled )treat as authoritative + if status ~= "running" and status ~= "ok" then + return false, err or "scope cancelled", nil + end + + -- Scope still "running" or "ok" + return unpack(results, 1, results.n) end ---------------------------------------------------------------------- From 2a3179797384bcdf0f4c2df44c47c6504aa1a153 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sat, 22 Nov 2025 13:39:23 +0000 Subject: [PATCH 039/138] preserve trailing nil responses --- src/fibers.lua | 5 ++--- src/fibers/op.lua | 20 ++++++++++---------- src/fibers/scope.lua | 15 ++++++--------- 3 files changed, 18 insertions(+), 22 deletions(-) diff --git a/src/fibers.lua b/src/fibers.lua index 25763f4..b9dd345 100644 --- a/src/fibers.lua +++ b/src/fibers.lua @@ -35,7 +35,6 @@ fibers.perform = Performer.perform --- Monotonic time source from the underlying scheduler. fibers.now = Runtime.now ---- Monotonic time source from the underlying scheduler. fibers.choice = Op.choice --- Run a main function under the scheduler's root scope. @@ -60,7 +59,7 @@ function fibers.run(main_fn, ...) assert(not Runtime.current_fiber(), "fibers.run must not be called from inside a fiber") local root = Scope.root() - local args = { ... } + local args = pack(...) local status, err local results -- may be nil or a packed table @@ -69,7 +68,7 @@ function fibers.run(main_fn, ...) -- scope.run creates a child scope of the current scope, -- runs main_fn(body_scope, ...) in its own fibre, and returns -- (status, err, ...results_from_body_fn...). - local packed = pack(Scope.run(main_fn, unpack(args))) + local packed = pack(Scope.run(main_fn, unpack(args, 1, args.n or #args))) status, err = packed[1], packed[2] if packed.n > 2 then diff --git a/src/fibers/op.lua b/src/fibers/op.lua index 6e85b97..827232d 100644 --- a/src/fibers/op.lua +++ b/src/fibers/op.lua @@ -47,7 +47,7 @@ function Suspension:complete(wrap, ...) assert(self:waiting()) self.state = 'synchronized' self.wrap = wrap - self.val = { ... } + self.val = pack(...) self.sched:schedule(self) end @@ -58,12 +58,12 @@ 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 = pack(...) }, CompleteTask) end function Suspension:run() assert(not self:waiting()) - return self.fiber:resume(self.wrap, unpack(self.val)) + return self.fiber:resume(self.wrap, unpack(self.val, 1, self.val.n)) end local function new_suspension(sched, fib) @@ -73,7 +73,7 @@ end -- A CompleteTask completes a suspension (if still waiting) when run. function CompleteTask:run() if self.suspension:waiting() then - self.suspension:complete_and_run(self.wrap, unpack(self.val)) + self.suspension:complete_and_run(self.wrap, unpack(self.val, 1, self.val.n)) end end @@ -152,9 +152,9 @@ local function with_nack(g) end local function always(...) - local results = { ... } + local results = pack(...) local function try() - return true, unpack(results) + return true, unpack(results, 1, results.n) end local function block() error("always: block_fn should never run") end -- never reached return new_primitive(nil, try, block) @@ -381,16 +381,16 @@ function Event:or_else(fallback_thunk) if idx then -- Normal CML semantics: `self` wins, fire nacks for losers. trigger_nacks(leaves, idx) - local results = { apply_wrap(leaves[idx].wrap, retval) } - return always(unpack(results)) + local results = pack(apply_wrap(leaves[idx].wrap, retval)) + return always(unpack(results, 1, results.n)) end -- No leaf of `self` is ready now → `self` loses as a whole. -- Fire all nacks/abort handlers hanging off `self`. trigger_nacks(leaves, nil) - local results = { fallback_thunk() } - return always(unpack(results)) + local results = pack(fallback_thunk()) + return always(unpack(results, 1, results.n)) end) end diff --git a/src/fibers/scope.lua b/src/fibers/scope.lua index e01f18e..6e4d80c 100644 --- a/src/fibers/scope.lua +++ b/src/fibers/scope.lua @@ -214,7 +214,7 @@ end -- propagated to children. function Scope:spawn(fn, ...) assert(self._status == "running", "cannot spawn on a non-running scope") - local args = { ... } + local args = pack(...) self._wg:add(1) -- Note: @@ -234,11 +234,7 @@ function Scope:spawn(fn, ...) fiber_scopes[fib] = self end - if #args > 0 then - fn(self, unpack(args)) - else - fn(self) - end + fn(self, unpack(args, 1, args.n)) if fib then fiber_scopes[fib] = prev @@ -396,6 +392,7 @@ end --- Synchronise on an event under this scope. -- Equivalent to op.perform_raw(self:run_ev(ev)). function Scope:sync(ev) + assert(runtime.current_fiber(), "scope:sync must be called from inside a fiber (use fibers.run as an entry point)") -- Fast pre-check: do not start new work on a non-running scope. local status, err = self:status() if status ~= "running" then @@ -506,14 +503,14 @@ local function run(body_fn, ...) assert(runtime.current_fiber(), "scope.run must be called from inside a fiber") local parent = current() local child = new_scope(parent) - local args = { ... } + local args = pack(...) -- store body results on the child scope child._result = nil -- body fibre under the child scope child:spawn(function(s) - local res = { body_fn(s, unpack(args)) } + local res = pack(body_fn(s, unpack(args, 1, args.n))) s._result = res end) @@ -522,7 +519,7 @@ local function run(body_fn, ...) local res = child._result if res then - return status, err, unpack(res) + return status, err, unpack(res, 1, res.n) else return status, err end From 08a571171186b670e2911f0b15675f1477cefe9e Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sat, 22 Nov 2025 13:45:18 +0000 Subject: [PATCH 040/138] enforce no blocking outside a fiber --- src/fibers/op.lua | 1 + src/fibers/performer.lua | 2 ++ src/fibers/scope.lua | 2 ++ 3 files changed, 5 insertions(+) diff --git a/src/fibers/op.lua b/src/fibers/op.lua index 827232d..a39b0c4 100644 --- a/src/fibers/op.lua +++ b/src/fibers/op.lua @@ -413,6 +413,7 @@ end -- Any Lua error raised during wraps or primitives is not caught here; -- it will abort the current fibre and be handled by the scope layer. perform = function(ev) + assert(runtime.current_fiber(), "perform_raw must be called from inside a fiber (use fibers.run as an entry point)") local leaves = compile_event(ev) -- Fast path: non-blocking attempt. diff --git a/src/fibers/performer.lua b/src/fibers/performer.lua index 5bcf48b..d109ca8 100644 --- a/src/fibers/performer.lua +++ b/src/fibers/performer.lua @@ -8,6 +8,7 @@ -- @module fibers.performer local op = require 'fibers.op' +local runtime = require 'fibers.runtime' local scope_mod @@ -27,6 +28,7 @@ local function assert_event(ev) end function M.perform(ev) + assert(runtime.current_fiber(), "perform: must be called from inside a fiber (use fibers.run as an entry point)") assert_event(ev) local s = current_scope() diff --git a/src/fibers/scope.lua b/src/fibers/scope.lua index 6e4d80c..c241140 100644 --- a/src/fibers/scope.lua +++ b/src/fibers/scope.lua @@ -392,7 +392,9 @@ end --- Synchronise on an event under this scope. -- Equivalent to op.perform_raw(self:run_ev(ev)). function Scope:sync(ev) + -- Scope-level synchronisation only occurs from inside a fiber assert(runtime.current_fiber(), "scope:sync must be called from inside a fiber (use fibers.run as an entry point)") + -- Fast pre-check: do not start new work on a non-running scope. local status, err = self:status() if status ~= "running" then From c0b7517d4d48f02578f2900904a7a5fb7a459eed Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sat, 22 Nov 2025 18:05:52 +0000 Subject: [PATCH 041/138] prevents hard exit on scope failure --- src/fibers/scope.lua | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/src/fibers/scope.lua b/src/fibers/scope.lua index c241140..2be12f2 100644 --- a/src/fibers/scope.lua +++ b/src/fibers/scope.lua @@ -59,6 +59,24 @@ local fiber_scopes = setmetatable({}, { __mode = "k" }) local root_scope local global_scope +-- Handler for uncaught errors from fibres not associated with any Scope. +-- Default policy: treat them as failures of the root scope. +local function default_unscoped_error_handler(fib, err) + if root_scope then + root_scope:_record_failure(err) + else + -- Root not yet initialised: conservative fallback is to log. + io.stderr:write("Unscoped fibre error before root initialised: " .. tostring(err) .. "\n") + end +end + +local unscoped_error_handler = default_unscoped_error_handler + +local function set_unscoped_error_handler(handler) + assert(type(handler) == "function", "unscoped error handler must be a function") + unscoped_error_handler = handler +end + ---------------------------------------------------------------------- -- Internal helpers ---------------------------------------------------------------------- @@ -117,9 +135,8 @@ local function root() -- ensure the scope's waitgroup is decremented for this fibre s._wg:done() else - -- Unscoped fibre failure: treat as fatal for now. - print("Unscoped fibre error: " .. tostring(err)) - os.exit(255) + -- Delegate unscoped fibre failures to the handler. + unscoped_error_handler(fib, err) end end end) @@ -532,9 +549,10 @@ end ---------------------------------------------------------------------- return { - root = root, - current = current, - run = run, - with_ev = with_ev, - Scope = Scope, + root = root, + current = current, + run = run, + with_ev = with_ev, + Scope = Scope, + set_unscoped_error_handler = set_unscoped_error_handler, } From 9f5f0abd4f98ab51bfc4b3fe4d54545a4f26c33c Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sat, 22 Nov 2025 18:16:57 +0000 Subject: [PATCH 042/138] scope: move failure accounting into Scope:spawn and extend with_ev tests --- src/fibers/scope.lua | 64 +++++++------ tests/test_scope.lua | 214 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 248 insertions(+), 30 deletions(-) diff --git a/src/fibers/scope.lua b/src/fibers/scope.lua index 2be12f2..54b61d4 100644 --- a/src/fibers/scope.lua +++ b/src/fibers/scope.lua @@ -123,19 +123,19 @@ local function root() root_scope = new_scope(nil) global_scope = root_scope - -- Supervisor fibre: translate uncaught fibre errors into - -- scope failures and cancellations. runtime.spawn_raw(function() while true do local fib, err = runtime.wait_fiber_error() local s = fiber_scopes[fib] + if s then - -- mark failure + cancel children + -- Fibres that were not started via Scope:spawn but + -- have an associated scope (e.g. internal helpers) + -- can still be treated as scope failures. s:_record_failure(err) - -- ensure the scope's waitgroup is decremented for this fibre - s._wg:done() + -- Note: NO _wg:done() here. else - -- Delegate unscoped fibre failures to the handler. + -- Completely unscoped fibres go to the global handler. unscoped_error_handler(fib, err) end end @@ -225,38 +225,45 @@ end -- fn :: function(Scope, ...): () -- ... :: arguments passed to fn -- --- Fail-fast semantics: --- - If fn raises, the fibre dies, runtime reports the failure, --- and this scope's status becomes "failed" with cancellation --- propagated to children. +-- Behaviour: +-- * The new fibre inherits this scope as its current scope. +-- * The scope’s waitgroup is incremented on creation and decremented +-- when the fibre finishes, whether normally or due to an error. +-- * Any uncaught error in fn is caught by the scope machinery, +-- recorded as a failure of the scope that was current at the point +-- of error, and does not propagate as a Lua error. +-- +-- The scope enters “failed” status on the first such error, with +-- cancellation propagated to any child scopes. function Scope:spawn(fn, ...) assert(self._status == "running", "cannot spawn on a non-running scope") local args = pack(...) self._wg:add(1) - -- Note: - -- * On normal completion of fn, this wrapper calls _wg:done(). - -- * If fn raises and the fibre aborts, this wrapper is not run. - -- In that case the supervisor fibre installed in scope.root() - -- observes the failure via runtime.wait_fiber_error() and calls - -- _wg:done() on this scope on the failing fibre's behalf. - -- * Do not call _wg:done() from fn or from other error paths: - -- every child fibre must account for exactly one decrement, - -- either here or via the supervisor, but never both. - runtime.spawn_raw(function() local fib = current_fiber() local prev = fib and fiber_scopes[fib] or nil + if fib then + -- Dynamic current scope for this fibre. fiber_scopes[fib] = self end - fn(self, unpack(args, 1, args.n)) + -- Run user code under this scope; catch uncaught errors. + local ok, err = safe.pcall(fn, self, unpack(args, 1, args.n)) + + if not ok then + -- Attribute failure to the scope that was current at the point of error. + local s = fib and (fiber_scopes[fib] or self) or self + s:_record_failure(err) + end + -- Restore previous dynamic scope mapping. if fib then fiber_scopes[fib] = prev end + -- Lifetime accounting: this fibre always belonged to `self`. self._wg:done() end) end @@ -507,17 +514,20 @@ end -- -- Behaviour: -- * A child scope of scope.current() is created. --- * body_fn is run in a *separate fibre* with that child as current. --- * All fibres spawned under that child are tracked. --- * When the child scope has closed (ok/failed/cancelled, defers run), --- this function returns: +-- * body_fn is run in a *separate fibre* with that child as the +-- dynamic current scope. +-- * All fibres spawned under that child are tracked via its waitgroup. +-- * Uncaught errors in body_fn (or its descendant fibres) are recorded +-- as failures of the child scope but do not escape as Lua errors. +-- * When the child scope reaches a terminal state (ok/failed/cancelled) +-- and its defers have run, this function returns: -- status, error, ...results_from_body_fn... -- -- status :: "ok" | "failed" | "cancelled" -- error :: primary error / cancellation reason, or nil on "ok". -- --- On failure or cancellation, no Lua error is thrown here; callers --- should branch on status. +-- The caller receives status information only; failure or cancellation +-- does not raise a Lua error here. local function run(body_fn, ...) assert(runtime.current_fiber(), "scope.run must be called from inside a fiber") local parent = current() diff --git a/tests/test_scope.lua b/tests/test_scope.lua index 85aafee..b10d378 100644 --- a/tests/test_scope.lua +++ b/tests/test_scope.lua @@ -166,11 +166,152 @@ local function test_with_ev_basic() local a, b = performer.perform(ev) assert(a == 99 and b == "ok", "with_ev should propagate child event results") + -- After perform, current() should be restored to the parent. + assert(scope.current() == parent, + "after with_ev perform, current() should be restored to parent scope") + assert(child_scope ~= nil, "with_ev should have created a child scope") local st, err = child_scope:status() assert(st == "ok" and err == nil, "with_ev child scope should end ok on success") end +-- Failure in the with_ev builder should be confined to the with_ev child scope. +local function test_with_ev_failure_confined_to_child() + local outer_scope + local child_scope + + local st, serr = scope.run(function(s) + outer_scope = s + + local ev = scope.with_ev(function(child) + child_scope = child + assert(scope.current() == child, + "inside failing with_ev, current() should be child scope") + assert(child:parent() == s, + "with_ev child parent should be the surrounding scope.run scope") + + error("with_ev builder failure") + end) + + -- The error above is caught by the Scope:spawn wrapper for this body fibre. + performer.perform(ev) + + -- Not reached. + end) + + -- The outer scope remains ok; the failure is local to the with_ev child. + assert(st == "ok" and serr == nil, + "outer scope.run should still succeed when with_ev child fails") + + assert(outer_scope ~= nil, "outer_scope should have been set") + assert(child_scope ~= nil, "with_ev failure test should have created child scope") + + local cst, cerr = child_scope:status() + assert(cst == "failed", "with_ev child should be failed after builder error") + assert(tostring(cerr):find("with_ev builder failure", 1, true), + "with_ev child error should mention builder failure") +end + +-- with_ev used in a choice where it loses should lead to a cancelled child scope. +local function test_with_ev_abort_on_choice() + local outer_scope + local child_scope + + local st, serr, winner = scope.run(function(s) + outer_scope = s + + local ev_with = scope.with_ev(function(child) + child_scope = child + assert(scope.current() == child, + "inside with_ev arm of choice, current() should be child scope") + assert(child:parent() == s, + "with_ev child parent in choice should be outer scope") + + -- This arm never becomes ready; it will lose the choice. + return op.never() + end) + + local ev_choice = op.choice(ev_with, op.always("right")) + local res = performer.perform(ev_choice) + assert(res == "right", "choice should pick the always('right') arm") + + -- After the choice, current() should be restored. + assert(scope.current() == s, + "after with_ev choice, current() should be restored to outer scope") + + return res + end) + + assert(st == "ok" and serr == nil, + "outer scope.run should succeed when with_ev arm loses a choice") + assert(winner == "right", + "outer scope.run should return the winning choice result") + + assert(outer_scope ~= nil, "outer_scope should have been set") + assert(child_scope ~= nil, "with_ev choice test should have created child scope") + + local cst, cerr = child_scope:status() + assert(cst == "cancelled", + "with_ev child should be cancelled when its event loses a choice") + assert(cerr == "scope aborted", + "with_ev aborted child error should be 'scope aborted'") +end + +-- Failure in a fibre spawned under a with_ev child scope should fail that child, +-- but not its outer scope. +local function test_with_ev_child_fibre_failure() + local outer_scope + local child_scope + + local st, serr = scope.run(function(s) + outer_scope = s + + local ev = scope.with_ev(function(child) + child_scope = child + assert(child:parent() == s, + "with_ev child parent should be outer scope in child-fibre test") + + -- A condition used only to keep one child fibre blocked. + local c = cond_mod.new() + + -- Failing child fibre under the with_ev scope. + child:spawn(function(_) + error("with_ev child fibre failure") + end) + + -- Another child fibre that blocks on a cond and is cancelled + -- via the with_ev scope's cancellation. + child:spawn(function(_) + local ok2, reason2 = performer.perform(c:wait_op()) + -- Under failure, this fibre should see a cancellation result. + assert(ok2 == false, "blocked child fibre should observe cancellation ok=false") + assert(reason2 ~= nil, "blocked child fibre should receive a cancellation reason") + end) + + -- The main event for with_ev completes successfully. + return op.always("ok") + end) + + local res = performer.perform(ev) + assert(res == "ok", "with_ev main event should still return its result") + + -- At this point, with_ev's release will have waited for the child scope + -- to close, including both spawned child fibres and defers. + end) + + assert(st == "ok" and serr == nil, + "outer scope.run should remain ok after with_ev child-fibre failure") + + assert(outer_scope ~= nil, "outer_scope should have been set") + assert(child_scope ~= nil, "with_ev child-fibre test should have created child scope") + + local cst, cerr = child_scope:status() + assert(cst == "failed", + "with_ev child scope should be failed after a child fibre failure") + assert(tostring(cerr):find("with_ev child fibre failure", 1, true), + "with_ev child scope error should mention the child fibre failure") +end + ------------------------------------------------------------------------------- -- 2. Status transitions for scope.run (success, failure, cancellation) ------------------------------------------------------------------------------- @@ -196,9 +337,7 @@ local function test_run_success_and_failure() assert(success_scope:parent() == root, "success scope parent should be root") -- Failure case: body error becomes scope failure; scope.run does not throw. - -- local fail_scope local st_fail, err_fail = scope.run(function() - -- fail_scope = s error("body failure") end) @@ -254,6 +393,33 @@ local function test_defers_lifo_and_failure() "defers should run in LIFO order even on failure") end +-- Defer failures after a successful body should turn the scope to 'failed' +-- and surface the defer error as primary, but still preserve body results. +local function test_defer_failure_marks_scope_failed() + local scope_ref + + local st, serr, body_res = scope.run(function(s) + scope_ref = s + s:defer(function() + error("defer failure") + end) + return "body-result" + end) + + assert(st == "failed", + "scope.run should report failed if a defer handler fails") + assert(tostring(serr):find("defer failure", 1, true), + "defer failure should be the primary error") + + local st2, serr2 = scope_ref:status() + assert(st2 == "failed", "scope status should be failed after defer failure") + assert(tostring(serr2):find("defer failure", 1, true), + "scope error should mention the defer failure") + + assert(body_res == "body-result", + "scope.run should still return body results even if defers fail") +end + ------------------------------------------------------------------------------- -- 4. Scope:sync via performer.perform: failure and cancellation paths ------------------------------------------------------------------------------- @@ -313,6 +479,38 @@ local function test_sync_respects_cancellation() assert(serr2 == "cancel before sync", "cancelled_scope error should be the cancellation reason") end +-- Cancellation racing with a blocking sync: cancel the scope while a fibre +-- is blocked on a wait_op. +local function test_sync_cancellation_race() + local race_scope + + local st, serr, ok_ev, reason_ev = scope.run(function(s) + race_scope = s + local cond = cond_mod.new() + + -- Canceller fibre: let the main fibre block first, then cancel. + s:spawn(function(_) + runtime.yield() + s:cancel("race cancel") + end) + + local ok2, reason2 = performer.perform(cond:wait_op()) + return ok2, reason2 + end) + + assert(st == "cancelled", "scope.run should report cancelled in race test") + assert(serr == "race cancel", + "race cancellation reason should be 'race cancel'") + + assert(ok_ev == false, "blocking event should observe ok=false when cancelled") + assert(reason_ev == "race cancel", + "blocking event should see the race cancellation reason") + + local st2, serr2 = race_scope:status() + assert(st2 == "cancelled" and serr2 == "race cancel", + "race_scope should be cancelled with the correct reason") +end + ------------------------------------------------------------------------------- -- 5. join_ev and done_ev (on failed/cancelled scopes) ------------------------------------------------------------------------------- @@ -377,7 +575,6 @@ end ------------------------------------------------------------------------------- local function test_fail_fast_from_child_fibre() - -- local root = scope.root() local test_scope local st, serr = scope.run(function(s) @@ -419,14 +616,25 @@ local function main() runtime.spawn_raw(function() test_outside_fibers() test_inside_fibers() + test_with_ev_basic() + test_with_ev_failure_confined_to_child() + test_with_ev_abort_on_choice() + test_with_ev_child_fibre_failure() + test_run_success_and_failure() test_run_explicit_cancel() + test_defers_lifo_and_failure() + test_defer_failure_marks_scope_failed() + test_sync_wraps_event_failure() test_sync_respects_cancellation() + test_sync_cancellation_race() + test_join_and_done_events() test_fail_fast_from_child_fibre() + io.stdout:write("OK\n") runtime.stop() end) From 82666334c99e2f7fa268ad031bb4e55b00662847 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sun, 23 Nov 2025 19:07:50 +0000 Subject: [PATCH 043/138] initial LuaLS annotation --- src/fibers.lua | 84 ++++---- src/fibers/channel.lua | 53 +++-- src/fibers/oneshot.lua | 37 +++- src/fibers/op.lua | 422 +++++++++++++++++++++------------------ src/fibers/performer.lua | 42 ++-- src/fibers/runtime.lua | 134 ++++++++----- src/fibers/sched.lua | 106 ++++++---- src/fibers/scope.lua | 313 ++++++++++++----------------- src/fibers/sleep.lua | 37 ++-- src/fibers/timer.lua | 83 +++++--- src/fibers/waitgroup.lua | 35 +++- tests/test_channel.lua | 2 +- tests/test_scope.lua | 236 +++++++++++----------- 13 files changed, 871 insertions(+), 713 deletions(-) diff --git a/src/fibers.lua b/src/fibers.lua index b9dd345..b78c709 100644 --- a/src/fibers.lua +++ b/src/fibers.lua @@ -7,14 +7,11 @@ -- - scope (structured concurrency), -- - primitives (sleep, channel, etc.). -- --- Scopes currently carry no policies; they form a tree and track the --- current scope per fiber. --- --- @module fibers +---@module 'fibers' -local Op = require 'fibers.op' +local Op = require 'fibers.op' local Runtime = require 'fibers.runtime' -local Scope = require 'fibers.scope' +local Scope = require 'fibers.scope' local Performer = require 'fibers.performer' local unpack = rawget(table, "unpack") or _G.unpack @@ -22,51 +19,34 @@ local pack = rawget(table, "pack") or function(...) return { n = select("#", ...), ... } end -local fibers = {} - ---------------------------------------------------------------------- -- Core entry points ---------------------------------------------------------------------- ---- Perform an event under the current scope (if any). --- Delegates to the scope-aware performer. -fibers.perform = Performer.perform - ---- Monotonic time source from the underlying scheduler. -fibers.now = Runtime.now - -fibers.choice = Op.choice - --- Run a main function under the scheduler's root scope. -- --- main_fn :: function(Scope, ...): ...results... --- --- Behaviour: --- * A process-wide root scope is created on first use. --- * A child scope of that root is created via scope.run. --- * main_fn is run in its own fibre under that child scope. --- * All fibres spawned under that child are tracked. --- * When the child scope has closed (ok/failed/cancelled, defers run), --- this function returns: --- status, err, ...results_from_main_fn... +-- main_fn is called as main_fn(scope, ...). -- +-- Returns: -- status :: "ok" | "failed" | "cancelled" -- err :: primary error / cancellation reason, or nil on "ok". --- --- This function does not exit the process. It stops the scheduler and --- hands the status and error back to the caller. -function fibers.run(main_fn, ...) +---@param main_fn fun(s: Scope, ...): any +---@param ... any +---@return ScopeStatus status +---@return any err +---@return any ... +local function run(main_fn, ...) assert(not Runtime.current_fiber(), "fibers.run must not be called from inside a fiber") local root = Scope.root() local args = pack(...) local status, err - local results -- may be nil or a packed table + local results -- may be nil or a packed table root:spawn(function() -- scope.run creates a child scope of the current scope, - -- runs main_fn(body_scope, ...) in its own fibre, and returns + -- runs main_fn(body_scope, ...) in its own fiber, and returns -- (status, err, ...results_from_body_fn...). local packed = pack(Scope.run(main_fn, unpack(args, 1, args.n or #args))) status, err = packed[1], packed[2] @@ -102,13 +82,39 @@ end -- Spawn ---------------------------------------------------------------------- ---- Spawn a fibre under the current scope. --- --- fn :: function(Scope, ...): () --- ... :: arguments passed to fn -function fibers.spawn(fn, ...) +--- Spawn a fiber under the current scope. +--- +--- fn is called as fn(scope, ...). +---@param fn fun(s: Scope, ...): any +---@param ... any +local function spawn(fn, ...) local s = Scope.current() return s:spawn(fn, ...) end -return fibers +return { + spawn = spawn, + run = run, + + perform = Performer.perform, + + now = Runtime.now, + + choice = Op.choice, + guard = Op.guard, + with_nack = Op.with_nack, + always = Op.always, + never = Op.never, + bracket = Op.bracket, + + -- Higher-level choice helpers + race = Op.race, + first_ready = Op.first_ready, + named_choice = Op.named_choice, + boolean_choice = Op.boolean_choice, + + -- Scope utilities re-exported + run_scope = Scope.run, + scope_op = Scope.with_op, + set_unscoped_error_handler = Scope.set_unscoped_error_handler, +} diff --git a/src/fibers/channel.lua b/src/fibers/channel.lua index ed2059b..71c20a9 100644 --- a/src/fibers/channel.lua +++ b/src/fibers/channel.lua @@ -1,13 +1,23 @@ -- fibers.channel --- Provides Concurrent ML style channels for communication between fibers. +-- Concurrent ML style channels for communication between fibers. +---@module 'fibers.channel' local op = require 'fibers.op' local fifo = require 'fibers.utils.fifo' local perform = require 'fibers.performer'.perform +--- Bidirectional communication channel between fibers. +---@class Channel +---@field buffer table|nil # optional FIFO buffer (nil for unbuffered) +---@field buffer_size integer +---@field getq table # queue of waiting receivers +---@field putq table # queue of waiting senders local Channel = {} Channel.__index = Channel +--- Create a new channel. +---@param buffer_size? integer # buffered capacity (0 or nil for unbuffered) +---@return Channel local function new(buffer_size) buffer_size = buffer_size or 0 @@ -28,6 +38,9 @@ end -- Helpers: pop active entries based on suspension state ---------------------------------------------------------------------- +--- Pop the next entry whose suspension is still waiting, if any. +---@param q any +---@return table|nil local function pop_active(q) while not q:empty() do local entry = q:pop() @@ -38,15 +51,19 @@ local function pop_active(q) return nil end ----------------------------------------------------------------------- --- PUT side ----------------------------------------------------------------------- - +--- Op that sends val on the channel. +--- For unbuffered channels, this synchronises with a receiver; for buffered +--- channels, it may complete when space is available in the buffer. +---@param val any +---@return Op function Channel:put_op(val) local getq, putq = self.getq, self.putq local buffer, buffer_size = self.buffer, self.buffer_size - -- Per-operation state for this event instance. + ---@class ChannelPutEntry + ---@field val any + ---@field suspension Suspension|nil + ---@field wrap WrapFn|nil local entry = { val = val, suspension = nil, @@ -69,6 +86,9 @@ function Channel:put_op(val) return false end + --- Enqueue as a waiting sender when the put cannot complete immediately. + ---@param suspension Suspension + ---@param wrap_fn WrapFn local function block(suspension, wrap_fn) entry.suspension = suspension entry.wrap = wrap_fn @@ -78,14 +98,16 @@ function Channel:put_op(val) return op.new_primitive(nil, try, block) end ----------------------------------------------------------------------- --- GET side ----------------------------------------------------------------------- - +--- Op that receives a value from the channel. +--- May take from the buffer or rendezvous directly with a sender. +---@return Op function Channel:get_op() local getq, putq = self.getq, self.putq local buffer = self.buffer + ---@class ChannelGetEntry + ---@field suspension Suspension|nil + ---@field wrap WrapFn|nil local entry = { suspension = nil, wrap = nil, @@ -120,6 +142,9 @@ function Channel:get_op() return false end + --- Enqueue as a waiting receiver when no value is immediately available. + ---@param suspension Suspension + ---@param wrap_fn WrapFn local function block(suspension, wrap_fn) entry.suspension = suspension entry.wrap = wrap_fn @@ -129,14 +154,14 @@ function Channel:get_op() return op.new_primitive(nil, try, block) end ----------------------------------------------------------------------- --- Synchronous wrappers ----------------------------------------------------------------------- - +--- Synchronously send message on the channel. +---@param message any function Channel:put(message) return perform(self:put_op(message)) end +--- Synchronously receive a message from the channel. +---@return any function Channel:get() return perform(self:get_op()) end diff --git a/src/fibers/oneshot.lua b/src/fibers/oneshot.lua index 0f80d9f..a97f7b5 100644 --- a/src/fibers/oneshot.lua +++ b/src/fibers/oneshot.lua @@ -1,19 +1,33 @@ -- fibers/oneshot.lua -local OneShot = {} -OneShot.__index = OneShot +--- One-shot notification primitive. +---@module 'fibers.oneshot' +---@alias OneshotWaiter fun() + +---@class Oneshot +---@field triggered boolean +---@field waiters OneshotWaiter[] +---@field on_after_signal fun()|nil +local Oneshot = {} +Oneshot.__index = Oneshot + +--- Create a new one-shot. +---@param on_after_signal? fun() # optional callback run after signalling all waiters +---@return Oneshot local function new(on_after_signal) return setmetatable({ triggered = false, - waiters = {}, -- array of functions() - on_after_signal = on_after_signal -- optional - }, OneShot) + waiters = {}, + on_after_signal = on_after_signal, + }, Oneshot) end -function OneShot:add_waiter(thunk) +--- Register a waiter. +--- If already triggered, the thunk is run immediately. +---@param thunk OneshotWaiter +function Oneshot:add_waiter(thunk) if self.triggered then - -- Already triggered: run immediately. thunk() return end @@ -22,7 +36,10 @@ function OneShot:add_waiter(thunk) ws[#ws + 1] = thunk end -function OneShot:signal() +--- Trigger the one-shot. +--- All waiters are run once; the optional callback runs afterwards. +--- Idempotent: subsequent calls after the first have no effect. +function Oneshot:signal() if self.triggered then return end self.triggered = true @@ -39,7 +56,9 @@ function OneShot:signal() end end -function OneShot:is_triggered() +--- Check whether the one-shot has fired. +---@return boolean +function Oneshot:is_triggered() return self.triggered end diff --git a/src/fibers/op.lua b/src/fibers/op.lua index a39b0c4..b699d2d 100644 --- a/src/fibers/op.lua +++ b/src/fibers/op.lua @@ -1,26 +1,14 @@ ---- fibers.op module --- Concurrent ML style operations for managing concurrency. --- --- Events are CML-style: primitive leaves, choices, guards, with_nack, --- wraps, and an abort combinator (on_abort). --- --- Core event AST kinds: --- prim : primitive leaf { try_fn, block_fn, wrap_fn } --- choice : non-empty list of events --- guard : delayed event builder (run once per sync) --- with_nack : CML-style nack combinator --- wrap : post-commit mapper (composed at compile time) --- abort : attach abort handler to an event (run if this arm loses) --- --- Important design note: --- This module is *exception-neutral*. It does not interpret Lua --- errors as part of event semantics. Any uncaught error in a wrap --- or primitive is treated as a bug and will be surfaced by the --- surrounding scope / fibre machinery. - -local runtime = require 'fibers.runtime' -local safe = require 'coxpcall' -local oneshot = require 'fibers.oneshot' +-- fibers/op.lua + +--- Concurrent ML style operations for structured concurrency. +--- Provides composable operations (ops) that may complete immediately +--- or block, with support for choice, guards, negative acknowledgements +--- and abort/cleanup behaviour. +---@module 'fibers.op' + +local runtime = require 'fibers.runtime' +local safe = require 'coxpcall' +local oneshot = require 'fibers.oneshot' local unpack = rawget(table, "unpack") or _G.unpack local pack = rawget(table, "pack") or function(...) @@ -33,16 +21,32 @@ local function id_wrap(...) return ... end -- Suspensions and completion tasks ---------------------------------------------------------------------- +--- A suspension of a fiber waiting on an op. +---@class Suspension +---@field state "waiting"|"synchronized" # whether the suspension is still pending +---@field sched Scheduler # scheduler used to reschedule the fiber +---@field fiber Fiber # fiber object to resume +---@field wrap WrapFn|nil # wrap function to apply on resume +---@field val table|nil # packed resume values local Suspension = {} Suspension.__index = Suspension +---@class CompleteTask : Task +---@field suspension Suspension # suspension to complete +---@field wrap WrapFn # wrap function applied on completion +---@field val table # packed completion values local CompleteTask = {} CompleteTask.__index = CompleteTask +--- Check whether the suspension is still waiting. +---@return boolean function Suspension:waiting() return self.state == 'waiting' end +--- Mark a suspension as complete and enqueue it on the scheduler. +---@param wrap WrapFn +---@param ... any function Suspension:complete(wrap, ...) assert(self:waiting()) self.state = 'synchronized' @@ -51,35 +55,46 @@ function Suspension:complete(wrap, ...) self.sched:schedule(self) end +--- Complete a suspension and resume the fiber immediately. +---@param wrap WrapFn +---@param ... any +---@return any function Suspension:complete_and_run(wrap, ...) assert(self:waiting()) self.state = 'synchronized' return self.fiber:resume(wrap, ...) end +--- Create a task that will complete this suspension when run. +---@param wrap WrapFn +---@param ... any +---@return CompleteTask function Suspension:complete_task(wrap, ...) return setmetatable({ suspension = self, wrap = wrap, val = pack(...) }, CompleteTask) end +--- Run the suspension completion task as a scheduled task. function Suspension:run() assert(not self:waiting()) return self.fiber:resume(self.wrap, unpack(self.val, 1, self.val.n)) end +---@param sched Scheduler +---@param fib any +---@return Suspension local function new_suspension(sched, fib) return setmetatable({ state = 'waiting', sched = sched, fiber = fib }, Suspension) end --- A CompleteTask completes a suspension (if still waiting) when run. +--- A CompleteTask completes a suspension (if still waiting) when run. function CompleteTask:run() if self.suspension:waiting() then self.suspension:complete_and_run(self.wrap, unpack(self.val, 1, self.val.n)) end end --- A CompleteTask can be cancelled. In the non-exceptional model, this --- completes the suspension with a special "cancel" wrap that returns --- a tagged result (false, reason) rather than raising. +--- Cancel a CompleteTask, completing the suspension with a tagged result. +---@param reason? string function CompleteTask:cancel(reason) if self.suspension:waiting() then local msg = reason or 'cancelled' @@ -92,26 +107,50 @@ function CompleteTask:cancel(reason) end ---------------------------------------------------------------------- --- 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 } --- kind = 'abort' : { inner = Event, abort_fn = f } +-- Op type (unifies primitive and composite ops) ---------------------------------------------------------------------- -local Event = {} -Event.__index = Event - --- forward declaration so compile_event can call perform if needed in --- future extension; currently perform does not use exceptions. +---@alias WrapFn fun(...: any): ... +---@alias TryFn fun(): boolean, ... +---@alias BlockFn fun(suspension: Suspension, wrap_fn: WrapFn) + +--- Negative acknowledgement condition used by with_nack/abort. +---@class NackCond +---@field wait_op fun(): Op # op that becomes ready when the condition fires (if present) +---@field signal fun() # trigger the condition (idempotent) + +--- Compiled primitive leaf of an op tree. +---@class CompiledLeaf +---@field try_fn TryFn +---@field block_fn BlockFn +---@field wrap WrapFn +---@field nacks NackCond[] + +--- General op representation (primitive or composite). +---@class Op +---@field kind "prim"|"choice"|"guard"|"with_nack"|"wrap"|"abort" +---@field ops Op[]|nil +---@field builder fun(...: any): Op +---@field wrap_fn WrapFn|nil +---@field inner Op|nil +---@field abort_fn fun()|nil +---@field try_fn TryFn|nil +---@field block_fn BlockFn|nil +local Op = {} +Op.__index = Op + +-- Forward declaration so compile_op can refer to perform. local perform --- Primitive event (leaf). --- try_fn() -> success:boolean, ... --- block_fn(suspension, wrap_fn) sets up async completion. +--- Construct a primitive op. +--- try_fn() -> success:boolean, ... +--- block_fn(suspension, wrap_fn) must arrange asynchronous completion +--- by calling suspension:complete(...), typically from an event handler; +--- it must not resume the fiber synchronously via complete_and_run(). +---@param wrap_fn? WrapFn +---@param try_fn TryFn +---@param block_fn BlockFn +---@return Op local function new_primitive(wrap_fn, try_fn, block_fn) return setmetatable( { @@ -120,69 +159,86 @@ local function new_primitive(wrap_fn, try_fn, block_fn) try_fn = try_fn, block_fn = block_fn, }, - Event + Op ) end --- Choice event: non-empty list of sub-events. +--- Choice op over a non-empty list of sub-ops. +--- Nested choices are flattened. +---@param ... Op +---@return Op 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 + local ops = {} + for _, op in ipairs({ ... }) do + if op.kind == 'choice' then + for _, sub in ipairs(op.ops) do + ops[#ops + 1] = sub end else - events[#events + 1] = ev + ops[#ops + 1] = op end end - if #events == 1 then return events[1] end - return setmetatable({ kind = 'choice', events = events }, Event) + if #ops == 1 then return ops[1] end + return setmetatable({ kind = 'choice', ops = ops }, Op) end --- guard g: delayed event; g() evaluated once per synchronization. +--- Delayed op builder; executed once per synchronisation. +---@param g fun(): Op +---@return Op local function guard(g) - return setmetatable({ kind = 'guard', builder = g }, Event) + return setmetatable({ kind = 'guard', builder = g }, Op) end --- CML-style with_nack: builder gets a nack event that becomes ready --- iff this event participates in a choice and *loses*. +--- CML-style with_nack. +--- The builder is passed a nack op that becomes ready if this arm loses in a choice. +---@param g fun(nack_op: Op): Op +---@return Op local function with_nack(g) - return setmetatable({ kind = 'with_nack', builder = g }, Event) + return setmetatable({ kind = 'with_nack', builder = g }, Op) end +--- Op that is immediately ready with the given results. +---@param ... any +---@return Op local function always(...) local results = pack(...) local function try() return true, unpack(results, 1, results.n) end - local function block() error("always: block_fn should never run") end -- never reached + local function block() error("always: block_fn should never run") end return new_primitive(nil, try, block) end +--- Op that never becomes ready. +---@return Op local function never() - -- An event that never becomes ready - return new_primitive(nil, + return new_primitive( + nil, function() return false end, - function() end) + function() end + ) end --- Wrap event with a post-processing function f (commit phase). --- This is another node in the tree; composed at compile time. -function Event:wrap(f) +--- Wrap this op with a post-processing function f (commit phase). +--- Wraps compose in declaration order. +---@param f WrapFn +---@return Op +function Op:wrap(f) return setmetatable( { kind = 'wrap', inner = self, wrap_fn = f }, - Event + Op ) end --- Attach an abort handler to this event. --- f() is run iff this event participates in a choice and *does not win*. -function Event:on_abort(f) +--- Attach an abort handler to this op. +--- f() is run if this op participates in a choice and does not win. +---@param f fun() +---@return Op +function Op:on_abort(f) assert(type(f) == 'function', "on_abort expects a function") return setmetatable( { kind = 'abort', inner = self, abort_fn = f }, - Event + Op ) end @@ -190,6 +246,9 @@ end -- Simple one-shot condition primitive (used for with_nack) ---------------------------------------------------------------------- +--- Create a nack condition optionally carrying an abort handler. +---@param opts? { abort_fn: fun() } +---@return NackCond local function new_cond(opts) local abort_fn = opts and opts.abort_fn or nil @@ -224,76 +283,68 @@ local function new_cond(opts) end return { - wait_op = abort_fn and nil or wait_op, + wait_op = wait_op, signal = signal, } end ---------------------------------------------------------------------- --- 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 nack/abort conds on this path --- } --- --- Semantics: --- - Each with_nack or abort node adds a cond to the nacks list. --- - wrap nodes compose their functions into the final wrap. +-- Compile an op tree into primitive leaves ---------------------------------------------------------------------- -local function compile_event(ev, outer_wrap, out, nacks) +---@param op Op +---@param outer_wrap? WrapFn +---@param out? CompiledLeaf[] +---@param nacks? NackCond[] +---@return CompiledLeaf[] +local function compile_op(op, outer_wrap, out, nacks) out = out or {} outer_wrap = outer_wrap or id_wrap nacks = nacks or {} - local kind = ev.kind + local kind = op.kind if kind == 'choice' then - for _, sub in ipairs(ev.events) do - compile_event(sub, outer_wrap, out, nacks) + for _, sub in ipairs(op.ops) do + compile_op(sub, outer_wrap, out, nacks) end elseif kind == 'guard' then - local inner = ev.builder() - compile_event(inner, outer_wrap, out, nacks) + local inner = op.builder() + compile_op(inner, outer_wrap, out, nacks) elseif kind == 'with_nack' then local cond = new_cond() - local nack_ev = cond.wait_op() - local inner = ev.builder(nack_ev) + local nack_op = cond.wait_op() + local inner = op.builder(nack_op) local child_nacks = { unpack(nacks) } child_nacks[#child_nacks + 1] = cond - compile_event(inner, outer_wrap, out, child_nacks) + compile_op(inner, outer_wrap, out, child_nacks) elseif kind == 'wrap' then - -- Wraps compose in declaration order: ev:wrap(f1):wrap(f2) → f2(f1(...)). - local f = ev.wrap_fn + -- Wraps compose in declaration order: op:wrap(f1):wrap(f2) → f2(f1(...)). + local f = assert(op.wrap_fn) local new_outer = function(...) return outer_wrap(f(...)) end - compile_event(ev.inner, new_outer, out, nacks) + compile_op(op.inner, new_outer, out, nacks) elseif kind == 'abort' then - local cond = new_cond{ abort_fn = ev.abort_fn } + local cond = new_cond{ abort_fn = op.abort_fn } local child_nacks = { unpack(nacks) } child_nacks[#child_nacks + 1] = cond - compile_event(ev.inner, outer_wrap, out, child_nacks) + compile_op(op.inner, outer_wrap, out, child_nacks) else -- 'prim' local function wrapped(...) - -- No exception machinery here; any Lua error is treated - -- as a bug and handled by the surrounding scope/fibre. - return outer_wrap(ev.wrap_fn(...)) + -- Any Lua error here is treated as a bug and propagates normally. + return outer_wrap(op.wrap_fn(...)) end out[#out + 1] = { - try_fn = ev.try_fn, - block_fn = ev.block_fn, + try_fn = op.try_fn, + block_fn = op.block_fn, wrap = wrapped, nacks = nacks, } @@ -306,10 +357,9 @@ end -- Nack triggering and non-blocking attempt ---------------------------------------------------------------------- --- Signal all conds that belong exclusively to losing arms. --- - Build set of nacks on the winner path. --- - For each loser leaf, signal any nacks not in the winner set. --- - Each cond object is responsible for idempotence. +--- Signal all nack conditions belonging exclusively to losing arms. +---@param ops CompiledLeaf[] +---@param winner_index? integer local function trigger_nacks(ops, winner_index) local winner_set if winner_index then @@ -343,8 +393,10 @@ local function trigger_nacks(ops, winner_index) end end --- Try once to find a ready leaf in ops (random probe order). --- Returns winner_index, retval_pack | nil. +--- Try once to find a ready leaf in ops (random probe order). +--- Returns winner_index and packed results, or nil if none are ready. +---@param ops CompiledLeaf[] +---@return integer|nil, table|nil local function try_ready(ops) local n = #ops if n == 0 then return nil end @@ -360,8 +412,13 @@ local function try_ready(ops) return nil end --- Apply a leaf's wrap to its retval_pack. +--- Apply a leaf's wrap to its packed results. +---@param wrap WrapFn +---@param retval table|nil +---@return any ... local function apply_wrap(wrap, retval) + assert(retval ~= nil, "apply_wrap: retval must not be nil") + ---@cast retval table return wrap(unpack(retval, 2, retval.n)) end @@ -369,24 +426,22 @@ end -- or_else: biased, non-blocking choice ---------------------------------------------------------------------- -function Event:or_else(fallback_thunk) +--- Non-blocking choice: try this op, otherwise run fallback_thunk. +---@param fallback_thunk fun(): any +---@return Op +function Op:or_else(fallback_thunk) assert(type(fallback_thunk) == "function", "or_else expects a function") return guard(function() - -- Compile `self` once for this synchronisation. - local leaves = compile_event(self) + local leaves = compile_op(self) - -- Non-blocking attempt to commit to `self`. local idx, retval = try_ready(leaves) if idx then - -- Normal CML semantics: `self` wins, fire nacks for losers. trigger_nacks(leaves, idx) local results = pack(apply_wrap(leaves[idx].wrap, retval)) return always(unpack(results, 1, results.n)) end - -- No leaf of `self` is ready now → `self` loses as a whole. - -- Fire all nacks/abort handlers hanging off `self`. trigger_nacks(leaves, nil) local results = pack(fallback_thunk()) @@ -398,6 +453,10 @@ end -- Blocking choice path ---------------------------------------------------------------------- +--- Block the current fiber until one of the compiled leaves completes. +---@param sched Scheduler +---@param fib any +---@param ops CompiledLeaf[] local function block_choice_op(sched, fib, ops) local suspension = new_suspension(sched, fib) for _, op in ipairs(ops) do @@ -406,15 +465,16 @@ local function block_choice_op(sched, fib, ops) end ---------------------------------------------------------------------- --- Event methods: perform +-- Op methods: perform ---------------------------------------------------------------------- --- Perform this event (primitive or composite), possibly blocking. --- Any Lua error raised during wraps or primitives is not caught here; --- it will abort the current fibre and be handled by the scope layer. -perform = function(ev) +--- Perform this op (primitive or composite), blocking if necessary. +--- Must be called from within a fiber; errors propagate as normal Lua errors. +---@param op Op +---@return any ... +perform = function(op) assert(runtime.current_fiber(), "perform_raw must be called from inside a fiber (use fibers.run as an entry point)") - local leaves = compile_event(ev) + local leaves = compile_op(op) -- Fast path: non-blocking attempt. local idx, retval = try_ready(leaves) @@ -423,11 +483,10 @@ perform = function(ev) return apply_wrap(leaves[idx].wrap, retval) end - -- Slow path: we now block all leaves using block_choice_op. + -- Slow path: block all leaves. local suspended = pack(runtime.suspend(block_choice_op, leaves)) local wrap = suspended[1] - -- Identify winning leaf by its wrap function, if any. local winner_index for i, leaf in ipairs(leaves) do if leaf.wrap == wrap then @@ -441,28 +500,16 @@ perform = function(ev) end ---------------------------------------------------------------------- --- bracket : (acquire, release, use) -> 'a event --- --- acquire() : -> resource --- release(resource, aborted:boolean) --- use(resource) : -> Event (the main action) --- --- Semantics: --- * acquire is run once, at sync time (inside a guard). --- * use(res) must return an Event; it is not performed here. --- * if the resulting event `ev` WINS: --- - its result is returned --- - release(res, false) is called exactly once --- * if the resulting event PARTICIPATES in a choice but LOSES: --- - release(res, true) is called exactly once, via on_abort --- --- Error handling: --- * Errors raised by acquire, use, or release propagate as normal --- Lua errors from the performing fibre. --- * This combinator does not interpret or catch such errors; they --- are handled by the surrounding scope / supervision machinery. +-- bracket : (acquire, release, use) -> 'a op ---------------------------------------------------------------------- +--- Resource-safe wrapper for an op. +--- acquire() obtains a resource, release(resource, aborted) cleans it up, +--- and use(resource) returns the op that uses it. +---@param acquire fun(): any +---@param release fun(resource: any, aborted: boolean) +---@param use fun(resource: any): Op +---@return Op local function bracket(acquire, release, use) assert(type(acquire) == "function", "bracket: acquire must be a function") assert(type(release) == "function", "bracket: release must be a function") @@ -470,17 +517,13 @@ local function bracket(acquire, release, use) return guard(function() local res = acquire() + local op = use(res) - -- If use(res) throws, that is a bug; scope machinery will handle it. - local ev = use(res) - - -- Success path: event wins and completes → release(res, false) once. - local wrapped = ev:wrap(function(...) + local wrapped = op:wrap(function(...) release(res, false) return ... end) - -- Losing path: event participates in a choice but loses → release(res, true) once. return wrapped:on_abort(function() release(res, true) end) @@ -488,68 +531,72 @@ local function bracket(acquire, release, use) end ---------------------------------------------------------------------- --- finally : (ev, cleanup) -> ev' --- --- cleanup(aborted:boolean) --- --- Semantics: --- * On normal post-sync completion (this event wins): --- cleanup(false) is called once. --- * If the event participates in a choice but LOSES (via on_abort): --- cleanup(true) is called once. --- --- This is expressed in terms of bracket, with no additional policy: --- * cleanup is run in the performing fibre. --- * Errors raised by cleanup propagate as normal Lua errors and --- are not intercepted here; they are handled by the surrounding --- scope / supervision machinery. +-- finally : (op, cleanup) -> op' ---------------------------------------------------------------------- -function Event:finally(cleanup) +--- Attach cleanup(aborted) to an op. +--- cleanup is called with aborted=true if the op loses in a choice. +---@param cleanup fun(aborted: boolean) +---@return Op +function Op:finally(cleanup) assert(type(cleanup) == "function", "finally expects a function") return bracket( - function() return nil end, -- no real resource - function(_, aborted) cleanup(aborted) end, -- release - function() return self end -- use: just this event + function() return nil end, + function(_, aborted) cleanup(aborted) end, + function() return self end ) end ---------------------------------------------------------------------- --- Higher-level choice helpers (built entirely from choice + wrap) +-- Higher-level choice helpers ---------------------------------------------------------------------- -local function race(events, on_win) +--- Race a list of ops, applying on_win(index, ...) to the winner's result. +---@param ops Op[] +---@param on_win fun(index: integer, ...: any): ... +---@return Op +local function race(ops, on_win) assert(type(on_win) == "function", "race expects on_win callback") local wrapped = {} - for i, ev in ipairs(events) do - wrapped[i] = ev:wrap(function(...) + for i, op in ipairs(ops) do + wrapped[i] = op:wrap(function(...) return on_win(i, ...) end) end return choice(unpack(wrapped)) end -local function first_ready(events) - return race(events, function(i, ...) +--- Race ops and return (index, ...results...) of the winner. +---@param ops Op[] +---@return Op +local function first_ready(ops) + return race(ops, function(i, ...) return i, ... end) end +--- Choice over a table of named ops, returning (name, ...results...). +---@param arms table +---@return Op local function named_choice(arms) - -- arms is a map { name = Event, ... } - local events, names = {}, {} - for name, ev in pairs(arms) do - names[#names + 1] = name - events[#events + 1] = ev + local ops, names = {}, {} + for name, op in pairs(arms) do + names[#names + 1] = name + ops[#ops + 1] = op end - return race(events, function(i, ...) + return race(ops, function(i, ...) return names[i], ... end) end -local function boolean_choice(ev_true, ev_false) - return race({ ev_true, ev_false }, function(i, ...) +--- Choice between two ops, returning (boolean, ...results...). +--- Returns true for the first op, false for the second. +---@param op_true Op +---@param op_false Op +---@return Op +local function boolean_choice(op_true, op_false) + return race({ op_true, op_false }, function(i, ...) if i == 1 then return true, ... else @@ -564,17 +611,14 @@ end return { perform_raw = perform, - new_primitive = new_primitive, -- primitive event constructor + new_primitive = new_primitive, choice = choice, guard = guard, with_nack = with_nack, bracket = bracket, always = always, never = never, - Event = Event, - -- Event instances have methods: wrap, on_abort, finally, or_else. - - -- higher-level helpers + Op = Op, race = race, first_ready = first_ready, named_choice = named_choice, diff --git a/src/fibers/performer.lua b/src/fibers/performer.lua index d109ca8..808f2e6 100644 --- a/src/fibers/performer.lua +++ b/src/fibers/performer.lua @@ -1,19 +1,19 @@ -- fibers/performer.lua --- --- Scope-aware performer. --- Preferred entry point for synchronising on events in normal code. +-- Scope-aware performer for ops. +-- Preferred entry point for synchronising on ops in normal code. -- Delegates to the current scope if available, otherwise falls back -- to the raw op.perform. --- --- @module fibers.performer +---@module 'fibers.performer' -local op = require 'fibers.op' -local runtime = require 'fibers.runtime' +local Op = require 'fibers.op' +local Runtime = require 'fibers.runtime' +---@type any local scope_mod -local M = {} - +--- Get the current scope if the scope module has been loaded. +---@return Scope|nil local function current_scope() if not scope_mod then scope_mod = require 'fibers.scope' @@ -21,22 +21,30 @@ local function current_scope() return scope_mod.current and scope_mod.current() or nil end -local function assert_event(ev) - if type(ev) ~= "table" or getmetatable(ev) ~= op.Event then - error(("perform: expected Event, got %s (%s)"):format(type(ev), tostring(ev)), 3) +--- Check that a value is an Op instance. +---@param op any +local function assert_op(op) + if type(op) ~= "table" or getmetatable(op) ~= Op.Op then + error(("perform: expected op, got %s (%s)"):format(type(op), tostring(op)), 3) end end -function M.perform(ev) - assert(runtime.current_fiber(), "perform: must be called from inside a fiber (use fibers.run as an entry point)") - assert_event(ev) +--- Perform an op under the current scope, if any. +--- Must be called from inside a fiber. +---@param op Op +---@return any ... +local function perform(op) + assert(Runtime.current_fiber(), "perform: must be called from inside a fiber (use fibers.run as an entry point)") + assert_op(op) local s = current_scope() if s and s.sync then - return s:sync(ev) + return s:sync(op) else - return op.perform_raw(ev) + return Op.perform_raw(op) end end -return M +return { + perform = perform, +} diff --git a/src/fibers/runtime.lua b/src/fibers/runtime.lua index 5308066..40b2f48 100644 --- a/src/fibers/runtime.lua +++ b/src/fibers/runtime.lua @@ -1,45 +1,67 @@ -- fibers/runtime.lua --- --- Runtime module. --- Wraps the global scheduler and fiber machinery. --- This is a low-level module; most code should go via higher-level APIs. --- @module fibers.runtime +-- Runtime module for fibers. +-- Provides a global scheduler, fiber creation, suspension and error reporting. +---@module 'fibers.runtime' local sched = require 'fibers.sched' +--- Identity helper used as the wrap function when resuming fibers. +---@generic T +---@param ... T +---@return T ... local function id(...) return ... end --- Queue of uncaught fibre errors to be consumed by supervisors. +--- Record of an uncaught fiber error. +---@class FiberErrorRecord +---@field fiber Fiber +---@field err any + +--- Record of a fiber waiting for an error notification. +---@class ErrorWaiterRecord +---@field fiber Fiber + +---@type FiberErrorRecord[] local error_queue = {} + +---@type ErrorWaiterRecord[] local error_waiters = {} --- Task object used to wake a fibre waiting for an error. +--- Task used to wake a fiber waiting for an error. +---@class WaiterTask : Task +---@field waiter Fiber # waiting fiber +---@field err_fiber Fiber # fiber that failed +---@field err any # error value local WaiterTask = {} WaiterTask.__index = WaiterTask +--- Resume the waiting fiber with (wrap, err_fiber, err). function WaiterTask:run() - -- Resume the waiting fibre with (wrap, fiber, err). self.waiter:resume(id, self.err_fiber, self.err) end -local _current_fiber -local current_scheduler = sched.new() - ---- Fiber class --- Represents a single fiber, or lightweight thread. --- @type Fiber +--- Cooperative fiber object managed by the runtime. +---@class Fiber +---@field coroutine thread +---@field alive boolean +---@field sockets table +---@field traceback string|nil local Fiber = {} Fiber.__index = Fiber ---- Spawns a new fiber. --- @function spawn --- @tparam function fn The function to run in the new fiber. +---@type Fiber|nil +local _current_fiber + +---@type Scheduler +local current_scheduler = sched.new() + +--- Spawn a new fiber scheduled on the global scheduler. +--- The function is called as fn(wrap, ...), where wrap is typically the identity. +---@param fn fun(wrap: fun(...: any): any, ...: any) local function spawn(fn) - -- Capture the traceback local tb = debug.traceback("", 2):match("\n[^\n]*\n(.*)") or "" - -- If we're inside another fiber, append the traceback to the parent's traceback if _current_fiber and _current_fiber.traceback then tb = tb .. "\n" .. _current_fiber.traceback end @@ -49,27 +71,28 @@ local function spawn(fn) coroutine = coroutine.create(fn), alive = true, sockets = {}, - traceback = tb + traceback = tb, }, Fiber) ) end ---- Resumes execution of the fiber. --- If the fiber is already dead, this will throw an error. --- @tparam vararg ... The arguments to pass to the fiber. +--- Resume execution of this fiber. +--- If the fiber is dead, an error is raised. +---@param wrap fun(...: any): any +---@param ... any function Fiber:resume(wrap, ...) - assert(self.alive, "dead fiber") -- checks that the fiber is alive - local saved_current_fiber = _current_fiber -- shift the old current fiber into a safe place - _current_fiber = self -- we are the new current fiber - local ok, err = coroutine.resume(self.coroutine, wrap, ...) -- rev up our coroutine - -- current_fiber = saved_current_fiber the KEY bit, we only get here when the coroutine above has yielded, - -- but we then pop back in the fiber we previously displaced + assert(self.alive, "dead fiber") + local saved_current_fiber = _current_fiber + _current_fiber = self + local ok, err = coroutine.resume(self.coroutine, wrap, ...) _current_fiber = saved_current_fiber + if coroutine.status(self.coroutine) == "dead" then self.alive = false end + if not ok then - -- Report uncaught error to error consumers. + -- Report uncaught error to any waiting fiber, or queue it. if #error_waiters > 0 then local waiter = table.remove(error_waiters, 1) current_scheduler:schedule(setmetatable({ @@ -86,36 +109,50 @@ function Fiber:resume(wrap, ...) end end +--- Alias for :resume, so a Fiber can be scheduled as a Task. Fiber.run = Fiber.resume +--- Suspend this fiber until block_fn arranges to reschedule it. +--- block_fn receives (scheduler, fiber, ...). +---@param block_fn fun(scheduler: Scheduler, fiber: Fiber, ...: any) +---@param ... any +---@return any ... function Fiber:suspend(block_fn, ...) assert(_current_fiber == self) - -- The block_fn should arrange to reschedule the fiber when it - -- becomes runnable. - block_fn(current_scheduler, _current_fiber, ...) + block_fn(current_scheduler, assert(_current_fiber), ...) return coroutine.yield() end +--- Return the captured creation traceback for this fiber, if any. +---@return string function Fiber:get_traceback() return self.traceback or "No traceback available" end ---- Returns the current Fiber object, or nil if not inside a fiber. +--- Return the current Fiber object, or nil if not inside a fiber. +---@return Fiber|nil local function current_fiber() return _current_fiber end +--- Current scheduler time in monotonic seconds. +---@return number local function now() return current_scheduler:now() end +--- Suspend the current fiber using block_fn. +--- block_fn must arrange for the fiber to be rescheduled later. +---@param block_fn fun(scheduler: Scheduler, fiber: Fiber, ...: any) +---@param ... any +---@return any ... local function suspend(block_fn, ...) + assert(_current_fiber, "can only suspend from inside a fiber") return _current_fiber:suspend(block_fn, ...) end ---- Suspends execution of the current fiber. --- The fiber will be resumed when the scheduler is ready to run it again. --- @function yield +--- Yield the current fiber and re-queue it as runnable. +---@return any ... local function yield() assert(current_fiber(), "can only yield from inside a fiber") return suspend(function(scheduler, fiber) @@ -123,48 +160,39 @@ local function yield() end) end ---- Stops the current scheduler from running more tasks. --- @function stop +--- Request that the global scheduler stops its main loop. local function stop() current_scheduler:stop() end +--- Wait for the next uncaught fiber error. +--- Returns the failing fiber and its error value. +---@return Fiber err_fiber +---@return any err local function wait_fiber_error() - -- Fast path: if an error is already queued, return it immediately. if #error_queue > 0 then local rec = table.remove(error_queue, 1) return rec.fiber, rec.err end - -- Otherwise, we must be in a fibre and suspend until an error arrives. assert(_current_fiber, "wait_fiber_error must be called from within a fiber") local function block_fn(_, fib) - -- Record this fibre as waiting for an error. When an error - -- arrives, the failing fibre will arrange to schedule a task - -- that resumes this fibre with (fiber, err). error_waiters[#error_waiters + 1] = { fiber = fib } end local _, err_fiber, err = _current_fiber:suspend(block_fn) - -- wrap should be the identity function; ignore it. return err_fiber, err end - ---- Runs the main event loop of the current scheduler. --- The scheduler will continue to run tasks and wait for events until stopped. --- @function main +--- Run the main event loop using the global scheduler. local function main() return current_scheduler:main() end return { - -- core runtime state current_scheduler = current_scheduler, current_fiber = current_fiber, - - -- time and suspension now = now, suspend = suspend, yield = yield, @@ -172,6 +200,6 @@ return { -- fiber management spawn_raw = spawn, - stop = stop, - main = main, + stop = stop, + main = main, } diff --git a/src/fibers/sched.lua b/src/fibers/sched.lua index 5887fcb..7ad2070 100644 --- a/src/fibers/sched.lua +++ b/src/fibers/sched.lua @@ -1,44 +1,66 @@ -- fibers/sched.lua --- Use of this source code is governed by the XXXXXXXXX license; see COPYING. - ---- Core scheduler for fibre tasks. --- @module fibers.sched +--- Core cooperative scheduler for fiber tasks. +---@module 'fibers.sched' local sc = require 'fibers.utils.syscall' local timer = require 'fibers.timer' local MAX_SLEEP_TIME = 10 +--- A runnable task with a :run() method invoked by the scheduler. +---@class Task +---@field run fun(self: Task) + +--- A source of tasks (timers, pollers, etc.) that can enqueue work on a scheduler. +---@class TaskSource +---@field schedule_tasks fun(self: TaskSource, sched: Scheduler, now: number) +---@field cancel_all_tasks fun(self: TaskSource, sched: Scheduler)|nil +---@field wait_for_events fun(self: TaskSource, sched: Scheduler, now: number, timeout: number)|nil + +--- Main scheduler state and API. +---@class Scheduler +---@field next Task[] # tasks runnable next turn +---@field cur Task[] # tasks being run this turn +---@field sources TaskSource[] # timer, poller, etc. +---@field wheel Timer # timer wheel using the same clock +---@field maxsleep number # maximum sleep interval in seconds +---@field get_time fun(): number # monotonic time source +---@field event_waiter TaskSource|nil # single source used for blocking waits (if any) +---@field done boolean local Scheduler = {} Scheduler.__index = Scheduler ---- Create a new scheduler. --- @tparam[opt] function get_time monotonic time source (defaults to sc.monotime) +--- Create a new scheduler instance. +---@param get_time? fun(): number # monotonic time source (defaults to sc.monotime) +---@return Scheduler local function new(get_time) local now_src = get_time or sc.monotime local now = now_src() local ret = setmetatable({ - next = {}, -- tasks runnable next turn - cur = {}, -- tasks being run this turn - sources = {}, -- timer, poller, etc. - wheel = timer.new(now), -- timer wheel on same clock + next = {}, + cur = {}, + sources = {}, + wheel = timer.new(now), maxsleep = MAX_SLEEP_TIME, get_time = now_src, - event_waiter = nil, -- optional poller + event_waiter = nil, done = false, }, Scheduler) - -- Timer source: advances wheel and schedules due tasks. + --- Timer source: advances the wheel and schedules due tasks. + ---@class TimerTaskSource : TaskSource + ---@field wheel Timer local timer_task_source = { wheel = ret.wheel } + --- Advance the timer wheel and schedule any due tasks. + ---@param sched Scheduler + ---@param now_ number function timer_task_source:schedule_tasks(sched, now_) self.wheel:advance(now_, sched) end - -- Timers are not cleared; future timers simply never run once - -- the scheduler stops. function timer_task_source:cancel_all_tasks() end @@ -46,8 +68,11 @@ local function new(get_time) return ret end ---- Register a task source. --- A source must support :schedule_tasks(sched, now). +--- Register a task source with this scheduler. +--- A source must implement :schedule_tasks(sched, now). +--- If the source implements :wait_for_events, it becomes the scheduler's +--- sole event waiter (overwriting any previous one). +---@param source TaskSource function Scheduler:add_task_source(source) table.insert(self.sources, source) if source.wait_for_events then @@ -55,39 +80,49 @@ function Scheduler:add_task_source(source) end end ---- Schedule a task object with a :run() method. +--- Schedule a task to be run on the next turn. +---@param task Task function Scheduler:schedule(task) table.insert(self.next, task) end +--- Get current monotonic time from the scheduler's clock source. +---@return number function Scheduler:monotime() return self.get_time() end ---- Last time seen by the timer wheel. +--- Get the last time observed by the timer wheel. +---@return number function Scheduler:now() return self.wheel.now end ---- Schedule at an absolute time. +--- Schedule a task at an absolute time. +---@param t number # absolute time on the scheduler clock +---@param task Task function Scheduler:schedule_at_time(t, task) self.wheel:add_absolute(t, task) end ---- Schedule after a delay from the wheel's current time. +--- Schedule a task after a delay from the wheel's current time. +---@param dt number # delay in seconds +---@param task Task function Scheduler:schedule_after_sleep(dt, task) self.wheel:add_delta(dt, task) end ---- Ask all sources to queue ready tasks. +--- Ask all registered sources to enqueue any ready tasks. +---@param now number function Scheduler:schedule_tasks_from_sources(now) for i = 1, #self.sources do self.sources[i]:schedule_tasks(self, now) end end ---- Run all tasks currently scheduled. --- If now is nil, uses monotonic time. +--- Run all tasks currently scheduled as runnable. +--- If now is nil, the current monotonic time is used. +---@param now? number function Scheduler:run(now) if now == nil then now = self:monotime() @@ -104,10 +139,10 @@ function Scheduler:run(now) end end ---- Time of the next thing that may need attention. --- If there are runnable tasks, returns now() (i.e. do not sleep). --- Otherwise delegates to the timer wheel, which returns either a time --- or math.huge when empty. +--- Compute the next time the scheduler may need to wake. +--- If there are runnable tasks, returns now() (do not sleep). +--- Otherwise defers to the timer wheel, which returns a time or math.huge. +---@return number function Scheduler:next_wake_time() if #self.next > 0 then return self:now() @@ -116,7 +151,7 @@ function Scheduler:next_wake_time() end --- Block until the next event or timeout. --- Uses an event_waiter (poller) if present, otherwise sleeps. +--- Uses an event_waiter (e.g. poller) if present, otherwise sleeps. function Scheduler:wait_for_events() local now = self:monotime() local next_time = self:next_wake_time() @@ -133,12 +168,13 @@ function Scheduler:wait_for_events() end end ---- Stop the main loop. +--- Request that the scheduler main loop stops after the current iteration. function Scheduler:stop() self.done = true end ---- Main event loop. +--- Run the scheduler main loop until stopped. +--- Repeatedly waits for events and runs ready tasks. function Scheduler:main() self.done = false repeat @@ -147,9 +183,11 @@ function Scheduler:main() until self.done end ---- Attempt to drain runnable work and ask sources to cancel. --- Does not clear timers; future timers remain queued but will never fire --- once the scheduler is no longer driven. +--- Attempt to drain runnable work and ask sources to cancel outstanding tasks. +--- Sources are given an opportunity to cancel pending work; the scheduler +--- continues to drive sources (including timers) while draining. +--- Returns true if the runnable queue is drained within the iteration limit. +---@return boolean drained # true if work queue drained, false on iteration limit function Scheduler:shutdown() for _ = 1, 100 do for i = 1, #self.sources do @@ -169,5 +207,5 @@ function Scheduler:shutdown() end return { - new = new + new = new, } diff --git a/src/fibers/scope.lua b/src/fibers/scope.lua index 54b61d4..b4521ed 100644 --- a/src/fibers/scope.lua +++ b/src/fibers/scope.lua @@ -1,77 +1,67 @@ --- --- Scope module (structured concurrency). +-- Structured concurrency scopes. -- --- Scopes form a tree of supervision domains. --- --- Semantics: --- - A single root scope exists for the process. --- - Each fibre has an associated current scope (defaulting to root). --- - scope.current() returns the current scope for the fibre, --- or the process-wide current scope when not in a fibre. --- - scope.run(fn, ...) runs fn in a fresh child scope in its *own* --- fibre and then returns (status, error, ...body_results). --- - s:spawn(fn, ...) spawns a new fibre whose current scope is s --- for the duration of fn. --- - scope.with_ev(build_ev) creates a first-class Event that --- represents running a child scope whose body is build_ev(child). --- --- Status model: --- - "running" : scope is active; children may be running. --- - "ok" : all fibres finished without uncaught errors and --- no explicit cancellation. --- - "failed" : at least one fibre ended with an uncaught error. --- - "cancelled" : explicit cancellation, or abort via with_ev. --- --- Failure handling: --- - Uncaught Lua errors from any fibre are reported by runtime. --- - The scope owning that fibre records a failure and cancels --- its children (fail-fast). --- - Callers observe this via status(), join_ev(), run(), etc. --- --- Cancellation in events: --- - Scope:run_ev(ev) races ev against a cancellation event. --- - If cancellation wins, the event result is: --- ok = false, --- value1 = reason, --- value2 = nil. --- --- @module fibers.scope +-- Scopes form a tree of supervision domains with fail-fast semantics. +-- Each fiber runs within a current scope; cancellation and failures +-- are tracked per-scope and propagated to children. +---@module 'fibers.scope' local runtime = require 'fibers.runtime' local op = require 'fibers.op' local waitgroup = require 'fibers.waitgroup' -local cond = require 'fibers.cond' - -local safe = require 'coxpcall' +local cond = require 'fibers.cond' +local safe = require 'coxpcall' local unpack = rawget(table, "unpack") or _G.unpack local pack = rawget(table, "pack") or function(...) return { n = select("#", ...), ... } end +---@alias ScopeStatus "running"|"ok"|"failed"|"cancelled" + +--- Supervision scope for structured concurrency. +---@class Scope +---@field _parent Scope|nil +---@field _children table # weak-key set of child scopes +---@field _status ScopeStatus +---@field _error any +---@field _failures any[] +---@field failure_mode "fail_fast" # reserved for future modes +---@field _wg Waitgroup +---@field _defers fun(self: Scope)[] # LIFO defers +---@field _cancel_cond Cond +---@field _join_cond Cond +---@field _join_worker_started boolean +---@field _result table|nil # packed results used by run() local Scope = {} Scope.__index = Scope -- Weak-keyed table mapping Fiber objects to their current Scope. +---@type table local fiber_scopes = setmetatable({}, { __mode = "k" }) --- Process-wide root scope and “current scope” when *not* in a fibre. +-- Process-wide root scope and “current scope” when not in a fiber. +---@type Scope|nil local root_scope +---@type Scope|nil local global_scope --- Handler for uncaught errors from fibres not associated with any Scope. --- Default policy: treat them as failures of the root scope. -local function default_unscoped_error_handler(fib, err) +-- Handler for uncaught errors from fibers not associated with any Scope. +---@param _ any +---@param err any +local function default_unscoped_error_handler(_, err) if root_scope then root_scope:_record_failure(err) else - -- Root not yet initialised: conservative fallback is to log. - io.stderr:write("Unscoped fibre error before root initialised: " .. tostring(err) .. "\n") + io.stderr:write("Unscoped fiber error before root initialised: " .. tostring(err) .. "\n") end end +---@type fun(fib: any, err: any) local unscoped_error_handler = default_unscoped_error_handler +--- Set the handler for uncaught errors in fibers that have no scope. +---@param handler fun(fib: Fiber, err: any) local function set_unscoped_error_handler(handler) assert(type(handler) == "function", "unscoped error handler must be a function") unscoped_error_handler = handler @@ -81,61 +71,56 @@ end -- Internal helpers ---------------------------------------------------------------------- --- Internal: current fibre object, or nil if not in a fibre. +--- Internal: current fiber object, or nil if not in a fiber. +---@return Fiber|nil local function current_fiber() return runtime.current_fiber() end --- Internal: create a new Scope with the given parent. +--- Internal: create a new Scope with the given parent. +---@param parent Scope|nil +---@return Scope local function new_scope(parent) local s = setmetatable({ _parent = parent, - _children = setmetatable({}, { __mode = "k" }), -- weak keys + _children = setmetatable({}, { __mode = "k" }), - -- Status and failure tracking _status = "running", _error = nil, _failures = {}, failure_mode = "fail_fast", - -- Concurrency tracking _wg = waitgroup.new(), _defers = {}, - -- Cancellation and join conditions _cancel_cond = cond.new(), _join_cond = cond.new(), - _join_worker_started = false, }, Scope) if parent then - -- store child as a weak key parent._children[s] = true end return s end ---- Return the process-wide root scope. +--- Return the process-wide root scope, creating it if needed. +---@return Scope local function root() if not root_scope then root_scope = new_scope(nil) global_scope = root_scope + -- Error pump: attribute uncaught fiber errors to scopes. runtime.spawn_raw(function() while true do local fib, err = runtime.wait_fiber_error() local s = fiber_scopes[fib] if s then - -- Fibres that were not started via Scope:spawn but - -- have an associated scope (e.g. internal helpers) - -- can still be treated as scope failures. s:_record_failure(err) - -- Note: NO _wg:done() here. else - -- Completely unscoped fibres go to the global handler. unscoped_error_handler(fib, err) end end @@ -145,8 +130,9 @@ local function root() end --- Return the current Scope. --- Inside a fibre: the fibre's mapped scope, or the root if none. --- Outside a fibre: the process-wide current scope, defaulting to root. +--- Inside a fiber: the fiber's scope or the root if none. +--- Outside a fiber: the process-wide current scope, defaulting to root. +---@return Scope local function current() local fib = current_fiber() if fib then @@ -160,38 +146,37 @@ end ---------------------------------------------------------------------- --- Internal: record a failure in this scope and cancel children. +---@param err any function Scope:_record_failure(err) if self._status == "running" then self._status = "failed" self._error = self._error or err - -- Failure implies cancellation of children and done_ev. self:_propagate_cancel(self._error) else - -- already "cancelled" or "failed": just record it local failures = self._failures failures[#failures + 1] = err end end --- Create a new child scope of this scope (no body, no current() change). +---@return Scope function Scope:new_child() return new_scope(self) end ---- Register a deferred handler to run at scope close (LIFO). --- handler :: function(Scope) +--- Register a deferred handler to run when the scope closes (LIFO). +---@param handler fun(self: Scope) function Scope:defer(handler) local defers = self._defers defers[#defers + 1] = handler end +---@param reason any|nil function Scope:_propagate_cancel(reason) local r = reason or self._error or "scope cancelled" - -- Wake done_ev waiters. self._cancel_cond:signal() - -- Propagate to children (weak-key set). local children = self._children for child in pairs(children) do if child then @@ -201,9 +186,9 @@ function Scope:_propagate_cancel(reason) end --- Cancel this scope and its children with an optional reason. --- Idempotent: multiple calls are safe. +--- Idempotent; subsequent calls after terminal success are ignored. +---@param reason any|nil function Scope:cancel(reason) - -- Once a scope is "ok", it is terminal: ignore. if self._status == "ok" then return end local r = reason or self._error or "scope cancelled" @@ -213,28 +198,14 @@ function Scope:cancel(reason) if self._error == nil then self._error = r end - -- Only now do we notify others. self:_propagate_cancel(r) end - - -- For "failed" or already "cancelled" scopes we do nothing here. - -- Their propagation is handled by _record_failure or the first cancel. end ---- Spawn a child fibre attached to this scope. --- fn :: function(Scope, ...): () --- ... :: arguments passed to fn --- --- Behaviour: --- * The new fibre inherits this scope as its current scope. --- * The scope’s waitgroup is incremented on creation and decremented --- when the fibre finishes, whether normally or due to an error. --- * Any uncaught error in fn is caught by the scope machinery, --- recorded as a failure of the scope that was current at the point --- of error, and does not propagate as a Lua error. --- --- The scope enters “failed” status on the first such error, with --- cancellation propagated to any child scopes. +--- Spawn a child fiber attached to this scope. +--- The fiber runs fn(self, ...) with this scope as its current scope. +---@param fn fun(s: Scope, ...): any +---@param ... any function Scope:spawn(fn, ...) assert(self._status == "running", "cannot spawn on a non-running scope") local args = pack(...) @@ -245,35 +216,32 @@ function Scope:spawn(fn, ...) local prev = fib and fiber_scopes[fib] or nil if fib then - -- Dynamic current scope for this fibre. fiber_scopes[fib] = self end - -- Run user code under this scope; catch uncaught errors. local ok, err = safe.pcall(fn, self, unpack(args, 1, args.n)) if not ok then - -- Attribute failure to the scope that was current at the point of error. local s = fib and (fiber_scopes[fib] or self) or self s:_record_failure(err) end - -- Restore previous dynamic scope mapping. if fib then fiber_scopes[fib] = prev end - -- Lifetime accounting: this fibre always belonged to `self`. self._wg:done() end) end --- Return this scope's parent, or nil for the root scope. +---@return Scope|nil function Scope:parent() return self._parent end ---- Return a shallow copy of this scope's children array. +--- Return a shallow copy of this scope's children. +---@return Scope[] function Scope:children() local out = {} local ch = self._children or {} @@ -285,14 +253,15 @@ function Scope:children() return out end ---- Return this scope's status and primary error. --- status :: "running" | "ok" | "failed" | "cancelled" --- err :: any | nil +--- Return this scope's status and primary error (if any). +---@return ScopeStatus status +---@return any err function Scope:status() return self._status, self._error end ---- Return a shallow copy of additional failures. +--- Return a shallow copy of additional failures recorded on this scope. +---@return any[] function Scope:failures() local out = {} local f = self._failures or {} @@ -303,36 +272,29 @@ function Scope:failures() end ---------------------------------------------------------------------- --- Join and done events +-- Join and done ops ---------------------------------------------------------------------- --- Internal: start a join worker fibre that: --- - waits for this scope's waitgroup to reach zero; --- - sets final status to "ok" if still "running"; --- - runs defers; and --- - signals _join_cond. +-- Internal: start a join worker that awaits children, runs defers and +-- finalises status, then signals _join_cond. function Scope:_start_join_worker() if self._join_worker_started then return end self._join_worker_started = true runtime.spawn_raw(function() - -- Attach this worker to the scope - local fib = current_fiber() + local fib = current_fiber() local prev = fib and fiber_scopes[fib] if fib then fiber_scopes[fib] = self end - -- Wait for child fibres of this scope to complete. op.perform_raw(self._wg:wait_op()) - -- If still running and not cancelled/failed, mark as ok. if self._status == "running" then self._status = "ok" self._error = nil end - -- Run defers in LIFO order, protected. local defers = self._defers for i = #defers, 1, -1 do local f = defers[i] @@ -340,7 +302,6 @@ function Scope:_start_join_worker() local ok, err = safe.pcall(f, self) if not ok then - -- Treat defer failures as scope failures, but do not crash process. if self._status == "ok" then self._status = "failed" self._error = self._error or err @@ -351,19 +312,18 @@ function Scope:_start_join_worker() end end - -- Restore previous scope mapping for this fibre if fib then fiber_scopes[fib] = prev end - -- Signal join completion. self._join_cond:signal() end) end ---- Event that fires once the scope has reached a terminal status. --- Returns (status, error) when synchronised. -function Scope:join_ev() +--- Op that fires once the scope has reached a terminal status. +--- Returns (status, error) when performed. +---@return Op +function Scope:join_op() self:_start_join_worker() local ev = self._join_cond:wait_op() return ev:wrap(function() @@ -371,9 +331,10 @@ function Scope:join_ev() end) end ---- Event that fires when the scope is cancelled or fails. --- Returns the cancellation/failure reason when synchronised. -function Scope:done_ev() +--- Op that fires when the scope is cancelled or fails. +--- Returns the cancellation or failure reason when performed. +---@return Op +function Scope:done_op() local ev = self._cancel_cond:wait_op() return ev:wrap(function() return self._error or "scope cancelled" @@ -381,82 +342,75 @@ function Scope:done_ev() end ---------------------------------------------------------------------- --- Failure + cancellation wrapping for Events +-- Failure and cancellation wrapping for Ops ---------------------------------------------------------------------- --- Internal: cancellation event used when running events under this scope. --- Convention for cancellable events: --- ok:boolean, value1_or_reason, value2 -local function cancel_event(self) +--- Internal: build a cancellation op for this scope. +--- The result convention is (ok:boolean, value1_or_reason, value2). +---@param self Scope +---@return Op +local function cancel_op(self) local ev = self._cancel_cond:wait_op() return ev:wrap(function() return false, self._error or "scope cancelled", nil end) end ---- Transform an event to obey this scope's failure and cancellation policy. --- Returns a new Event; does not perform it. -function Scope:run_ev(ev) - -- Build a fresh event per synchronisation, so we can look at the - -- scope status at sync time rather than at definition time. +--- Wrap an op so that it observes this scope's cancellation and failure state. +--- Returns a new Op; does not perform it. +---@param ev Op +---@return Op +function Scope:run_op(ev) return op.guard(function() local status, err = self:status() - -- If not running, short-circuit to cancellation result if status ~= "running" then return op.always(false, err or "scope cancelled", nil) end - -- Normal path - local cancel_ev = cancel_event(self) - return op.choice(ev, cancel_ev) + local this_cancel_op = cancel_op(self) + return op.choice(ev, this_cancel_op) end) end ---- Synchronise on an event under this scope. --- Equivalent to op.perform_raw(self:run_ev(ev)). +--- Perform an op under this scope, obeying its cancellation rules. +--- The result convention is (ok, value1, value2): +--- - ok == true if the op completed and the scope remained running/ok +--- - ok == false if the scope had already failed/cancelled, or fails/ +--- cancels before the call returns; in that case value1 +--- is the failure/cancellation reason and value2 is nil. +---@param ev Op +---@return boolean ok +---@return any ... function Scope:sync(ev) - -- Scope-level synchronisation only occurs from inside a fiber assert(runtime.current_fiber(), "scope:sync must be called from inside a fiber (use fibers.run as an entry point)") - -- Fast pre-check: do not start new work on a non-running scope. local status, err = self:status() if status ~= "running" then return false, err or "scope cancelled", nil end - -- Perform the cancellable event - local results = pack(op.perform_raw(self:run_ev(ev))) + local results = pack(op.perform_raw(self:run_op(ev))) - -- Re-check the scope status once the event has completed. status, err = self:status() - -- If scope has failed/cancelled )treat as authoritative if status ~= "running" and status ~= "ok" then return false, err or "scope cancelled", nil end - -- Scope still "running" or "ok" return unpack(results, 1, results.n) end ---------------------------------------------------------------------- --- Scope as an *event*: scope.with_ev +-- Scope as an op: with_op ---------------------------------------------------------------------- ---- Event-level API: create a child scope whose body is an Event. --- --- build_ev :: function(child_scope :: Scope) -> Event --- --- The returned Event, when performed, will: --- * create a child scope of scope.current(); --- * install it as the current scope while build_ev runs; --- * run build_ev(child) as an Event under normal CML semantics --- (whoever performs this Event controls cancellation etc.); --- * on conclusion or abort, wait for child fibres, run defers, --- and signal join_ev(); --- * propagate the inner Event's result or error as usual. -local function with_ev(build_ev) +--- Create an Op that runs a child scope whose body is an Op. +--- build_op(child_scope) must return an Op; the child scope is current() +--- for the duration of the body. +---@param build_op fun(child_scope: Scope): Op +---@return Op +local function with_op(build_op) return op.guard(function() local parent = current() local child = new_scope(parent) @@ -474,30 +428,26 @@ local function with_ev(build_ev) end end + ---@param token { kind: "fiber"|"global", fib?: any, prev: Scope } + ---@param aborted boolean local function release(token, aborted) - -- Restore the previous current scope. if token.kind == "fiber" then fiber_scopes[token.fib] = token.prev else global_scope = token.prev end - -- If the event was aborted and the scope is still running, - -- treat that as cancellation of the scope. if aborted and child._status == "running" then child._status = "cancelled" child._error = child._error or "scope aborted" child:cancel(child._error) end - -- Ensure the child scope is closed and defers run. - op.perform_raw(child:join_ev()) + op.perform_raw(child:join_op()) end local function use() - -- Here the child is already installed as current(). - -- build_ev must return an Event, and must not perform it. - return build_ev(child) + return build_op(child) end return op.bracket(acquire, release, use) @@ -505,46 +455,35 @@ local function with_ev(build_ev) end ---------------------------------------------------------------------- --- scope.run: run a child scope in its own fibre +-- scope.run: run a child scope in its own fiber ---------------------------------------------------------------------- --- Run a function inside a fresh child scope of the current scope. --- --- body_fn :: function(Scope, ...): ...results... --- --- Behaviour: --- * A child scope of scope.current() is created. --- * body_fn is run in a *separate fibre* with that child as the --- dynamic current scope. --- * All fibres spawned under that child are tracked via its waitgroup. --- * Uncaught errors in body_fn (or its descendant fibres) are recorded --- as failures of the child scope but do not escape as Lua errors. --- * When the child scope reaches a terminal state (ok/failed/cancelled) --- and its defers have run, this function returns: --- status, error, ...results_from_body_fn... --- --- status :: "ok" | "failed" | "cancelled" --- error :: primary error / cancellation reason, or nil on "ok". --- --- The caller receives status information only; failure or cancellation --- does not raise a Lua error here. +--- +--- The body runs as body_fn(child_scope, ...). +--- Returns: +--- status :: "ok" | "failed" | "cancelled" +--- err :: primary error or cancellation reason (nil on "ok") +--- ... :: any results returned from body_fn +---@param body_fn fun(s: Scope, ...): ... +---@param ... any +---@return ScopeStatus status +---@return any err +---@return any ... local function run(body_fn, ...) assert(runtime.current_fiber(), "scope.run must be called from inside a fiber") local parent = current() local child = new_scope(parent) local args = pack(...) - -- store body results on the child scope child._result = nil - -- body fibre under the child scope child:spawn(function(s) local res = pack(body_fn(s, unpack(args, 1, args.n))) s._result = res end) - -- wait for the child scope to reach a terminal state - local status, err = op.perform_raw(child:join_ev()) + local status, err = op.perform_raw(child:join_op()) local res = child._result if res then @@ -562,7 +501,7 @@ return { root = root, current = current, run = run, - with_ev = with_ev, + with_op = with_op, Scope = Scope, set_unscoped_error_handler = set_unscoped_error_handler, } diff --git a/src/fibers/sleep.lua b/src/fibers/sleep.lua index 47d5041..5a21870 100644 --- a/src/fibers/sleep.lua +++ b/src/fibers/sleep.lua @@ -1,49 +1,56 @@ -- Use of this source code is governed by the Apache 2.0 license; see COPYING. ---- fibers.sleep module. --- Provides functions to suspend execution of fibers for a certain duration (sleep) or until a specific time. --- @module fibers.sleep +--- Sleep operations for fibers. +--- Provides ops and helpers for suspending fibers for a duration or until a deadline. +---@module 'fibers.sleep' -local op = require 'fibers.op' +local op = require 'fibers.op' local runtime = require 'fibers.runtime' local perform = require 'fibers.performer'.perform --- Primitive: wait until absolute time `t`. +--- Primitive op that becomes ready when the absolute time t is reached. +---@param t number # absolute time on the runtime clock +---@return Op local function deadline_op(t) local function try() return runtime.now() >= t end + + --- Schedule completion of the suspension at time t. + ---@param suspension Suspension + ---@param wrap_fn WrapFn local function block(suspension, wrap_fn) suspension.sched:schedule_at_time(t, suspension:complete_task(wrap_fn)) end + return op.new_primitive(nil, try, block) end ---- Create a new operation that puts the current fiber to sleep until the time t. --- @tparam number t The time to sleep until. --- @treturn operation The created operation. +--- Op that sleeps until absolute time t. +---@param t number # absolute time on the runtime clock +---@return Op local function sleep_until_op(t) return deadline_op(t) end ---- Put the current fiber to sleep until time t. --- @tparam number t The time to sleep until. +--- Sleep until absolute time t. +---@param t number # absolute time on the runtime clock local function sleep_until(t) return perform(sleep_until_op(t)) end ---- Create a new operation that puts the current fiber to sleep for a duration dt. --- @tparam number dt The duration to sleep. --- @treturn operation The created operation. +--- Op that sleeps for a duration dt. +---@param dt number # delay in seconds +---@return Op local function sleep_op(dt) return op.guard(function() return deadline_op(runtime.now() + dt) end) end ---- Put the current fiber to sleep for a duration dt. --- @tparam number dt The duration to sleep. +--- Sleep for a duration dt. +---@param dt number # delay in seconds local function sleep(dt) return perform(sleep_op(dt)) end diff --git a/src/fibers/timer.lua b/src/fibers/timer.lua index 1645171..95e4786 100644 --- a/src/fibers/timer.lua +++ b/src/fibers/timer.lua @@ -1,27 +1,33 @@ --- (c) Jangala --- --- Use of this source code is governed by the XXXXXXXXX license; see COPYING. +-- fibers/timer.lua ---- Binary-heap timer. --- @module fibers.timer +--- Monotonic timer built on a binary min-heap. +---@module 'fibers.timer' -local sc = require 'fibers.utils.syscall' +---@class TimerNode +---@field time number # absolute due time (monotonic seconds) +---@field obj any # scheduled payload --- Simple min-heap keyed by node.time. -local BinaryHeap = {} -BinaryHeap.__index = BinaryHeap - -function BinaryHeap:new() - return setmetatable({ heap = {}, size = 0 }, BinaryHeap) +---@class Heap +---@field heap TimerNode[] +---@field size integer +local Heap = {} +Heap.__index = Heap + +---@return Heap +local function new_heap() + return setmetatable({ heap = {}, size = 0 }, Heap) end -function BinaryHeap:push(node) +---@param node TimerNode +function Heap:push(node) self.size = self.size + 1 self.heap[self.size] = node self:heapify_up(self.size) end -function BinaryHeap:pop() +---@return TimerNode|nil +function Heap:pop() if self.size == 0 then return nil end @@ -40,7 +46,8 @@ function BinaryHeap:pop() return root end -function BinaryHeap:heapify_up(idx) +---@param idx integer +function Heap:heapify_up(idx) if idx <= 1 then return end local parent = math.floor(idx / 2) if self.heap[parent].time > self.heap[idx].time then @@ -49,7 +56,8 @@ function BinaryHeap:heapify_up(idx) end end -function BinaryHeap:heapify_down(idx) +---@param idx integer +function Heap:heapify_down(idx) local smallest = idx local left = 2 * idx local right = 2 * idx + 1 @@ -66,29 +74,41 @@ function BinaryHeap:heapify_down(idx) end end ---- Timer built on top of BinaryHeap. +---@class Scheduler +---@field schedule fun(self: Scheduler, obj: any) # called when a timer fires + +--- Monotonic timer used by the scheduler. +---@class Timer +---@field now number # current timer time (monotonic seconds) +---@field heap Heap local Timer = {} Timer.__index = Timer ---- Create a new timer. --- @tparam[opt] number now initial time (defaults to sc.monotime()). +--- Create a new timer instance. +---@param now? number # initial monotonic time. +---@return Timer local function new(now) - now = now or sc.monotime() - return setmetatable({ now = now, heap = BinaryHeap:new() }, Timer) + assert(now) + return setmetatable({ now = now, heap = new_heap() }, Timer) end ---- Schedule at absolute time t. +--- Schedule an object at absolute time t. +---@param t number # absolute due time (same clock as sc.monotime) +---@param obj any # payload to pass to the scheduler function Timer:add_absolute(t, obj) self.heap:push({ time = t, obj = obj }) end ---- Schedule after delay dt from current timer time. +--- Schedule an object after a delay from the current timer time. +---@param dt number # delay in seconds from self.now +---@param obj any # payload to pass to the scheduler function Timer:add_delta(dt, obj) return self:add_absolute(self.now + dt, obj) end ---- Time of next entry, or math.huge if none. --- This is the only API relied on by the scheduler. +--- Get the time of the next scheduled entry, or math.huge if none exist. +--- This is the only method used by the scheduler to determine wake-up time. +---@return number function Timer:next_entry_time() if self.heap.size == 0 then return math.huge @@ -96,17 +116,20 @@ function Timer:next_entry_time() return self.heap.heap[1].time end ---- Low-level pop of the next node. --- Returns { time = ..., obj = ... } or nil. --- Not used by the scheduler; kept for possible callers that want manual control. +--- Pop the next scheduled entry without dispatching it. +--- Returns the earliest TimerNode or nil if the timer is empty. +---@return TimerNode|nil function Timer:pop() return self.heap:pop() end ---- Advance to time t, scheduling all entries <= t on sched. +--- Advance the timer to time t and dispatch all due entries. +--- For each entry with time <= t, sched:schedule(node.obj) is invoked. +---@param t number # new monotonic time +---@param sched Scheduler # scheduler that receives due objects function Timer:advance(t, sched) while self.heap.size > 0 and t >= self.heap.heap[1].time do - local node = self.heap:pop() + local node = assert(self.heap:pop()) self.now = node.time sched:schedule(node.obj) end @@ -114,5 +137,5 @@ function Timer:advance(t, sched) end return { - new = new + new = new, } diff --git a/src/fibers/waitgroup.lua b/src/fibers/waitgroup.lua index 1c19421..43b64b5 100644 --- a/src/fibers/waitgroup.lua +++ b/src/fibers/waitgroup.lua @@ -1,11 +1,23 @@ -- fibers/waitgroup.lua -local op = require 'fibers.op' -local perform = require 'fibers.performer'.perform -local cond_mod = require 'fibers.cond' +--- +-- Wait group for tracking completion of a set of tasks. +-- A waitgroup supports generations: when the counter returns to zero, +-- the current generation completes and a new one starts on the next increment. +---@module 'fibers.waitgroup' +local op = require 'fibers.op' +local perform = require 'fibers.performer'.perform +local cond_mod = require 'fibers.cond' + +--- Waitgroup with a counter and per-generation condition. +---@class Waitgroup +---@field _counter integer +---@field _cond Cond|nil # per-generation condition; nil when there is no active generation local Waitgroup = {} Waitgroup.__index = Waitgroup +--- Create a new waitgroup. +---@return Waitgroup local function new() return setmetatable({ _counter = 0, @@ -13,6 +25,9 @@ local function new() }, Waitgroup) end +--- Adjust the waitgroup counter by delta. +--- When the counter returns to zero, the current generation completes. +---@param delta integer function Waitgroup:add(delta) if delta == 0 then return @@ -28,23 +43,26 @@ function Waitgroup:add(delta) self._counter = new_count if new_count == 0 then - -- This generation completes: wake any waiters and drop the cond. + -- This generation completes: wake any waiters and drop the condition. if self._cond then self._cond:signal() self._cond = nil end elseif old_count == 0 and new_count > 0 then - -- Starting a new generation: new condition for new work. + -- Starting a new generation: create a condition for new work. self._cond = cond_mod.new() end end +--- Decrement the waitgroup counter by one. function Waitgroup:done() self:add(-1) end +--- Build an Op that completes when the current generation drains. +---@return Op function Waitgroup:wait_op() - -- Build the event lazily at sync time. + -- Build the op lazily at perform time. return op.guard(function() -- If there is nothing outstanding, fire immediately. if self._counter == 0 then @@ -53,15 +71,18 @@ function Waitgroup:wait_op() -- Active generation: delegate to the generation's condition. local cond = assert(self._cond, "waitgroup internal error: missing condition for active generation") - return cond:wait_op() end) end +--- Block until the current generation completes. +---@return any ... function Waitgroup:wait() return perform(self:wait_op()) end return { + --- Construct a new waitgroup. + ---@return Waitgroup new = new, } diff --git a/tests/test_channel.lua b/tests/test_channel.lua index 1258e6d..6afdf92 100644 --- a/tests/test_channel.lua +++ b/tests/test_channel.lua @@ -66,7 +66,7 @@ local function test_unbounded() -- At this point, there is no value available, so get() must have blocked. assert(blocked, "get should block") - -- Now provide a value so the spawned fibre can complete. + -- Now provide a value so the spawned fiber can complete. chan:put(123) print("Unbounded passed") diff --git a/tests/test_scope.lua b/tests/test_scope.lua index b10d378..e928bfc 100644 --- a/tests/test_scope.lua +++ b/tests/test_scope.lua @@ -17,8 +17,8 @@ local cond_mod = require "fibers.cond" local function test_outside_fibers() local root = scope.root() - -- current() outside any fibre should be the root (process-wide current scope) - assert(scope.current() == root, "outside fibres, current() should be root") + -- current() outside any fiber should be the root (process-wide current scope) + assert(scope.current() == root, "outside fibers, current() should be root") local outer_scope local inner_scope @@ -71,8 +71,8 @@ local function test_outside_fibers() assert(inner_scope ~= nil, "inner_scope should have been set") assert(outer_scope ~= inner_scope, "outer and inner scopes must differ") - -- After scope.run returns, current() outside fibres should be root again - assert(scope.current() == scope.root(), "after scope.run, current() should be root outside fibres") + -- After scope.run returns, current() outside fibers should be root again + assert(scope.current() == scope.root(), "after scope.run, current() should be root outside fibers") end local function test_inside_fibers() @@ -81,57 +81,57 @@ local function test_inside_fibers() local child_in_fiber local grandchild_in_fiber - -- Use a cond to wait for the spawned fibre to finish. + -- Use a cond to wait for the spawned fiber to finish. local done = cond_mod.new() - -- Spawn a fibre anchored to the root scope. + -- Spawn a fiber anchored to the root scope. root:spawn(function(s) - -- In this fibre, s is the scope used for spawn -> root + -- In this fiber, s is the scope used for spawn -> root assert(s == root, "spawn(fn) on root should pass root as scope") - assert(scope.current() == root, "inside spawned fibre, current() should be root initially") + assert(scope.current() == root, "inside spawned fiber, current() should be root initially") - -- Create a child scope inside the fibre + -- Create a child scope inside the fiber local st, err = scope.run(function(child) child_in_fiber = child - assert(scope.current() == child, "inside scope.run in fibre, current() should be child") - assert(child:parent() == root, "child-in-fibre parent must be root") + assert(scope.current() == child, "inside scope.run in fiber, current() should be child") + assert(child:parent() == root, "child-in-fiber parent must be root") -- Create a grandchild scope local st2, err2 = scope.run(function(grandchild) grandchild_in_fiber = grandchild - assert(scope.current() == grandchild, "inside nested run in fibre, current() should be grandchild") + assert(scope.current() == grandchild, "inside nested run in fiber, current() should be grandchild") assert(grandchild:parent() == child, "grandchild parent must be child") end) assert(st2 == "ok" and err2 == nil, - "nested scope.run in fibre should complete with status ok") + "nested scope.run in fiber should complete with status ok") -- After nested run, current() should be back to child - assert(scope.current() == child, "after nested run in fibre, current() should be child again") + assert(scope.current() == child, "after nested run in fiber, current() should be child again") end) assert(st == "ok" and err == nil, - "scope.run in fibre should complete with status ok") + "scope.run in fiber should complete with status ok") - -- After inner run, current() should be back to root for this fibre - assert(scope.current() == root, "after scope.run in fibre, current() should be root again") + -- After inner run, current() should be back to root for this fiber + assert(scope.current() == root, "after scope.run in fiber, current() should be root again") done:signal() end) - -- Drive until the child fibre finishes. + -- Drive until the child fiber finishes. performer.perform(done:wait_op()) - -- After that, we are still inside the test fibre; current() should be root. - assert(scope.current() == root, "after inner fibre completes, current() should be root in test fibre") + -- After that, we are still inside the test fiber; current() should be root. + assert(scope.current() == root, "after inner fiber completes, current() should be root in test fiber") - -- Check that scopes created inside the fibre were recorded + -- Check that scopes created inside the fiber were recorded assert(child_in_fiber ~= nil, "child_in_fiber should have been set") assert(grandchild_in_fiber ~= nil, "grandchild_in_fiber should have been set") assert(child_in_fiber:parent() == root, "child_in_fiber parent must be root") assert(grandchild_in_fiber:parent() == child_in_fiber, "grandchild_in_fiber parent must be child_in_fiber") - -- Check that root children include the child created in this fibre. + -- Check that root children include the child created in this fiber. local rc = root:children() local found_child = false for _, s in ipairs(rc) do @@ -144,18 +144,18 @@ local function test_inside_fibers() end ------------------------------------------------------------------------------- --- 1b. basic scope.with_ev behaviour +-- 1b. basic scope.with_op behaviour ------------------------------------------------------------------------------- -local function test_with_ev_basic() +local function test_with_op_basic() local parent = scope.current() local child_scope - local ev = scope.with_ev(function(child) + local ev = scope.with_op(function(child) child_scope = child - -- inside build_ev, current scope should be the child - assert(scope.current() == child, "inside with_ev build_ev, current() should be child scope") - assert(child:parent() == parent, "with_ev child parent should be current scope") + -- inside build_op, current scope should be the child + assert(scope.current() == child, "inside with_op build_op, current() should be child scope") + assert(child:parent() == parent, "with_op child parent should be current scope") -- simple event that returns two values return op.always(true):wrap(function() @@ -164,68 +164,68 @@ local function test_with_ev_basic() end) local a, b = performer.perform(ev) - assert(a == 99 and b == "ok", "with_ev should propagate child event results") + assert(a == 99 and b == "ok", "with_op should propagate child event results") -- After perform, current() should be restored to the parent. assert(scope.current() == parent, - "after with_ev perform, current() should be restored to parent scope") + "after with_op perform, current() should be restored to parent scope") - assert(child_scope ~= nil, "with_ev should have created a child scope") + assert(child_scope ~= nil, "with_op should have created a child scope") local st, err = child_scope:status() - assert(st == "ok" and err == nil, "with_ev child scope should end ok on success") + assert(st == "ok" and err == nil, "with_op child scope should end ok on success") end --- Failure in the with_ev builder should be confined to the with_ev child scope. -local function test_with_ev_failure_confined_to_child() +-- Failure in the with_op builder should be confined to the with_op child scope. +local function test_with_op_failure_confined_to_child() local outer_scope local child_scope local st, serr = scope.run(function(s) outer_scope = s - local ev = scope.with_ev(function(child) + local ev = scope.with_op(function(child) child_scope = child assert(scope.current() == child, - "inside failing with_ev, current() should be child scope") + "inside failing with_op, current() should be child scope") assert(child:parent() == s, - "with_ev child parent should be the surrounding scope.run scope") + "with_op child parent should be the surrounding scope.run scope") - error("with_ev builder failure") + error("with_op builder failure") end) - -- The error above is caught by the Scope:spawn wrapper for this body fibre. + -- The error above is caught by the Scope:spawn wrapper for this body fiber. performer.perform(ev) -- Not reached. end) - -- The outer scope remains ok; the failure is local to the with_ev child. + -- The outer scope remains ok; the failure is local to the with_op child. assert(st == "ok" and serr == nil, - "outer scope.run should still succeed when with_ev child fails") + "outer scope.run should still succeed when with_op child fails") assert(outer_scope ~= nil, "outer_scope should have been set") - assert(child_scope ~= nil, "with_ev failure test should have created child scope") + assert(child_scope ~= nil, "with_op failure test should have created child scope") local cst, cerr = child_scope:status() - assert(cst == "failed", "with_ev child should be failed after builder error") - assert(tostring(cerr):find("with_ev builder failure", 1, true), - "with_ev child error should mention builder failure") + assert(cst == "failed", "with_op child should be failed after builder error") + assert(tostring(cerr):find("with_op builder failure", 1, true), + "with_op child error should mention builder failure") end --- with_ev used in a choice where it loses should lead to a cancelled child scope. -local function test_with_ev_abort_on_choice() +-- with_op used in a choice where it loses should lead to a cancelled child scope. +local function test_with_op_abort_on_choice() local outer_scope local child_scope local st, serr, winner = scope.run(function(s) outer_scope = s - local ev_with = scope.with_ev(function(child) + local ev_with = scope.with_op(function(child) child_scope = child assert(scope.current() == child, - "inside with_ev arm of choice, current() should be child scope") + "inside with_op arm of choice, current() should be child scope") assert(child:parent() == s, - "with_ev child parent in choice should be outer scope") + "with_op child parent in choice should be outer scope") -- This arm never becomes ready; it will lose the choice. return op.never() @@ -237,79 +237,79 @@ local function test_with_ev_abort_on_choice() -- After the choice, current() should be restored. assert(scope.current() == s, - "after with_ev choice, current() should be restored to outer scope") + "after with_op choice, current() should be restored to outer scope") return res end) assert(st == "ok" and serr == nil, - "outer scope.run should succeed when with_ev arm loses a choice") + "outer scope.run should succeed when with_op arm loses a choice") assert(winner == "right", "outer scope.run should return the winning choice result") assert(outer_scope ~= nil, "outer_scope should have been set") - assert(child_scope ~= nil, "with_ev choice test should have created child scope") + assert(child_scope ~= nil, "with_op choice test should have created child scope") local cst, cerr = child_scope:status() assert(cst == "cancelled", - "with_ev child should be cancelled when its event loses a choice") + "with_op child should be cancelled when its event loses a choice") assert(cerr == "scope aborted", - "with_ev aborted child error should be 'scope aborted'") + "with_op aborted child error should be 'scope aborted'") end --- Failure in a fibre spawned under a with_ev child scope should fail that child, +-- Failure in a fiber spawned under a with_op child scope should fail that child, -- but not its outer scope. -local function test_with_ev_child_fibre_failure() +local function test_with_op_child_fiber_failure() local outer_scope local child_scope local st, serr = scope.run(function(s) outer_scope = s - local ev = scope.with_ev(function(child) + local ev = scope.with_op(function(child) child_scope = child assert(child:parent() == s, - "with_ev child parent should be outer scope in child-fibre test") + "with_op child parent should be outer scope in child-fiber test") - -- A condition used only to keep one child fibre blocked. + -- A condition used only to keep one child fiber blocked. local c = cond_mod.new() - -- Failing child fibre under the with_ev scope. + -- Failing child fiber under the with_op scope. child:spawn(function(_) - error("with_ev child fibre failure") + error("with_op child fiber failure") end) - -- Another child fibre that blocks on a cond and is cancelled - -- via the with_ev scope's cancellation. + -- Another child fiber that blocks on a cond and is cancelled + -- via the with_op scope's cancellation. child:spawn(function(_) local ok2, reason2 = performer.perform(c:wait_op()) - -- Under failure, this fibre should see a cancellation result. - assert(ok2 == false, "blocked child fibre should observe cancellation ok=false") - assert(reason2 ~= nil, "blocked child fibre should receive a cancellation reason") + -- Under failure, this fiber should see a cancellation result. + assert(ok2 == false, "blocked child fiber should observe cancellation ok=false") + assert(reason2 ~= nil, "blocked child fiber should receive a cancellation reason") end) - -- The main event for with_ev completes successfully. + -- The main event for with_op completes successfully. return op.always("ok") end) local res = performer.perform(ev) - assert(res == "ok", "with_ev main event should still return its result") + assert(res == "ok", "with_op main event should still return its result") - -- At this point, with_ev's release will have waited for the child scope - -- to close, including both spawned child fibres and defers. + -- At this point, with_op's release will have waited for the child scope + -- to close, including both spawned child fibers and defers. end) assert(st == "ok" and serr == nil, - "outer scope.run should remain ok after with_ev child-fibre failure") + "outer scope.run should remain ok after with_op child-fiber failure") assert(outer_scope ~= nil, "outer_scope should have been set") - assert(child_scope ~= nil, "with_ev child-fibre test should have created child scope") + assert(child_scope ~= nil, "with_op child-fiber test should have created child scope") local cst, cerr = child_scope:status() assert(cst == "failed", - "with_ev child scope should be failed after a child fibre failure") - assert(tostring(cerr):find("with_ev child fibre failure", 1, true), - "with_ev child scope error should mention the child fibre failure") + "with_op child scope should be failed after a child fiber failure") + assert(tostring(cerr):find("with_op child fiber failure", 1, true), + "with_op child scope error should mention the child fiber failure") end ------------------------------------------------------------------------------- @@ -424,8 +424,8 @@ end -- 4. Scope:sync via performer.perform: failure and cancellation paths ------------------------------------------------------------------------------- -local function test_sync_wraps_event_failure() - -- Event whose post-wrap raises: tests that scope sees failure. +local function test_sync_wraps_op_failure() + -- Op whose post-wrap raises: tests that scope sees failure. local ev = op.always(123):wrap(function(v) assert(v == 123, "inner always should pass its value") error("event post-wrap failure") @@ -434,7 +434,7 @@ local function test_sync_wraps_event_failure() local failed_scope local st, serr = scope.run(function(s) failed_scope = s - -- This synchronisation will cause this fibre to fail; + -- This performance will cause this fiber to fail; -- scope should record status 'failed'. performer.perform(ev) end) @@ -456,7 +456,7 @@ local function test_sync_respects_cancellation() local ev = op.never() local cancelled_scope - local st, serr, ok_ev, reason_ev = scope.run(function(s) + local st, serr, ok_op, reason_op = scope.run(function(s) cancelled_scope = s s:cancel("cancel before sync") @@ -470,8 +470,8 @@ local function test_sync_respects_cancellation() assert(st == "cancelled", "scope should be cancelled") assert(serr == "cancel before sync", "cancellation reason should be preserved") - assert(ok_ev == false, "scope.run should return the event ok flag from body") - assert(reason_ev == "cancel before sync", + assert(ok_op == false, "scope.run should return the event ok flag from body") + assert(reason_op == "cancel before sync", "scope.run should return the cancellation reason from body") local st2, serr2 = cancelled_scope:status() @@ -479,16 +479,16 @@ local function test_sync_respects_cancellation() assert(serr2 == "cancel before sync", "cancelled_scope error should be the cancellation reason") end --- Cancellation racing with a blocking sync: cancel the scope while a fibre +-- Cancellation racing with a blocking sync: cancel the scope while a fiber -- is blocked on a wait_op. local function test_sync_cancellation_race() local race_scope - local st, serr, ok_ev, reason_ev = scope.run(function(s) + local st, serr, ok_op, reason_op = scope.run(function(s) race_scope = s local cond = cond_mod.new() - -- Canceller fibre: let the main fibre block first, then cancel. + -- Canceller fiber: let the main fiber block first, then cancel. s:spawn(function(_) runtime.yield() s:cancel("race cancel") @@ -502,8 +502,8 @@ local function test_sync_cancellation_race() assert(serr == "race cancel", "race cancellation reason should be 'race cancel'") - assert(ok_ev == false, "blocking event should observe ok=false when cancelled") - assert(reason_ev == "race cancel", + assert(ok_op == false, "blocking event should observe ok=false when cancelled") + assert(reason_op == "race cancel", "blocking event should see the race cancellation reason") local st2, serr2 = race_scope:status() @@ -512,10 +512,10 @@ local function test_sync_cancellation_race() end ------------------------------------------------------------------------------- --- 5. join_ev and done_ev (on failed/cancelled scopes) +-- 5. join_op and done_op (on failed/cancelled scopes) ------------------------------------------------------------------------------- -local function test_join_and_done_events() +local function test_join_and_done_opents() -- Failed scope: body error. local failed_scope local st_fail, err_fail = scope.run(function(s) @@ -528,20 +528,20 @@ local function test_join_and_done_events() "failed scope error should mention the body failure") do - local ev = failed_scope:join_ev() + local ev = failed_scope:join_op() local st, jerr = performer.perform(ev) - assert(st == "failed", "join_ev on failed scope should report 'failed'") + assert(st == "failed", "join_op on failed scope should report 'failed'") assert(tostring(jerr):find("join test failure", 1, true), - "join_ev error should mention the body failure") + "join_op error should mention the body failure") end do - local ev = failed_scope:done_ev() + local ev = failed_scope:done_op() local reason = performer.perform(ev) - -- For a failed scope we also call cancel(error), so done_ev + -- For a failed scope we also call cancel(error), so done_op -- should be triggered and report the same error. assert(tostring(reason):find("join test failure", 1, true), - "done_ev on failed scope should report the failure reason") + "done_op on failed scope should report the failure reason") end -- Cancelled scope (explicit cancel, not body error). @@ -556,37 +556,37 @@ local function test_join_and_done_events() "cancelled scope.run should report the cancellation reason") do - local ev = cancelled_scope:join_ev() + local ev = cancelled_scope:join_op() local st, jerr = performer.perform(ev) assert(st == "cancelled" and jerr == "stop again", - "join_ev on cancelled scope should report 'cancelled' and reason") + "join_op on cancelled scope should report 'cancelled' and reason") end do - local ev = cancelled_scope:done_ev() + local ev = cancelled_scope:done_op() local reason = performer.perform(ev) assert(reason == "stop again", - "done_ev on cancelled scope should report cancellation reason") + "done_op on cancelled scope should report cancellation reason") end end ------------------------------------------------------------------------------- --- 6. Fail-fast from child fibres (via performer.perform) +-- 6. Fail-fast from child fibers (via performer.perform) ------------------------------------------------------------------------------- -local function test_fail_fast_from_child_fibre() +local function test_fail_fast_from_child_fiber() local test_scope local st, serr = scope.run(function(s) test_scope = s - -- Use a condition to ensure the child fibre runs before we exit the body. + -- Use a condition to ensure the child fiber runs before we exit the body. local cond = cond_mod.new() - -- Spawn a child fibre that signals, then fails. + -- Spawn a child fiber that signals, then fails. s:spawn(function(_) cond:signal() - error("child fibre failure") + error("child fiber failure") end) -- Wait for the cond via performer, so we do not exit @@ -594,14 +594,14 @@ local function test_fail_fast_from_child_fibre() performer.perform(cond:wait_op()) end) - assert(st == "failed", "scope.run should report failed when a child fibre fails") - assert(tostring(serr):find("child fibre failure", 1, true), - "primary error should mention child fibre failure") + assert(st == "failed", "scope.run should report failed when a child fiber fails") + assert(tostring(serr):find("child fiber failure", 1, true), + "primary error should mention child fiber failure") local st2, serr2 = test_scope:status() - assert(st2 == "failed", "scope status should be failed after child fibre failure") - assert(tostring(serr2):find("child fibre failure", 1, true), - "scope primary error should mention child fibre failure") + assert(st2 == "failed", "scope status should be failed after child fiber failure") + assert(tostring(serr2):find("child fiber failure", 1, true), + "scope primary error should mention child fiber failure") end ------------------------------------------------------------------------------- @@ -611,16 +611,16 @@ end local function main() io.stdout:write("Running scope tests...\n") - -- Run all tests inside a single top-level fibre so that scope.run + -- Run all tests inside a single top-level fiber so that scope.run -- and performer.perform are always called from within the scheduler. runtime.spawn_raw(function() test_outside_fibers() test_inside_fibers() - test_with_ev_basic() - test_with_ev_failure_confined_to_child() - test_with_ev_abort_on_choice() - test_with_ev_child_fibre_failure() + test_with_op_basic() + test_with_op_failure_confined_to_child() + test_with_op_abort_on_choice() + test_with_op_child_fiber_failure() test_run_success_and_failure() test_run_explicit_cancel() @@ -628,12 +628,12 @@ local function main() test_defers_lifo_and_failure() test_defer_failure_marks_scope_failed() - test_sync_wraps_event_failure() + test_sync_wraps_op_failure() test_sync_respects_cancellation() test_sync_cancellation_race() - test_join_and_done_events() - test_fail_fast_from_child_fibre() + test_join_and_done_opents() + test_fail_fast_from_child_fiber() io.stdout:write("OK\n") runtime.stop() From f94d60c074d49dde11b244ad056fda639df8ef1e Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sun, 23 Nov 2025 19:09:49 +0000 Subject: [PATCH 044/138] initial LuaLS annotation for cond.lua --- src/fibers/cond.lua | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/fibers/cond.lua b/src/fibers/cond.lua index a92a4b7..558d0f8 100644 --- a/src/fibers/cond.lua +++ b/src/fibers/cond.lua @@ -1,19 +1,21 @@ ---- fibers/cond.lua +-- fibers/cond.lua --- --- Generic condition events built on top of oneshot and op. --- --- A cond has: --- cond:wait_op() -> Event --- cond:wait() -- blocking, via performer --- cond:signal() -- idempotent +-- Generic condition operations built on top of oneshot and op. +-- A condition can be waited on via an Op or by blocking in the current fiber. +---@module 'fibers.cond' -local op = require 'fibers.op' -local oneshot = require 'fibers.oneshot' -local perform = require 'fibers.performer'.perform +local op = require 'fibers.op' +local oneshot = require 'fibers.oneshot' +local perform = require 'fibers.performer'.perform +--- Condition variable backed by a one-shot. +---@class Cond +---@field _os Oneshot local Cond = {} Cond.__index = Cond +--- Build an Op that becomes ready when the condition fires. +---@return Op function Cond:wait_op() local os = self._os @@ -22,8 +24,10 @@ function Cond:wait_op() function() return os:is_triggered() end, + --- Arrange to complete this suspension when the condition fires. + ---@param resumer Suspension + ---@param wrap_fn WrapFn function(resumer, wrap_fn) - -- Arrange to complete this suspension when the condition fires. os:add_waiter(function() if resumer:waiting() then resumer:complete(wrap_fn) @@ -33,14 +37,19 @@ function Cond:wait_op() ) end +--- Block the current fiber until the condition fires. +---@return any ... function Cond:wait() return perform(self:wait_op()) end +--- Signal the condition (idempotent). function Cond:signal() return self._os:signal() end +--- Create a new condition. +---@return Cond local function new() return setmetatable({ _os = oneshot.new(), -- no extra callback From 8da409746138fbddea0afaab6d532fcaf4a0711f Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Mon, 24 Nov 2025 21:26:19 +0000 Subject: [PATCH 045/138] settle on op naming --- src/fibers/op.lua | 2 +- src/fibers/performer.lua | 4 ++-- src/fibers/scope.lua | 47 ++++++++++++++++++++++++---------------- tests/test_op.lua | 30 ++++++++++++------------- tests/test_scope.lua | 46 +++++++++++++++++++-------------------- 5 files changed, 69 insertions(+), 60 deletions(-) diff --git a/src/fibers/op.lua b/src/fibers/op.lua index b699d2d..9bdb60f 100644 --- a/src/fibers/op.lua +++ b/src/fibers/op.lua @@ -576,7 +576,7 @@ local function first_ready(ops) end) end ---- Choice over a table of named ops, returning (name, ...results...). +--- Choice over a table of namedå ops, returning (name, ...results...). ---@param arms table ---@return Op local function named_choice(arms) diff --git a/src/fibers/performer.lua b/src/fibers/performer.lua index 808f2e6..dd92f02 100644 --- a/src/fibers/performer.lua +++ b/src/fibers/performer.lua @@ -38,8 +38,8 @@ local function perform(op) assert_op(op) local s = current_scope() - if s and s.sync then - return s:sync(op) + if s and s.perform then + return s:perform(op) else return Op.perform_raw(op) end diff --git a/src/fibers/scope.lua b/src/fibers/scope.lua index b4521ed..8363920 100644 --- a/src/fibers/scope.lua +++ b/src/fibers/scope.lua @@ -26,13 +26,13 @@ end ---@field _status ScopeStatus ---@field _error any ---@field _failures any[] ----@field failure_mode "fail_fast" # reserved for future modes +---@field failure_mode string # e.g. "fail_fast" ---@field _wg Waitgroup ---@field _defers fun(self: Scope)[] # LIFO defers ---@field _cancel_cond Cond ---@field _join_cond Cond ---@field _join_worker_started boolean ----@field _result table|nil # packed results used by run() +---@field _result table|nil # used by run() local Scope = {} Scope.__index = Scope @@ -346,7 +346,8 @@ end ---------------------------------------------------------------------- --- Internal: build a cancellation op for this scope. ---- The result convention is (ok:boolean, value1_or_reason, value2). +--- The particular return values are ignored by Scope:sync/Scope:perform; +--- only the fact that this op can win in a choice is important. ---@param self Scope ---@return Op local function cancel_op(self) @@ -362,43 +363,51 @@ end ---@return Op function Scope:run_op(ev) return op.guard(function() - local status, err = self:status() - - if status ~= "running" then - return op.always(false, err or "scope cancelled", nil) - end - local this_cancel_op = cancel_op(self) return op.choice(ev, this_cancel_op) end) end --- Perform an op under this scope, obeying its cancellation rules. ---- The result convention is (ok, value1, value2): ---- - ok == true if the op completed and the scope remained running/ok ---- - ok == false if the scope had already failed/cancelled, or fails/ ---- cancels before the call returns; in that case value1 ---- is the failure/cancellation reason and value2 is nil. +--- On success returns true followed by the op's result values. +--- On failure or cancellation returns false and an error value. ---@param ev Op ---@return boolean ok ---@return any ... function Scope:sync(ev) - assert(runtime.current_fiber(), "scope:sync must be called from inside a fiber (use fibers.run as an entry point)") + assert(runtime.current_fiber(), + "scope:sync must be called from inside a fiber (use fibers.run as an entry point)") local status, err = self:status() if status ~= "running" then - return false, err or "scope cancelled", nil + return false, err or "scope cancelled" end local results = pack(op.perform_raw(self:run_op(ev))) status, err = self:status() - if status ~= "running" and status ~= "ok" then - return false, err or "scope cancelled", nil + return false, err or "scope cancelled" + end + + return true, unpack(results, 1, results.n) +end + +--- Perform an op under this scope, raising on failure or cancellation. +--- On success returns the op's result values. +---@param ev Op +---@return any ... +function Scope:perform(ev) + -- sync does the fibre assertion and fail-fast logic + local results = pack(self:sync(ev)) + + local ok = results[1] + if not ok then + -- results[2] is the error value from sync + error(results[2]) end - return unpack(results, 1, results.n) + return unpack(results, 2, results.n) end ---------------------------------------------------------------------- diff --git a/tests/test_op.lua b/tests/test_op.lua index 75b49aa..9854f46 100644 --- a/tests/test_op.lua +++ b/tests/test_op.lua @@ -37,24 +37,24 @@ end runtime.spawn_raw(function() -------------------------------------------------------- - -- 1) Base event: perform, or_else, wrap + -- 1) Base op: perform, or_else, wrap -------------------------------------------------------- do local base = always(1) assert(perform(base) == 1, "base: perform failed") - -- or_else: event wins, fallback ignored + -- or_else: op wins, fallback ignored local base2 = always(2) local ev1 = base2:or_else(function() return 9 end) local palt1 = perform(ev1) - assert(palt1 == 2, "base: or_else should use event result") + assert(palt1 == 2, "base: or_else should use op result") - -- or_else: never-ready event → fallback wins + -- or_else: never-ready op → fallback wins local ev2 = never():or_else(function() return 99 end) local palt2 = perform(ev2) - assert(palt2 == 99, "base: or_else should use fallback when event can't commit") + assert(palt2 == 99, "base: or_else should use fallback when op can't commit") - -- or_else: async event is not ready now, so fallback wins + -- or_else: async op is not ready now, so fallback wins do local fallback_called = false local ev_async, tries = async_task(123) @@ -67,7 +67,7 @@ runtime.spawn_raw(function() assert(r == -1, "or_else(async): expected fallback to win") assert(tries() == 1, "async_task: try_fn should be called exactly once") assert(fallback_called == true, - "or_else(async): fallback should run when event is not ready") + "or_else(async): fallback should run when op is not ready") end -- nested wrap: ((x + 1) * 2) @@ -92,7 +92,7 @@ runtime.spawn_raw(function() -- 3) Choice + wrap -------------------------------------------------------- do - -- choice over multiple ready events + -- choice over multiple ready ops local choice_ev = choice(always(1), always(2), always(3)) for _ = 1, 5 do local v = perform(choice_ev) @@ -138,7 +138,7 @@ runtime.spawn_raw(function() end assert(calls2 == runs, "guard in choice: builder call mismatch") - -- guard + with_nack (builder returns a with_nack event) + -- guard + with_nack (builder returns a with_nack op) local guard_calls, cancelled = 0, false local guarded_nack = op.guard(function() guard_calls = guard_calls + 1 @@ -161,7 +161,7 @@ runtime.spawn_raw(function() end -------------------------------------------------------- - -- 5) or_else on composite events + -- 5) or_else on composite ops -------------------------------------------------------- do -- composite ready: choice(always, always) @@ -254,11 +254,11 @@ runtime.spawn_raw(function() end -------------------------------------------------------- - -- 7) bracket: RAII-style resource management over events + -- 7) bracket: RAII-style resource management over ops -------------------------------------------------------- do ---------------------------------------------------- - -- 7.1 basic success: inner event wins → aborted=false + -- 7.1 basic success: inner op wins → aborted=false ---------------------------------------------------- do local acq_count = 0 @@ -378,7 +378,7 @@ runtime.spawn_raw(function() -- In the new model: -- finally(cleanup) is about lifetime: -- * cleanup(false) on normal success - -- * cleanup(true) if the event participates in a choice and loses + -- * cleanup(true) if the op participates in a choice and loses -- It does not intercept Lua errors; those are handled by scopes. -------------------------------------------------------- do @@ -398,11 +398,11 @@ runtime.spawn_raw(function() "finally(success): aborted should be false") end - -- 8.2 abort path: event loses in a choice → cleanup(true) + -- 8.2 abort path: op loses in a choice → cleanup(true) do local calls = {} - -- This event never commits, so in choice it always loses. + -- This op never commits, so in choice it always loses. local base = never():finally(function(aborted) calls[#calls + 1] = aborted end) diff --git a/tests/test_scope.lua b/tests/test_scope.lua index e928bfc..41833a5 100644 --- a/tests/test_scope.lua +++ b/tests/test_scope.lua @@ -157,14 +157,14 @@ local function test_with_op_basic() assert(scope.current() == child, "inside with_op build_op, current() should be child scope") assert(child:parent() == parent, "with_op child parent should be current scope") - -- simple event that returns two values + -- simple op that returns two values return op.always(true):wrap(function() return 99, "ok" end) end) local a, b = performer.perform(ev) - assert(a == 99 and b == "ok", "with_op should propagate child event results") + assert(a == 99 and b == "ok", "with_op should propagate child op results") -- After perform, current() should be restored to the parent. assert(scope.current() == parent, @@ -252,7 +252,7 @@ local function test_with_op_abort_on_choice() local cst, cerr = child_scope:status() assert(cst == "cancelled", - "with_op child should be cancelled when its event loses a choice") + "with_op child should be cancelled when its op loses a choice") assert(cerr == "scope aborted", "with_op aborted child error should be 'scope aborted'") end @@ -288,12 +288,12 @@ local function test_with_op_child_fiber_failure() assert(reason2 ~= nil, "blocked child fiber should receive a cancellation reason") end) - -- The main event for with_op completes successfully. + -- The main op for with_op completes successfully. return op.always("ok") end) local res = performer.perform(ev) - assert(res == "ok", "with_op main event should still return its result") + assert(res == "ok", "with_op main op should still return its result") -- At this point, with_op's release will have waited for the child scope -- to close, including both spawned child fibers and defers. @@ -428,7 +428,7 @@ local function test_sync_wraps_op_failure() -- Op whose post-wrap raises: tests that scope sees failure. local ev = op.always(123):wrap(function(v) assert(v == 123, "inner always should pass its value") - error("event post-wrap failure") + error("op post-wrap failure") end) local failed_scope @@ -439,19 +439,19 @@ local function test_sync_wraps_op_failure() performer.perform(ev) end) - assert(st == "failed", "scope.run should report failure when event post-wrap fails") - assert(tostring(serr):find("event post-wrap failure", 1, true), - "scope error should mention the event failure") + assert(st == "failed", "scope.run should report failure when op post-wrap fails") + assert(tostring(serr):find("op post-wrap failure", 1, true), + "scope error should mention the op failure") local st2, serr2 = failed_scope:status() - assert(st2 == "failed", "scope should be failed after event failure") - assert(tostring(serr2):find("event post-wrap failure", 1, true), - "scope error should mention the event failure") + assert(st2 == "failed", "scope should be failed after op failure") + assert(tostring(serr2):find("op post-wrap failure", 1, true), + "scope error should mention the op failure") end local function test_sync_respects_cancellation() - -- Race a never-ready event against cancellation; cancellation should win - -- and be reflected as (ok=false, reason, nil) at the event level, and + -- Race a never-ready op against cancellation; cancellation should win + -- and be reflected as (ok=false, reason) at the sync level, and -- as status 'cancelled' at the scope level. local ev = op.never() @@ -460,17 +460,17 @@ local function test_sync_respects_cancellation() cancelled_scope = s s:cancel("cancel before sync") - local ok2, reason2 = performer.perform(ev) - assert(ok2 == false, "performer.perform should return ok=false after cancellation") + local ok2, reason2 = s:sync(ev) + assert(ok2 == false, "Scope:sync should return ok=false after cancellation") assert(reason2 == "cancel before sync", - "performer.perform should return cancellation reason") + "Scope:sync should return cancellation reason") return ok2, reason2 end) assert(st == "cancelled", "scope should be cancelled") assert(serr == "cancel before sync", "cancellation reason should be preserved") - assert(ok_op == false, "scope.run should return the event ok flag from body") + assert(ok_op == false, "scope.run should return the op ok flag from body") assert(reason_op == "cancel before sync", "scope.run should return the cancellation reason from body") @@ -494,7 +494,7 @@ local function test_sync_cancellation_race() s:cancel("race cancel") end) - local ok2, reason2 = performer.perform(cond:wait_op()) + local ok2, reason2 = s:sync(cond:wait_op()) return ok2, reason2 end) @@ -502,9 +502,9 @@ local function test_sync_cancellation_race() assert(serr == "race cancel", "race cancellation reason should be 'race cancel'") - assert(ok_op == false, "blocking event should observe ok=false when cancelled") + assert(ok_op == false, "blocking op should observe ok=false when cancelled") assert(reason_op == "race cancel", - "blocking event should see the race cancellation reason") + "blocking op should see the race cancellation reason") local st2, serr2 = race_scope:status() assert(st2 == "cancelled" and serr2 == "race cancel", @@ -515,7 +515,7 @@ end -- 5. join_op and done_op (on failed/cancelled scopes) ------------------------------------------------------------------------------- -local function test_join_and_done_opents() +local function test_join_and_done_ops() -- Failed scope: body error. local failed_scope local st_fail, err_fail = scope.run(function(s) @@ -632,7 +632,7 @@ local function main() test_sync_respects_cancellation() test_sync_cancellation_race() - test_join_and_done_opents() + test_join_and_done_ops() test_fail_fast_from_child_fiber() io.stdout:write("OK\n") From 5b44bc328a810d1701053b0b9296233486b17f95 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Fri, 28 Nov 2025 08:06:36 +0000 Subject: [PATCH 046/138] adds waiter abstraction and test --- src/fibers/wait.lua | 319 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_wait.lua | 143 ++++++++++++++++++++ 2 files changed, 462 insertions(+) create mode 100644 src/fibers/wait.lua create mode 100644 tests/test_wait.lua diff --git a/src/fibers/wait.lua b/src/fibers/wait.lua new file mode 100644 index 0000000..7c5358f --- /dev/null +++ b/src/fibers/wait.lua @@ -0,0 +1,319 @@ +--- +-- Wait module. +-- +-- Internal helper utilities for building blocking primitives: +-- +-- * Waitset: keyed sets of waiting tasks with unlink tokens. +-- * waitable(register, step, wrap_fn?): build an op from +-- a step function and a registration function. +-- +-- This module is intended for backend / primitive implementations +-- (pollers, in-memory pipes, streams, timers). Normal library users +-- should not need to depend on it directly. +-- +-- Design notes: +-- - This module is exception-neutral. It does not interpret Lua +-- errors as part of op semantics. +-- - step() and register(...) are assumed to be non-blocking and +-- non-yielding. If they raise, this is treated as a bug and the +-- surrounding scope/fiber machinery will surface the failure. +---@module 'fibers.wait' + +local op = require 'fibers.op' + +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 + +---------------------------------------------------------------------- +-- Waitset: keyed lists of tasks with unlink tokens +---------------------------------------------------------------------- + +--- Keyed set of scheduler tasks grouped by an arbitrary key. +---@class Waitset +---@field buckets table # key -> list of scheduler tasks +local Waitset = {} +Waitset.__index = Waitset + +--- Token returned from Waitset:add. +--- unlink() removes the task from the waitset; it is idempotent. +---@class WaitToken +---@field _waitset Waitset +---@field key any +---@field task Task +---@field unlink fun(self: WaitToken): boolean # true if bucket emptied + +--- Create a new Waitset instance. +---@return Waitset +local function new_waitset() + return setmetatable({ buckets = {} }, Waitset) +end + +--- Remove element at index i by swapping with the tail. +---@param t Task[] +---@param i integer +local function remove_at(t, i) + local n = #t + t[i] = t[n] + t[n] = nil +end + +--- Add a task under a given key. +-- +-- @param key Arbitrary key (fd, object, tag, etc.). +-- @param task Scheduler task object (must have :run()). +-- +-- @return token Table with token:unlink() -> bucket_empty:boolean. +---@param key any +---@param task Task +---@return WaitToken +function Waitset:add(key, task) + local buckets = self.buckets + local list = buckets[key] + if not list then + list = {} + buckets[key] = list + end + + list[#list + 1] = task + local idx = #list + local unlinked = false + + ---@class WaitToken + local token = { + _waitset = self, + key = key, + task = task, + } + + --- Unlink this task from the waitset. + --- Best-effort: falls back to a reverse scan if the stored index + --- has been invalidated by earlier removals. + ---@param tok WaitToken + ---@return boolean bucket_empty + function token.unlink(tok) + if unlinked then + return false + end + unlinked = true + + local bs = tok._waitset.buckets + local l = bs[tok.key] + if not l or #l == 0 then + return false + end + + -- Best-effort removal; index may be stale. + if idx <= #l and l[idx] == tok.task then + remove_at(l, idx) + else + for i = #l, 1, -1 do + if l[i] == tok.task then + remove_at(l, i) + break + end + end + end + + if #l == 0 then + bs[tok.key] = nil + return true + end + return false + end + + return token +end + +--- Take and remove all waiters for a key. +--- +--- Returns the list (which the caller may iterate and discard), or nil. +---@param key any +---@return Task[]|nil +function Waitset:take_all(key) + local list = self.buckets[key] + if not list then + return nil + end + self.buckets[key] = nil + return list +end + +--- Take and remove a single waiter (LIFO) for a key. +--- +--- Returns the task or nil. +---@param key any +---@return Task|nil +function Waitset:take_one(key) + local list = self.buckets[key] + if not list or #list == 0 then + return nil + end + local idx = #list + local task = list[idx] + list[idx] = nil + if #list == 0 then + self.buckets[key] = nil + end + return task +end + +--- Return whether there are no waiters for this key. +---@param key any +---@return boolean +function Waitset:is_empty(key) + local list = self.buckets[key] + return not list or #list == 0 +end + +--- Return the number of waiters for this key. +---@param key any +---@return integer +function Waitset:size(key) + local list = self.buckets[key] + return list and #list or 0 +end + +--- Remove all waiters for a single key without notifying them. +---@param key any +function Waitset:clear_key(key) + self.buckets[key] = nil +end + +--- Remove all waiters for all keys without notifying them. +function Waitset:clear_all() + self.buckets = {} +end + +--- Notify and schedule all waiters for a key. +---@param key any +---@param scheduler Scheduler +function Waitset:notify_all(key, scheduler) + local list = self:take_all(key) + if not list then return end + for i = 1, #list do + scheduler:schedule(list[i]) + list[i] = nil + end +end + +--- Notify and schedule a single waiter (LIFO) for a key. +---@param key any +---@param scheduler Scheduler +function Waitset:notify_one(key, scheduler) + local task = self:take_one(key) + if not task then return end + scheduler:schedule(task) +end + +---------------------------------------------------------------------- +-- waitable: (register, step, wrap_fn?) -> Op +---------------------------------------------------------------------- + +--- Build a waitable Op from a register function and step function. +-- +-- step() -> done:boolean, ... +-- +-- * done == true : the operation is ready to commit now; +-- remaining values are the result. +-- * done == false : not ready; the register() function must +-- arrange a future call to task:run(). +-- +-- register(task, suspension, leaf_wrap) -> token +-- +-- * Must arrange for task:run() to be invoked when progress may +-- have been made (fd readable, space available, timer expired). +-- * Returns a token table which may define token:unlink() to +-- cancel any outstanding registration for this synchronisation. +-- +-- wrap_fn (optional) is used as the primitive wrap for the Op. +-- +-- Requirements on step and register: +-- - Both must be non-blocking and must not yield. +-- - Errors raised by step/register are not caught here; they are +-- treated as bugs and surfaced by the surrounding scope/fiber. +-- +-- The op participates fully in choice/with_nack/on_abort; if it loses +-- a choice, any outstanding registration is cancelled via token:unlink(). +---@param register fun(task: Task, suspension: Suspension, leaf_wrap: WrapFn): WaitToken +---@param step fun(): boolean, ... +---@param wrap_fn? WrapFn +---@return Op +local function waitable(register, step, wrap_fn) + assert(type(register) == "function", "waitable: register must be a function") + assert(type(step) == "function", "waitable: step must be a function") + + wrap_fn = wrap_fn or id_wrap + + return op.guard(function() + -- Token for this synchronisation (one per compiled leaf). + local token + + -- Fast path: single non-blocking attempt. + -- step() must not yield; if it raises, this fiber fails. + local function try() + return step() + end + + --- Blocking path: register a task that will re-run step + --- after the external condition changes. + --- + --- The same task is re-used across wake-ups; token is updated + --- to track the latest registration. + ---@param suspension Suspension + ---@param leaf_wrap WrapFn + local function block(suspension, leaf_wrap) + ---@class WaitTask : Task + local task + + task = { + run = function() + if not suspension:waiting() then + return + end + + -- Re-check readiness. + local res = pack(step()) + local done = res[1] + if done then + -- Complete with the leaf's final wrap. + return suspension:complete( + leaf_wrap, + unpack(res, 2, res.n) + ) + end + + -- Not done yet; re-register for another wake-up. + if token and token.unlink then + token:unlink() + end + + token = register(task, suspension, leaf_wrap) + end, + } + + -- Initial registration for this synchronisation. + token = register(task, suspension, leaf_wrap) + end + + local prim = op.new_primitive(wrap_fn, try, block) + + -- If this op participates in a choice and loses, ensure any + -- extant registration is cancelled once for this synchronisation. + return prim:on_abort(function() + if token and token.unlink then + token:unlink() + end + end) + end) +end + +return { + new_waitset = new_waitset, + waitable = waitable, +} diff --git a/tests/test_wait.lua b/tests/test_wait.lua new file mode 100644 index 0000000..8379553 --- /dev/null +++ b/tests/test_wait.lua @@ -0,0 +1,143 @@ +-- tests/test_wait.lua +print('testing: fibers.wait') + +-- look one level up +package.path = "../src/?.lua;" .. package.path + +local wait = require 'fibers.wait' +local op = require 'fibers.op' +local fibers = require 'fibers' + +---------------------------------------------------------------------- +-- Simple assertion helper +---------------------------------------------------------------------- + +local function check(name, fn) + io.write(name, " ... ") + fn() + io.write("ok\n") +end + +---------------------------------------------------------------------- +-- Waitset tests +---------------------------------------------------------------------- + +check("Waitset add/unlink/notify_all", function() + local ws = wait.new_waitset() + + local scheduled = {} + local fake_sched = { + schedule = function(self, task) + scheduled[#scheduled + 1] = task + end, + } + + local t1 = { run = function() end } + local t2 = { run = function() end } + + local tok1 = ws:add("k", t1) + local tok2 = ws:add("k", t2) + + assert(ws:size("k") == 2, "size after add should be 2") + + -- Unlink first token; bucket should still be non-empty. + local emptied = tok1:unlink() + assert(emptied == false, "unlink of first waiter should not empty bucket") + assert(ws:size("k") == 1, "size after unlink should be 1") + + -- notify_all should schedule remaining task and clear bucket. + ws:notify_all("k", fake_sched) + assert(#scheduled == 1 and scheduled[1] == t2, "notify_all should schedule remaining waiter") + assert(ws:is_empty("k"), "bucket should be empty after notify_all") + + -- Unlink on already-unlinked token is a no-op. + emptied = tok1:unlink() + assert(emptied == false, "unlink on stale token should be benign") +end) + +check("Waitset notify_one", function() + local ws = wait.new_waitset() + + local scheduled = {} + local fake_sched = { + schedule = function(self, task) + scheduled[#scheduled + 1] = task + end, + } + + local t1 = { run = function() end } + local t2 = { run = function() end } + + ws:add("k", t1) + ws:add("k", t2) + + ws:notify_one("k", fake_sched) + assert(#scheduled == 1, "notify_one should schedule exactly one task") + assert(ws:size("k") == 1, "one waiter should remain after notify_one") + + ws:notify_one("k", fake_sched) + assert(#scheduled == 2, "second notify_one should schedule second task") + assert(ws:is_empty("k"), "bucket should be empty after second notify_one") +end) + +---------------------------------------------------------------------- +-- waitable tests +---------------------------------------------------------------------- + +-- 1. Fast path: step completes immediately; register() is never called. +check("waitable fast path (no blocking)", function() + local step_calls = 0 + + local function step() + step_calls = step_calls + 1 + -- done == true, plus two result values. + return true, "ok", 42 + end + + local register_called = false + local function register(_task, _susp, _wrap) + register_called = true + error("register should not be called on fast path") + end + + local ev = wait.waitable(register, step) + + local a, b = op.perform_raw(ev) + assert(step_calls == 1, "step should be called once on fast path") + assert(a == "ok" and b == 42, "results should be returned from step") + assert(not register_called, "register should not be called on fast path") +end) + +-- 2. Blocking path: first step() returns done=false, second returns done=true. +-- We run this under the scheduler so the suspension path is exercised. +check("waitable blocking path under scheduler", function() + local step_calls = 0 + + local function step() + step_calls = step_calls + 1 + if step_calls == 1 then + -- Not ready on first probe. + return false + end + -- Ready on second probe. + return true, "ready" + end + + -- register(): in a real backend this would arrange for task:run() + -- once the external condition changes. For this test we just run + -- the task immediately, which still exercises the suspension logic. + local function register(task, _suspension, _wrap) + task:run() + return { unlink = function() end } + end + + local ev = wait.waitable(register, step) + + fibers.run(function() + local res = fibers.perform(ev) + assert(res == "ready", "waitable should eventually return 'ready'") + assert(step_calls == 2, "step should be called twice (try + one wake)") + end) +end) + +io.write("All wait.lua tests completed\n") From 2aaa839385e776fa3be4a0bd3fc5f84ed3dd1adf Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Fri, 28 Nov 2025 08:28:11 +0000 Subject: [PATCH 047/138] adds ffi + rope based ring and linear buffers --- src/fibers/utils/bytes.lua | 821 ++++++++++++++++++++++++++++++ tests/test_utils-bytes.lua | 174 +++++++ tests/test_utils-bytes_stress.lua | 191 +++++++ 3 files changed, 1186 insertions(+) create mode 100644 src/fibers/utils/bytes.lua create mode 100644 tests/test_utils-bytes.lua create mode 100644 tests/test_utils-bytes_stress.lua diff --git a/src/fibers/utils/bytes.lua b/src/fibers/utils/bytes.lua new file mode 100644 index 0000000..11c7a41 --- /dev/null +++ b/src/fibers/utils/bytes.lua @@ -0,0 +1,821 @@ +-- fibers/utils/bytes.lua +-- +-- Unified buffer abstraction: +-- * bytes.RingBuf : ring buffer for bytes +-- * bytes.LinearBuf : growable linear buffer +-- +-- Two implementations exist: +-- - FFI-backed (LuaJIT or lua-cffi) under bytes.ffi +-- - Pure Lua rope/string-based under bytes.lua +-- +-- The default backend is chosen at load time: +-- _G.FIBERS_BYTES_BACKEND = "ffi" | "lua" | "auto" (default: "auto") +-- "auto" => use FFI if available, else pure Lua. +---@module 'fibers.utils.bytes' + +local is_LuaJIT = rawget(_G, "jit") and true or false + +local bit = rawget(_G, "bit") or require 'bit32' + +local ok_ffi, ffi +if is_LuaJIT then + ok_ffi, ffi = pcall(require, 'ffi') +else + ok_ffi, ffi = pcall(require, 'cffi') +end + +local has_ffi = ok_ffi and (ffi ~= nil) + +---------------------------------------------------------------------- +-- High-level type annotations +---------------------------------------------------------------------- + +--- Fixed-capacity byte ring buffer. +--- Implementations: +--- * FFI: C struct with power-of-two size and index masking. +--- * Lua: rope of strings with explicit size limit. +---@class RingBuf +---@field size integer # configured capacity +---@field len integer # unread byte count (Lua impl; FFI uses indices) +---@field buf any # FFI backing storage (uint8_t[]); Lua impl ignores +---@field read_avail fun(self: RingBuf): integer +---@field write_avail fun(self: RingBuf): integer +---@field is_empty fun(self: RingBuf): boolean +---@field is_full fun(self: RingBuf): boolean +---@field reset fun(self: RingBuf) +---@field put fun(self: RingBuf, s: string) # enqueue bytes +---@field take fun(self: RingBuf, n: integer): string # dequeue up to n bytes +---@field tostring fun(self: RingBuf): string # non-destructive snapshot +---@field find fun(self: RingBuf, pattern: string): integer|nil +---@field init fun(self: RingBuf, size: integer): RingBuf + +--- Growable linear byte buffer. +---@class LinearBuf +---@field reset fun(self: LinearBuf) +---@field append fun(self: LinearBuf, s: string) +---@field tostring fun(self: LinearBuf): string + +--- RingBuf constructor module. +---@class RingBufModule +---@field new fun(size: integer): RingBuf + +--- LinearBuf constructor module. +---@class LinearBufModule +---@field new fun(size?: integer): LinearBuf + +---------------------------------------------------------------------- +-- FFI-backed implementation builder +---------------------------------------------------------------------- + +--- Build the FFI-backed implementation tables, if FFI is available. +---@return { RingBuf: RingBufModule, LinearBuf: LinearBufModule }|nil +local function build_ffi_impl() + if not has_ffi then + return nil + end + + local band = bit.band + + -- Ring buffer struct: indices wrap modulo 2^32; size must be power of two. + ffi.cdef[[ + typedef struct { + uint32_t read_idx; + uint32_t write_idx; + uint32_t size; + uint8_t buf[?]; + } fibers_ringbuf_t; + ]] + + ---@class FfiRingBuf : RingBuf + local ring_mt = {} + ring_mt.__index = ring_mt + + -- Linear buffer is implemented as a Lua table with a growable + -- uint8_t[] backing store; no C struct is required. + ---@class FfiLinearBuf : LinearBuf + local lin_mt = {} + lin_mt.__index = lin_mt + + local ring_ct = ffi.metatype("fibers_ringbuf_t", ring_mt) + + --- Normalise to uint32 range for index arithmetic. + ---@param n integer + ---@return integer + local function to_uint32(n) + return n % 2^32 + end + + -------------------------------------------------------------------- + -- RingBuf (FFI) + -------------------------------------------------------------------- + + --- Initialise a ring buffer with a power-of-two size. + ---@param size integer + ---@return FfiRingBuf + function ring_mt:init(size) + assert(type(size) == "number" and size > 0, "RingBuf: positive size required") + assert(band(size, size - 1) == 0, "RingBuf: size must be power of two") + self.size = size + self.read_idx = 0 + self.write_idx = 0 + return self + end + + --- Reset indices; content is treated as discarded. + function ring_mt:reset() + self.read_idx = 0 + self.write_idx = 0 + end + + --- Number of bytes available to read. + ---@return integer + function ring_mt:read_avail() + return to_uint32(self.write_idx - self.read_idx) + end + + --- Remaining capacity for writes. + ---@return integer + function ring_mt:write_avail() + return self.size - self:read_avail() + end + + function ring_mt:is_empty() + return self.read_idx == self.write_idx + end + + function ring_mt:is_full() + return self:read_avail() == self.size + end + + --- Internal: absolute read position modulo size. + ---@return integer + function ring_mt:_read_pos() + return band(self.read_idx, self.size - 1) + end + + --- Internal: absolute write position modulo size. + ---@return integer + function ring_mt:_write_pos() + return band(self.write_idx, self.size - 1) + end + + --- Advance read index by count bytes. + ---@param count integer + function ring_mt:advance_read(count) + assert(count >= 0 and count <= self:read_avail(), "RingBuf:advance_read out of range") + self.read_idx = self.read_idx + ffi.cast("uint32_t", count) + end + + --- Advance write index by count bytes. + ---@param count integer + function ring_mt:advance_write(count) + assert(count >= 0 and count <= self:write_avail(), "RingBuf:advance_write out of range") + self.write_idx = self.write_idx + ffi.cast("uint32_t", count) + end + + --- Low-level pointer-based write into the ring. + ---@param src ffi.ct* -- pointer to bytes + ---@param count integer + function ring_mt:write(src, count) + assert(count >= 0 and count <= self:write_avail(), "RingBuf: write xrun") + if count == 0 then return end + local pos = self:_write_pos() + local size = self.size + local first = math.min(size - pos, count) + if first > 0 then + ffi.copy(self.buf + pos, src, first) + end + local rest = count - first + if rest > 0 then + ffi.copy(self.buf, src + first, rest) + end + self:advance_write(count) + end + + --- Low-level pointer-based read from the ring. + ---@param dst ffi.ct* -- pointer to destination + ---@param count integer + function ring_mt:read(dst, count) + assert(count >= 0 and count <= self:read_avail(), "RingBuf: read xrun") + if count == 0 then return end + local pos = self:_read_pos() + local size = self.size + local first = math.min(size - pos, count) + if first > 0 then + ffi.copy(dst, self.buf + pos, first) + end + local rest = count - first + if rest > 0 then + ffi.copy(dst + first, self.buf, rest) + end + self:advance_read(count) + end + + --- Peek at contiguous readable bytes without advancing. + ---@return ffi.ct*|nil ptr + ---@return integer len + function ring_mt:peek() + local pos = self:_read_pos() + local avail = self:read_avail() + local first = math.min(avail, self.size - pos) + if first <= 0 then + return nil, 0 + end + return self.buf + pos, first + end + + --- Reserve contiguous write space and return pointer and length. + --- Caller must follow with commit(count) after writing. + ---@param request? integer + ---@return ffi.ct*|nil ptr + ---@return integer len + function ring_mt:reserve(request) + local avail = self:write_avail() + if avail <= 0 then + return nil, 0 + end + local pos = self:_write_pos() + local first = math.min(avail, self.size - pos) + local n = first + if request and request < n then + n = request + end + if n <= 0 then + return nil, 0 + end + return self.buf + pos, n + end + + --- Commit count bytes previously reserved. + ---@param count integer + function ring_mt:commit(count) + self:advance_write(count) + end + + -- String-oriented helpers to match the pure Lua interface. + + --- Enqueue a string into the ring. + ---@param str string + function ring_mt:put(str) + assert(type(str) == "string", "RingBuf:put expects a string") + local n = #str + if n == 0 then return end + assert(n <= self:write_avail(), "RingBuf: write would exceed capacity") + local tmp = ffi.new("uint8_t[?]", n) + ffi.copy(tmp, str, n) + self:write(tmp, n) + end + + --- Dequeue up to n bytes and return as a string. + ---@param n integer + ---@return string + function ring_mt:take(n) + assert(type(n) == "number" and n >= 0, "RingBuf:take expects non-negative count") + local avail = self:read_avail() + if avail == 0 or n == 0 then + return "" + end + if n > avail then + n = avail + end + local tmp = ffi.new("uint8_t[?]", n) + self:read(tmp, n) + return ffi.string(tmp, n) + end + + --- Non-destructive snapshot of all readable data as a string. + --- Internally reads then rewinds read_idx. + ---@return string + function ring_mt:tostring() + local n = self:read_avail() + if n == 0 then + return "" + end + local tmp = ffi.new("uint8_t[?]", n) + self:read(tmp, n) + -- Restore read_idx so this is a non-destructive view. + self.read_idx = self.read_idx - ffi.cast("uint32_t", n) + return ffi.string(tmp, n) + end + + --- Find a literal substring in the readable region. + --- Returns zero-based offset or nil. + ---@param pattern string + ---@return integer|nil + function ring_mt:find(pattern) + assert(type(pattern) == "string" and #pattern > 0, "RingBuf:find expects non-empty string") + local s = self:tostring() + local i = s:find(pattern, 1, true) + return i and (i - 1) or nil + end + + -------------------------------------------------------------------- + -- LinearBuf (FFI, growable) + -- + -- Growable linear buffer backed by a uint8_t[] array. + -- cap is treated as an initial capacity hint; the buffer grows + -- geometrically when needed. + -------------------------------------------------------------------- + + --- Create a new FFI-backed LinearBuf. + ---@param cap? integer + ---@return FfiLinearBuf + local function LinearBuf_new(cap) + cap = cap or 4096 + assert(cap > 0, "LinearBuf.new: positive initial capacity required") + local buf = ffi.new("uint8_t[?]", cap) + return setmetatable({ + buf = buf, + len = 0, + cap = cap, + }, lin_mt) + end + + --- Reset to empty without releasing capacity. + function lin_mt:reset() + self.len = 0 + end + + --- Ensure space for at least extra bytes beyond current len. + ---@param extra integer + function lin_mt:ensure(extra) + assert(extra >= 0, "LinearBuf:ensure expects non-negative extra") + local needed = self.len + extra + if needed <= self.cap then + return + end + + local new_cap = self.cap + if new_cap <= 0 then + new_cap = 1 + end + while new_cap < needed do + new_cap = new_cap * 2 + end + + local new_buf = ffi.new("uint8_t[?]", new_cap) + if self.len > 0 then + ffi.copy(new_buf, self.buf, self.len) + end + + self.buf = new_buf + self.cap = new_cap + end + + --- Reserve raw space for n bytes and return a pointer. + --- Caller must follow with commit(n) after writing. + ---@param n integer + ---@return ffi.ct* ptr + function lin_mt:reserve(n) + assert(type(n) == "number" and n >= 0, "LinearBuf:reserve expects non-negative count") + if n == 0 then + return self.buf + self.len + end + self:ensure(n) + return self.buf + self.len + end + + --- Commit n bytes written after reserve(). + ---@param n integer + function lin_mt:commit(n) + assert(type(n) == "number" and n >= 0, "LinearBuf:commit expects non-negative count") + assert(self.len + n <= self.cap, "LinearBuf:commit overflow") + self.len = self.len + n + end + + --- Convert buffer contents to a string. + ---@return string + function lin_mt:tostring() + if self.len == 0 then + return "" + end + return ffi.string(self.buf, self.len) + end + + --- String-level append matching the pure Lua interface. + ---@param str string + function lin_mt:append(str) + assert(type(str) == "string", "LinearBuf:append expects a string") + local n = #str + if n == 0 then return end + self:ensure(n) + ffi.copy(self.buf + self.len, str, n) + self.len = self.len + n + end + + -------------------------------------------------------------------- + -- Public constructors (FFI) + -------------------------------------------------------------------- + + ---@class FfiImpl + ---@field RingBuf RingBufModule + ---@field LinearBuf LinearBufModule + local ffi_impl = {} + + --- Create a new FFI-backed RingBuf. + ---@param size integer + ---@return RingBuf + function ffi_impl.RingBuf_new(size) + local self = ring_ct(size) + return ring_mt.init(self, size) + end + + ffi_impl.RingBuf = { new = ffi_impl.RingBuf_new } + + ffi_impl.LinearBuf_new = LinearBuf_new + ffi_impl.LinearBuf = { new = LinearBuf_new } + + return ffi_impl +end + +---------------------------------------------------------------------- +-- Pure Lua implementation builder +---------------------------------------------------------------------- + +--- Build the pure Lua implementation tables (no FFI). +---@return { RingBuf: RingBufModule, LinearBuf: LinearBufModule } +local function build_lua_impl() + -------------------------------------------------------------------- + -- RingBuf (pure Lua, rope-style: table of strings) + -------------------------------------------------------------------- + + ---@class LuaRingBuf : RingBuf + local RingBuf_mt = {} + RingBuf_mt.__index = RingBuf_mt + + --- Create a new rope-based RingBuf with fixed capacity. + ---@param size integer + ---@return LuaRingBuf + local function RingBuf_new(size) + assert(type(size) == "number" and size > 0, "RingBuf.new: positive size required") + return setmetatable({ + chunks = {}, -- array of strings + head_idx = 1, -- index of first chunk with unread data + head_off = 0, -- bytes already consumed from chunks[head_idx] + len = 0, -- total unread bytes + size = size, + }, RingBuf_mt) + end + + function RingBuf_mt:reset() + self.chunks = {} + self.head_idx = 1 + self.head_off = 0 + self.len = 0 + end + + function RingBuf_mt:read_avail() + return self.len + end + + function RingBuf_mt:write_avail() + return self.size - self.len + end + + function RingBuf_mt:is_empty() + return self.len == 0 + end + + function RingBuf_mt:is_full() + return self.len >= self.size + end + + --- Compact the chunks array when the head index has advanced far. + --- This keeps table size bounded over long runs. + ---@param self LuaRingBuf + local function compact(self) + local hi = self.head_idx + if hi <= 8 and hi <= (#self.chunks / 2) then + return + end + for i = 1, hi - 1 do + self.chunks[i] = nil + end + local k = 1 + for j = hi, #self.chunks do + self.chunks[k] = self.chunks[j] + if k ~= j then self.chunks[j] = nil end + k = k + 1 + end + self.head_idx = 1 + end + + --- Advance read position by n bytes. + ---@param n integer + function RingBuf_mt:advance_read(n) + assert(n >= 0 and n <= self.len, "RingBuf:advance_read out of range") + if n == 0 then return end + + self.len = self.len - n + + local i = self.head_idx + local off = self.head_off + + while n > 0 and i <= #self.chunks do + local chunk = self.chunks[i] + local rem = #chunk - off + if n < rem then + off = off + n + n = 0 + else + n = n - rem + i = i + 1 + off = 0 + end + end + + self.head_idx = i + self.head_off = off + compact(self) + end + + --- Low-level write used internally (string-based). + ---@param src string + ---@param count? integer + function RingBuf_mt:write(src, count) + assert(type(src) == "string", "RingBuf:write expects string in pure Lua mode") + local n = count or #src + assert(n <= #src, "RingBuf:write count > #src") + assert(n <= self:write_avail(), "RingBuf: write xrun") + + if n == 0 then return end + local s = src + if n < #s then + s = s:sub(1, n) + end + self.len = self.len + n + table.insert(self.chunks, s) + end + + --- Low-level read used internally; returns a string of count bytes. + ---@param _ any + ---@param count integer + ---@return string + function RingBuf_mt:read(_, count) + assert(count >= 0 and count <= self.len, "RingBuf: read xrun") + if count == 0 then return "" end + + local out = {} + local need = count + local i = self.head_idx + local off = self.head_off + local n = #self.chunks + + while need > 0 and i <= n do + local chunk = self.chunks[i] + local rem = #chunk - off + local take = math.min(need, rem) + out[#out + 1] = chunk:sub(off + 1, off + take) + need = need - take + if take == rem then + i = i + 1 + off = 0 + else + off = off + take + end + end + + local s = table.concat(out) + self.head_idx = i + self.head_off = off + self.len = self.len - count + compact(self) + return s + end + + --- Peek at next chunk as a single string without advancing. + ---@return string|nil chunk + ---@return integer len + function RingBuf_mt:peek() + if self.len == 0 then + return nil, 0 + end + + local i = self.head_idx + local off = self.head_off + local chunk = self.chunks[i] + local rem = #chunk - off + + if rem <= 0 then + return nil, 0 + end + local s = chunk:sub(off + 1) + return s, #s + end + + --- Reserve space; not supported in Lua backend. + ---@param _ any + ---@return nil, integer + function RingBuf_mt:reserve(_) + return nil, 0 + end + + function RingBuf_mt:commit(_) + end + + -- String-oriented helpers matching the FFI interface. + + --- Enqueue a string into the ring. + ---@param str string + function RingBuf_mt:put(str) + assert(type(str) == "string", "RingBuf:put expects a string") + local n = #str + if n == 0 then return end + assert(n <= self:write_avail(), "RingBuf: write would exceed capacity") + self:write(str, n) + end + + --- Dequeue up to n bytes and return as a string. + ---@param n integer + ---@return string + function RingBuf_mt:take(n) + assert(type(n) == "number" and n >= 0, "RingBuf:take expects non-negative count") + if self.len == 0 or n == 0 then + return "" + end + if n > self.len then + n = self.len + end + return self:read(nil, n) + end + + --- Non-destructive snapshot of all data. + ---@return string + function RingBuf_mt:tostring() + if self.len == 0 then + return "" + end + local out = {} + local i = self.head_idx + local off = self.head_off + local n = #self.chunks + + if i <= n then + local first = self.chunks[i] + if off > 0 then + first = first:sub(off + 1) + end + out[#out + 1] = first + for j = i + 1, n do + out[#out + 1] = self.chunks[j] + end + end + + return table.concat(out) + end + + --- Find a literal substring in the readable region. + --- Returns zero-based offset or nil. + ---@param pattern string + ---@return integer|nil + function RingBuf_mt:find(pattern) + assert(type(pattern) == "string" and #pattern > 0, "RingBuf:find expects non-empty string") + local s = self:tostring() + local i = s:find(pattern, 1, true) + return i and (i - 1) or nil + end + + -------------------------------------------------------------------- + -- LinearBuf (pure Lua, rope-style) + -------------------------------------------------------------------- + + ---@class LuaLinearBuf : LinearBuf + local LinearBuf_mt = {} + LinearBuf_mt.__index = LinearBuf_mt + + --- Create a new rope-based LinearBuf. + ---@param _ integer + ---@return LuaLinearBuf + local function LinearBuf_new(_) + return setmetatable({ + chunks = {}, + head_idx = 1, + head_off = 0, + len = 0, + }, LinearBuf_mt) + end + + function LinearBuf_mt:reset() + self.chunks = {} + self.head_idx = 1 + self.head_off = 0 + self.len = 0 + end + + --- Append a string to the linear buffer. + ---@param s string + function LinearBuf_mt:append(s) + if not s or #s == 0 then return end + self.len = self.len + #s + table.insert(self.chunks, s) + end + + --- Convert contents to a string. + ---@return string + function LinearBuf_mt:tostring() + if self.len == 0 then + return "" + end + local out = {} + local i = self.head_idx + local off = self.head_off + local n = #self.chunks + + if i <= n then + local first = self.chunks[i] + if off > 0 then + first = first:sub(off + 1) + end + out[#out + 1] = first + for j = i + 1, n do + out[#out + 1] = self.chunks[j] + end + end + + return table.concat(out) + end + + --- Advance read position by n bytes, discarding data. + ---@param n integer + function LinearBuf_mt:advance(n) + assert(n >= 0 and n <= self.len, "LinearBuf:advance out of range") + if n == 0 then return end + + self.len = self.len - n + local i = self.head_idx + local off = self.head_off + + while n > 0 and i <= #self.chunks do + local chunk = self.chunks[i] + local rem = #chunk - off + if n < rem then + off = off + n + n = 0 + else + n = n - rem + i = i + 1 + off = 0 + end + end + + self.head_idx = i + self.head_off = off + end + + function LinearBuf_mt:reserve(_) + return nil + end + + function LinearBuf_mt:commit(_) + end + + ---@class LuaImpl + ---@field RingBuf RingBufModule + ---@field LinearBuf LinearBufModule + local lua_impl = { + RingBuf = { new = RingBuf_new }, + LinearBuf = { new = LinearBuf_new }, + } + + return lua_impl +end + +---------------------------------------------------------------------- +-- Assemble implementations and choose default +---------------------------------------------------------------------- + +local ffi_impl = build_ffi_impl() +local lua_impl = build_lua_impl() + +local backend = rawget(_G, "FIBERS_BYTES_BACKEND") or "auto" + +local use_ffi +if backend == "ffi" then + use_ffi = (ffi_impl ~= nil) +elseif backend == "lua" then + use_ffi = false +else -- "auto" + use_ffi = (ffi_impl ~= nil) +end + +local impl = use_ffi and ffi_impl or lua_impl + +---@class BytesModule +---@field RingBuf RingBufModule +---@field LinearBuf LinearBufModule +---@field has_ffi boolean +---@field ffi { RingBuf: RingBufModule, LinearBuf: LinearBufModule }|nil +---@field lua { RingBuf: RingBufModule, LinearBuf: LinearBufModule } + +local M_out = { + -- Default backend: + RingBuf = impl.RingBuf, + LinearBuf = impl.LinearBuf, + has_ffi = use_ffi, + + -- Explicit backends for testing / overrides: + ffi = ffi_impl, + lua = lua_impl, +} + +return M_out diff --git a/tests/test_utils-bytes.lua b/tests/test_utils-bytes.lua new file mode 100644 index 0000000..f849fa0 --- /dev/null +++ b/tests/test_utils-bytes.lua @@ -0,0 +1,174 @@ +-- tests/test_utils-bytes.lua + +print('testing: fibers.utils.bytes') + +-- look one level up +package.path = "../src/?.lua;" .. package.path + +-- Tests for fibers.utils.bytes +-- - always exercises the pure Lua backend +-- - additionally exercises the FFI backend if available +-- - all backends are tested via the unified string-level API: +-- RingBuf: new, read_avail, write_avail, is_empty, put, take, +-- tostring, find, reset +-- LinearBuf:new, append, tostring, reset +-- plus an extra FFI-only test for reserve/commit on LinearBuf. + +local function get_ffi() + local is_LuaJIT = rawget(_G, "jit") and true or false + local ok, ffi + if is_LuaJIT then + ok, ffi = pcall(require, "ffi") + else + ok, ffi = pcall(require, "cffi") + end + if not ok then return nil end + return ffi +end + +local bytes = require "fibers.utils.bytes" +local ffi = get_ffi() + +local function assert_eq(actual, expected, msg) + if actual ~= expected then + error(string.format("%s (expected %q, got %q)", + msg or "assert_eq failed", + tostring(expected), + tostring(actual))) + end +end + +------------------------------------------------------------ +-- Backend-independent tests (string-level API) +------------------------------------------------------------ + +local function test_ring_basic(impl) + local RingBuf = impl.RingBuf + print(" RingBuf basic...") + + local rb = RingBuf.new(16) + + assert_eq(rb:read_avail(), 0, "read_avail at start") + assert_eq(rb:write_avail(), 16, "write_avail at start") + assert(rb:is_empty(), "is_empty at start") + assert(not rb:is_full(), "is_full at start") + + -- Write "hello" + rb:put("hello") + assert_eq(rb:read_avail(), 5, "read_avail after put('hello')") + assert_eq(rb:write_avail(), 11, "write_avail after put('hello')") + + -- tostring should not consume + local s_view = rb:tostring() + assert_eq(s_view, "hello", "tostring after put 'hello'") + assert_eq(rb:read_avail(), 5, "read_avail unchanged after tostring") + + -- Take back + local s = rb:take(5) + assert_eq(s, "hello", "take(5) returns 'hello'") + assert_eq(rb:read_avail(), 0, "read_avail after full take") + assert(rb:is_empty(), "is_empty after draining") +end + +local function test_ring_wrap(impl) + local RingBuf = impl.RingBuf + print(" RingBuf wrap-around...") + + local rb = RingBuf.new(8) -- small to force wrap + + -- Sequence: put 6, take 4, put 4 → buffer should contain "efWXYZ" + rb:put("abcdef") + assert_eq(rb:read_avail(), 6, "wrap: read_avail after first put(6)") + + local s1 = rb:take(4) + assert_eq(s1, "abcd", "wrap: first take(4)") + assert_eq(rb:read_avail(), 2, "wrap: read_avail after first take") + + rb:put("WXYZ") + assert_eq(rb:read_avail(), 6, "wrap: read_avail after second put(4)") + + local s_all = rb:tostring() + assert_eq(s_all, "efWXYZ", "wrap: content after wrap sequence") + + local off = rb:find("WX") + assert_eq(off, 2, "wrap: find('WX') offset") +end + +local function test_linear_basic(impl) + local LinearBuf = impl.LinearBuf + print(" LinearBuf basic...") + + local lb = LinearBuf.new(32) + + lb:append("hello") + lb:append(" world") + assert_eq(lb:tostring(), "hello world", "LinearBuf tostring after appends") + + lb:reset() + assert_eq(lb:tostring(), "", "LinearBuf tostring after reset") +end + +------------------------------------------------------------ +-- FFI-only extra test for reserve/commit on LinearBuf +------------------------------------------------------------ + +local function test_linear_reserve_commit_ffi(impl, ffi_obj) + if not ffi_obj then + return + end + if not impl or not impl.LinearBuf then + return + end + + print(" LinearBuf reserve/commit (FFI)...") + + local LinearBuf = impl.LinearBuf + local lb = LinearBuf.new(32) + + -- reserve/commit path + local p = lb:reserve(5) + ffi_obj.copy(p, "hello", 5) + lb:commit(5) + assert_eq(lb:tostring(), "hello", "LinearBuf tostring after reserve/commit") + + -- append path on top + lb:append(" world") + assert_eq(lb:tostring(), "hello world", "LinearBuf tostring after append") +end + +------------------------------------------------------------ +-- Run tests for a given backend +------------------------------------------------------------ + +local function run_backend_tests(name, impl, has_ffi_flag, ffi_obj) + print(("testing bytes backend: %s"):format(name)) + test_ring_basic(impl) + test_ring_wrap(impl) + test_linear_basic(impl) + if has_ffi_flag then + test_linear_reserve_commit_ffi(impl, ffi_obj) + end + print(("backend %s: OK\n"):format(name)) +end + +------------------------------------------------------------ +-- Main +------------------------------------------------------------ + +print("testing fibers.utils.bytes") + +-- Always test pure Lua backend +if not bytes.lua or not bytes.lua.RingBuf or not bytes.lua.LinearBuf then + error("bytes.lua backend not available") +end + +run_backend_tests("lua", bytes.lua, false, nil) + +-- Test FFI backend if present +if bytes.ffi and bytes.ffi.RingBuf and bytes.ffi.LinearBuf and ffi then + run_backend_tests("ffi", bytes.ffi, true, ffi) +else + print("ffi backend not available or no ffi/cffi; skipping FFI tests\n") +end + +print("all bytes tests done") diff --git a/tests/test_utils-bytes_stress.lua b/tests/test_utils-bytes_stress.lua new file mode 100644 index 0000000..d5e985d --- /dev/null +++ b/tests/test_utils-bytes_stress.lua @@ -0,0 +1,191 @@ +-- tests/test_bytes_stress.lua +-- +-- Stress tests for fibers.utils.bytes +-- - always exercises the pure Lua backend +-- - also exercises the FFI backend if available +-- +-- Uses a simple reference model (Lua strings) to verify correctness +-- under randomised workloads, via the unified string-level interface: +-- RingBuf:put(str), RingBuf:take(n), RingBuf:tostring() +-- LinearBuf:append(str), LinearBuf:tostring() + +print('testing: fibers.utils.bytes stress') + +-- look one level up +package.path = "../src/?.lua;" .. package.path + +local bytes = require "fibers.utils.bytes" + +local function assert_eq(actual, expected, msg) + if actual ~= expected then + error(string.format( + "%s (expected %q, got %q)", + msg or "assert_eq failed", + tostring(expected), + tostring(actual) + )) + end +end + +---------------------------------------------------------------------- +-- Helpers +---------------------------------------------------------------------- + +local function random_bytes(len) + -- Deterministic pseudo-random ASCII payload; simple but fine for testing. + local t = {} + for i = 1, len do + local b = 32 + ((i * 37) % 90) -- printable range + t[i] = string.char(b) + end + return table.concat(t) +end + +---------------------------------------------------------------------- +-- RingBuf stress: random reads/writes checked against string model +---------------------------------------------------------------------- + +local function stress_ring(impl, label) + local RingBuf = impl.RingBuf + print((" RingBuf stress (%s)..."):format(label)) + + -- Use a modest capacity so wrap-around / full conditions are exercised. + local cap = 4096 + local rb = RingBuf.new(cap) + + -- Reference model: Lua string containing unread data. + local ref = "" + + math.randomseed(12345) + + local iterations = 20000 + + for step = 1, iterations do + local do_write + if #ref == 0 then + do_write = true + elseif rb:write_avail() == 0 then + do_write = false + else + do_write = (math.random() < 0.5) + end + + if do_write then + -- Write between 1 and 64 bytes, limited by write_avail. + local max_len = math.min(64, rb:write_avail()) + if max_len > 0 then + local len = math.random(1, max_len) + local chunk = random_bytes(len) + + rb:put(chunk) + ref = ref .. chunk + end + else + -- Read between 1 and 32 bytes, limited by read_avail/ref length. + local avail = rb:read_avail() + if avail > 0 then + local max_len = math.min(32, avail, #ref) + local len = math.random(1, max_len) + + local got = rb:take(len) + local exp = ref:sub(1, len) + if got ~= exp then + error(string.format( + "RingBuf stress mismatch at step %d: expected %q, got %q", + step, exp, got + )) + end + ref = ref:sub(len + 1) + end + end + + -- Occasionally cross-check the snapshot. + if step % 5000 == 0 then + local snap = rb:tostring() + if snap ~= ref then + error(string.format("RingBuf snapshot mismatch at step %d", step)) + end + end + end + + -- Final consistency check. + local snap = rb:tostring() + assert_eq(snap, ref, "RingBuf final snapshot mismatch") +end + +---------------------------------------------------------------------- +-- LinearBuf stress: random appends and resets +---------------------------------------------------------------------- + +local function stress_linear(impl, label) + local LinearBuf = impl.LinearBuf + print((" LinearBuf stress (%s)..."):format(label)) + + -- FFI backend may honour a capacity; Lua backend may ignore it. + local cap = 128 + local lb = LinearBuf.new(cap) + + local ref = "" + math.randomseed(54321) + + local iterations = 10000 + + for step = 1, iterations do + -- Occasionally reset to exercise that path as well. + if step % 2000 == 0 then + lb:reset() + ref = "" + end + + local len = math.random(0, 128) + if len == 0 then + -- Occasionally just check without changing. + local snap = lb:tostring() + if snap ~= ref then + error(string.format("LinearBuf snapshot mismatch at step %d", step)) + end + else + local chunk = random_bytes(len) + lb:append(chunk) + ref = ref .. chunk + end + + if step % 2500 == 0 then + local snap = lb:tostring() + if snap ~= ref then + error(string.format("LinearBuf snapshot mismatch at step %d", step)) + end + end + end + + local snap = lb:tostring() + assert_eq(snap, ref, "LinearBuf final snapshot mismatch") +end + +---------------------------------------------------------------------- +-- Run backends +---------------------------------------------------------------------- + +local function run_backend(label, impl) + print(("testing bytes backend (stress): %s"):format(label)) + stress_ring(impl, label) + stress_linear(impl, label) + print(("backend %s: OK\n"):format(label)) +end + +print("testing fibers.utils.bytes (stress)") + +-- Always test pure Lua backend +if not bytes.lua or not bytes.lua.RingBuf or not bytes.lua.LinearBuf then + error("bytes.lua backend not available") +end +run_backend("lua", bytes.lua) + +-- Test FFI backend if present +if bytes.ffi and bytes.ffi.RingBuf and bytes.ffi.LinearBuf then + run_backend("ffi", bytes.ffi) +else + print("ffi backend not available; skipping FFI stress\n") +end + +print("all bytes stress tests done") From 580fccdcc062fcc65ed7f037eea9f29f76808ba9 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Fri, 28 Nov 2025 08:31:21 +0000 Subject: [PATCH 048/138] adds stream absraction + mem backend built on waitable --- src/fibers/io/mem_backend.lua | 158 ++++++++++ src/fibers/io/stream.lua | 523 ++++++++++++++++++++++++++++++++++ tests/test_io-mem.lua | 279 ++++++++++++++++++ tests/test_io-stream.lua | 124 ++++++++ 4 files changed, 1084 insertions(+) create mode 100644 src/fibers/io/mem_backend.lua create mode 100644 src/fibers/io/stream.lua create mode 100644 tests/test_io-mem.lua create mode 100644 tests/test_io-stream.lua diff --git a/src/fibers/io/mem_backend.lua b/src/fibers/io/mem_backend.lua new file mode 100644 index 0000000..f7a106e --- /dev/null +++ b/src/fibers/io/mem_backend.lua @@ -0,0 +1,158 @@ +-- fibers/io/mem_backend.lua +-- +-- In-memory duplex pair (pipe-like) backend. +-- +-- Backend contract towards fibers.io.stream: +-- * kind() -> "mem" +-- * fileno() -> nil +-- * read_string(max) -> str|nil, err|nil +-- - str == nil : would block +-- - str == "" : EOF +-- * write_string(str) -> n|nil, err|nil +-- - n == nil : would block +-- * on_readable(task) -> token{ unlink = fn } +-- * on_writable(task) -> token{ unlink = fn } +-- * close() -> ok, err|nil + +local runtime = require 'fibers.runtime' +local wait = require 'fibers.wait' +local bytes = require 'fibers.utils.bytes' + +local RingBuf = bytes.RingBuf + +local function new_half(bufsize) + local H = { + rx = RingBuf.new(bufsize or 4096), + r_wait = wait.new_waitset(), + w_wait = wait.new_waitset(), + peer = nil, + rx_closed = false, -- peer has closed its write side + closed = false, -- this half has been closed + } + + local function schedule_all(waitset, key) + local sched = runtime.current_scheduler + waitset:notify_all(key, sched) + end + + function H:kind() + return "mem" + end + + function H:fileno() + return nil + end + + -------------------------------------------------------------------- + -- String-oriented I/O, for use by fibers.io.stream + -------------------------------------------------------------------- + + -- Read up to max bytes as a Lua string. + -- * returns "" when peer has closed and no more data → EOF. + -- * returns nil, nil when no data and not closed yet → would block. + function H:read_string(max) + local have = self.rx:read_avail() + if have == 0 then + if self.rx_closed then + -- EOF + return "", nil + end + -- Would block + return nil, nil + end + + local n = math.min(have, max or have) + local chunk = self.rx:take(n) + + -- Space freed → wake peer writers. + local peer = self.peer + if peer then + schedule_all(peer.w_wait, peer) + end + + return chunk, nil + end + + -- Write a Lua string into the peer's receive buffer. + -- * returns nil, "closed" if peer is gone. + -- * returns nil, nil when no space → would block. + function H:write_string(str) + local peer = self.peer + if not peer or peer.rx_closed then + return nil, "closed" + end + + local len = #str + if len == 0 then + return 0, nil + end + + local room = peer.rx:write_avail() + if room == 0 then + if peer.rx_closed then + return nil, "closed" + end + -- Would block. + return nil, nil + end + + local n = math.min(room, len) + peer.rx:put(str:sub(1, n)) + + -- Data available → wake peer readers. + schedule_all(peer.r_wait, peer) + + return n, nil + end + + -------------------------------------------------------------------- + -- Readiness registration + -------------------------------------------------------------------- + + function H:on_readable(task) + -- Key by this half; effectively one bucket. + return self.r_wait:add(self, task) + end + + function H:on_writable(task) + return self.w_wait:add(self, task) + end + + -------------------------------------------------------------------- + -- Lifecycle + -------------------------------------------------------------------- + + function H:close() + if self.closed then + return true + end + self.closed = true + + local peer = self.peer + self.peer = nil + + -- Wake any local waiters so they can observe closure. + schedule_all(self.r_wait, self) + schedule_all(self.w_wait, self) + + if peer then + -- Indicate EOF to the peer's read side and wake its waiters. + peer.rx_closed = true + schedule_all(peer.r_wait, peer) + schedule_all(peer.w_wait, peer) + peer.peer = nil + end + + return true + end + + return H +end + +local function pipe(bufsize) + local a, b = new_half(bufsize), new_half(bufsize) + a.peer, b.peer = b, a + return a, b +end + +return { pipe = pipe } diff --git a/src/fibers/io/stream.lua b/src/fibers/io/stream.lua new file mode 100644 index 0000000..0546906 --- /dev/null +++ b/src/fibers/io/stream.lua @@ -0,0 +1,523 @@ +-- Use of this source code is governed by the Apache 2.0 license; see COPYING. + +---@module 'fibers.io.stream' + +local wait = require 'fibers.wait' +local bytes = require 'fibers.utils.bytes' +local op = require 'fibers.op' +local perform = require 'fibers.performer'.perform + +local RingBuf = bytes.RingBuf +local LinearBuf = bytes.LinearBuf + +--- Backend interface expected by Stream. +---@class StreamBackend +---@field read_string fun(self: StreamBackend, max: integer): string|nil, string|nil +---@field write_string fun(self: StreamBackend, data: string): integer|nil, string|nil +---@field on_readable fun(self: StreamBackend, task: Task): WaitToken +---@field on_writable fun(self: StreamBackend, task: Task): WaitToken +---@field close fun(self: StreamBackend): boolean, string|nil +---@field seek fun(self: StreamBackend, whence: string, offset: integer): integer|nil, string|nil +---@field nonblock fun(self: StreamBackend)|nil +---@field block fun(self: StreamBackend)|nil +---@field filename string|nil +---@field fileno fun(self: StreamBackend): integer|nil -- optional, used by file.tmpfile + +--- Buffered IO stream over a StreamBackend. +---@class Stream +---@field io StreamBackend|nil +---@field rx RingBuf|nil +---@field tx RingBuf|nil +---@field line_buffering boolean # flag only; behaviour is caller-defined +---@field flush_output fun(self: Stream)|nil +---@field flush fun(self: Stream)|nil +---@field rename fun(self: Stream, newname: string): boolean|nil, string|nil +local Stream = {} +Stream.__index = Stream + +local DEFAULT_BUFFER_SIZE = 2^12 + +--- Open a new Stream over a backend. +---@param io_backend StreamBackend +---@param readable? boolean # default true +---@param writable? boolean # default true +---@param bufsize? integer # per-direction buffer size +---@return Stream +local function open(io_backend, readable, writable, bufsize) + local s = setmetatable({ + io = io_backend, + line_buffering = false, + }, Stream) + + if readable ~= false then + s.rx = RingBuf.new(bufsize or DEFAULT_BUFFER_SIZE) + end + if writable ~= false then + s.tx = RingBuf.new(bufsize or DEFAULT_BUFFER_SIZE) + end + + return s +end + +--- Check whether a value is a Stream instance. +---@param x any +---@return boolean +local function is_stream(x) + return type(x) == 'table' and getmetatable(x) == Stream +end + +function Stream:nonblock() + if self.io and self.io.nonblock then + self.io:nonblock() + end +end + +function Stream:block() + if self.io and self.io.block then + self.io:block() + end +end + +---------------------------------------------------------------------- +-- Internal step machines +---------------------------------------------------------------------- + +---@param stream Stream +---@param buf LinearBuf +---@param min integer +---@param max integer +---@param terminator string|nil +---@return fun(): boolean, ... # step() +local function make_read_step(stream, buf, min, max, terminator) + local tally = 0 + + local function adjust_for_terminator() + if not terminator then return end + local loc = stream.rx:find(terminator) + if loc then + local final = tally + loc + #terminator + min, max = final, final + end + end + + return function() + while true do + adjust_for_terminator() + + local avail = stream.rx:read_avail() + if avail > 0 and tally < max then + local need = math.min(avail, max - tally) + local chunk = stream.rx:take(need) + if #chunk > 0 then + buf:append(chunk) + tally = tally + #chunk + if tally >= min then + return true, buf, tally + end + end + end + + if not (stream.io and stream.io.read_string) then + return true, buf, tally, "backend does not support read_string" + end + + local room = stream.rx:write_avail() + if room <= 0 then + if tally >= min then + return true, buf, tally + end + return true, buf, tally, "buffer capacity exhausted" + end + + local data, err = stream.io:read_string(room) + if err then + return true, buf, tally, err + end + if data == nil then + if tally >= min then + return true, buf, tally + end + return false + end + if #data == 0 then + return true, buf, tally + end + + stream.rx:put(data) + end + end +end + +---@param stream Stream +---@param src_str string +---@return fun(): boolean, ... # step() +local function make_write_step(stream, src_str) + local offset = 0 + local len = #src_str + + return function() + if offset == len then + return true, len + end + + if not (stream.io and stream.io.write_string) then + return true, offset, "backend does not support write_string" + end + + local chunk = src_str:sub(offset + 1) + local n, err = stream.io:write_string(chunk) + if err then + return true, offset, err + end + if n == nil then + return false + end + if n == 0 then + return true, offset + end + + offset = offset + n + if offset >= len then + return true, offset + end + return false + end +end + +---------------------------------------------------------------------- +-- Core stream primitives +---------------------------------------------------------------------- + +---@param buf LinearBuf +---@param opts? { min?: integer, max?: integer, terminator?: string, eof_ok?: boolean } +---@return Op +function Stream:read_into_op(buf, opts) + assert(self.rx, "stream is not readable") + + opts = opts or {} + local min = opts.min or 1 + local max = opts.max or min + local terminator = opts.terminator + local eof_ok = not not opts.eof_ok + + local step = make_read_step(self, buf, min, max, terminator) + + local function wrap(ret_buf, cnt, err) + if cnt == 0 and not eof_ok then + return nil, cnt, err + end + return ret_buf, cnt, err + end + + return wait.waitable( + function(task) + return self.io:on_readable(task) + end, + step, + wrap + ) +end + +---@param opts? { min?: integer, max?: integer, terminator?: string, eof_ok?: boolean } +---@return Op +function Stream:read_string_op(opts) + local buf = LinearBuf.new() + local ev = self:read_into_op(buf, opts) + + return ev:wrap(function(ret_buf, cnt, err) + if not ret_buf then + return nil, cnt, err + end + local s = ret_buf:tostring() + if cnt == 0 and s == "" then + return nil, 0, err + end + return s, cnt, err + end) +end + +---@param str string +---@return Op +function Stream:write_string_op(str) + assert(self.tx, "stream is not writable") + assert(type(str) == "string", "write_string_op expects a string") + + local step = make_write_step(self, str) + + local function wrap(bytes_written, err) + return bytes_written, err + end + + return wait.waitable( + function(task) + return self.io:on_writable(task) + end, + step, + wrap + ) +end + +---@return Op +function Stream:flush_output_op() + -- Unbuffered write path: there is nothing to flush at the Stream level. + -- Writes only return once the backend has accepted the data (or errored). + return op.always(0, nil) +end + +---------------------------------------------------------------------- +-- Derived per-stream ops +---------------------------------------------------------------------- + +---@param opts? { terminator?: string, keep_terminator?: boolean, max?: integer } +---@return Op -- when performed: line:string|nil, err:string|nil +function Stream:read_line_op(opts) + assert(self.rx, "stream is not readable") + + opts = opts or {} + local term = opts.terminator or "\n" + local keep_term = not not opts.keep_terminator + local max_bytes = opts.max or math.huge + + local ev = self:read_string_op{ + min = max_bytes, + max = max_bytes, + terminator = term, + eof_ok = true, + } + + return ev:wrap(function(s, cnt, err) + if err then return nil, err end + + if not s or cnt == 0 then return nil, nil end + + if not keep_term and #term > 0 and s:sub(-#term) == term then + s = s:sub(1, -#term - 1) + end + + return s, nil + end) +end + +---@param n integer +---@return Op -- when performed: data:string|nil, err:string|nil +function Stream:read_exactly_op(n) + assert(type(n) == "number" and n >= 0, "read_exactly_op: n must be non-negative") + + return self:read_string_op{ + min = n, + max = n, + eof_ok = false, + }:wrap(function(s, cnt, err) + if err then return nil, err end + + if not s or cnt ~= n then return nil, "short read" end + + return s, nil + end) +end + +---@return Op -- when performed: data:string, err:string|nil +function Stream:read_all_op() + assert(self.rx, "stream is not readable") + + -- Read until EOF or error in a single op. + local ev = self:read_string_op{ + min = math.huge, + max = math.huge, + eof_ok = true, + } + + return ev:wrap(function(s, _, err) + -- read_string_op returns: + -- s == nil, cnt == 0 : no data at all (EOF or error-before-data) + -- s ~= nil, cnt > 0 : some data read, possibly with err + if not s then return "", err end -- Normalise “no data” to empty string. + + return s, err + end) +end + +---------------------------------------------------------------------- +-- Misc and lifecycle +---------------------------------------------------------------------- + +function Stream:flush_input() + if self.rx then + self.rx:reset() + end +end + +---@return boolean ok, string|nil err +function Stream:close() + local ok, err + if self.io and self.io.close then + ok, err = self.io:close() + else + ok, err = true, nil + end + self.rx, self.tx, self.io = nil, nil, nil + return ok, err +end + +---@param whence? string +---@param offset? integer +---@return integer|nil pos, string|nil err +function Stream:seek(whence, offset) + if not (self.io and self.io.seek) then + return nil, 'stream is not seekable' + end + whence = whence or "cur" + offset = offset or 0 + return self.io:seek(whence, offset) +end + +---@param mode '"no"'|'"line"'|'"full"' +---@param _ any +---@return Stream +function Stream:setvbuf(mode, _) + if mode == 'no' then + self.line_buffering = false + elseif mode == 'line' then + self.line_buffering = true + elseif mode == 'full' then + self.line_buffering = false + else + error('bad mode: ' .. tostring(mode)) + end + return self +end + +---@return string|nil +function Stream:filename() + return self.io and self.io.filename +end + +---------------------------------------------------------------------- +-- Synchronous convenience wrappers +---------------------------------------------------------------------- + +function Stream:read_string(opts) + return perform(self:read_string_op(opts)) +end + +function Stream:read_all() + return perform(self:read_all_op()) +end + +function Stream:read_exactly(n) + return perform(self:read_exactly_op(n)) +end + +function Stream:write_string(str) + return perform(self:write_string_op(str)) +end + +function Stream:flush_output() + return perform(self:flush_output_op()) +end + +function Stream:flush() + return self:flush_output() +end + +---------------------------------------------------------------------- +-- Lua compatibility surface +---------------------------------------------------------------------- + +---@param fmt? string|integer +---@return Op -- when performed: value|nil, err|string|nil +function Stream:read_op(fmt) + assert(self.rx, "stream is not readable") + + local t = type(fmt) + + -- Default / "*l": line without terminator + if fmt == nil or fmt == "*l" then return self:read_line_op() end + + -- "*L": line with terminator + if fmt == "*L" then return self:read_line_op{ keep_terminator = true } end + + -- "*a": read all + if fmt == "*a" then return self:read_all_op() end + + -- numeric: read up to n bytes + if t == "number" then + local n = fmt + assert(n >= 0, "read_op: n must be non-negative") + + -- Lua: f:read(0) returns "" immediately + if n == 0 then return op.always("", nil) end + + -- read up to n bytes; allow EOF + local ev = self:read_string_op{ min = 1, max = n, eof_ok = true } + + return ev:wrap(function(s, cnt, err) + if err then return nil, err end + if not s or cnt == 0 then return nil, nil end -- EOF before any data + return s, nil + end) + else + error("read_op: invalid format " .. tostring(fmt)) + end +end + +function Stream:read(fmt) + return perform(self:read_op(fmt)) +end + +---@param ... any +---@return Op -- when performed: bytes_written:integer, err:string|nil +function Stream:write_op(...) + assert(self.tx, "stream is not writable") + + local n = select("#", ...) + if n == 0 then + -- Match the “no-op but succeed” flavour. + return op.always(0, nil) + end + + local parts = {} + for i = 1, n do + local v = select(i, ...) + -- Follow Lua’s io.write behaviour: tostring each argument. + parts[i] = (type(v) == "string") and v or tostring(v) + end + + local s = table.concat(parts) + return self:write_string_op(s) +end + +function Stream:write(...) + return perform(self:write_op(...)) +end + +---------------------------------------------------------------------- +-- Module-level helpers +---------------------------------------------------------------------- + +--- Race a single line read across multiple named streams. +--- +--- When performed, returns: +--- name : string +--- line : string|nil +--- err : string|nil +---@param named_streams table +---@param opts? { terminator?: string, keep_terminator?: boolean, max?: integer } +---@return Op +local function merge_lines_op(named_streams, opts) + local arms = {} + for name, s in pairs(named_streams) do + arms[name] = s:read_line_op(opts) + end + return op.named_choice(arms) +end + +---------------------------------------------------------------------- +-- Public API +---------------------------------------------------------------------- + +return { + open = open, + is_stream = is_stream, + merge_lines_op = merge_lines_op, +} diff --git a/tests/test_io-mem.lua b/tests/test_io-mem.lua new file mode 100644 index 0000000..1481b93 --- /dev/null +++ b/tests/test_io-mem.lua @@ -0,0 +1,279 @@ +-- tests/test_stream_mem.lua +-- +-- Integration tests for: +-- - fibers.io.mem_backend +-- - fibers.io.stream +-- +-- Uses the real scheduler, ops and wait/waitset machinery. +print('testing: fibers.io.mem_stream') + +-- look one level up +package.path = "../src/?.lua;" .. package.path + + +local fibers = require 'fibers' +local stream = require 'fibers.io.stream' +local mem = require 'fibers.io.mem_backend' + + +local function assert_eq(actual, expected, msg) + if actual ~= expected then + error(string.format("%s (expected: %s, got: %s)", msg or "assert_eq failed", + tostring(expected), tostring(actual))) + end +end + +local function assert_nil(v, msg) + if v ~= nil then + error(string.format("%s (expected nil, got: %s)", msg or "assert_nil failed", tostring(v))) + end +end + +local function assert_true(v, msg) + if not v then + error(msg or "assert_true failed") + end +end + +---------------------------------------------------------------------- +-- Test 1: simple one-shot write and read +---------------------------------------------------------------------- + +local function test_simple_read_write() + local a_io, b_io = mem.pipe(1024) + + local a = stream.open(a_io, true, true) + local b = stream.open(b_io, true, true) + + local payload = "hello world" + + -- Writer: write once from A to B + fibers.spawn(function() + local ev = a:write_string_op(payload) + local n, err = fibers.perform(ev) + assert_nil(err, "simple write: unexpected error") + assert_eq(n, #payload, "simple write: wrong byte count") + end) + + -- Reader: read exactly len(payload) bytes + local ev = b:read_string_op{ + min = #payload, + max = #payload, + eof_ok = true, + } + + local s, cnt, err = fibers.perform(ev) + + assert_nil(err, "simple read: unexpected error") + assert_eq(cnt, #payload, "simple read: wrong count") + assert_eq(s, payload, "simple read: wrong data") + + a:close() + b:close() +end + +---------------------------------------------------------------------- +-- Test 2: backpressure and partial progress +-- Small buffer so writer must make progress in steps. +---------------------------------------------------------------------- + +local function test_backpressure_and_partial() + -- Small buffer forces backpressure. + local a_io, b_io = mem.pipe(4) + + local a = stream.open(a_io, true, true) + local b = stream.open(b_io, true, true) + + local payload = "abcdef" -- length 6, buffer capacity 4 + + -- Reader: accumulate until we have the whole payload + fibers.spawn(function() + local collected = {} + local total = 0 + + while total < #payload do + -- Read at least 1 byte, at most 3 each time. + local ev = b:read_string_op{ + min = 1, + max = 3, + eof_ok = true, + } + + local s, cnt, err = fibers.perform(ev) + assert_nil(err, "backpressure read: unexpected error") + + if s == nil then + -- EOF; should not happen before we see all bytes. + break + end + + assert_true(cnt > 0, "backpressure read: zero-length read with data?") + table.insert(collected, s) + total = total + cnt + end + + local joined = table.concat(collected) + assert_eq(joined, payload, "backpressure read: wrong data") + end) + + -- Writer: write the full payload as one op + local ev = a:write_string_op(payload) + local n, err = fibers.perform(ev) + + assert_nil(err, "backpressure write: unexpected error") + assert_eq(n, #payload, "backpressure write: wrong byte count") + + a:close() + b:close() +end + +---------------------------------------------------------------------- +-- Test 3: EOF behaviour +---------------------------------------------------------------------- + +local function test_eof_behaviour() + local a_io, b_io = mem.pipe(1024) + + local a = stream.open(a_io, true, true) + local b = stream.open(b_io, true, true) + + local payload = "end" + + -- Write then close A's half. + fibers.spawn(function() + local ev = a:write_string_op(payload) + local n, err = fibers.perform(ev) + assert_nil(err, "EOF write: unexpected error") + assert_eq(n, #payload, "EOF write: wrong byte count") + a:close() + end) + + -- First read should get the payload. + local ev1 = b:read_string_op{ + min = #payload, + max = #payload, + eof_ok = true, + } + + local s1, cnt1, err1 = fibers.perform(ev1) + assert_nil(err1, "EOF read(1): unexpected error") + assert_eq(cnt1, #payload, "EOF read(1): wrong count") + assert_eq(s1, payload, "EOF read(1): wrong data") + + -- Second read should see EOF. For read_string_op: + -- EOF with no data → (nil, 0, err|nil) + local ev2 = b:read_string_op{ + min = 1, + max = 16, + eof_ok = true, + } + + local s2, cnt2, err2 = fibers.perform(ev2) + -- EOF is not treated as an error at this layer. + assert_nil(err2, "EOF read(2): unexpected error") + assert_nil(s2, "EOF read(2): expected nil string at EOF") + assert_eq(cnt2, 0, "EOF read(2): expected count == 0 at EOF") + + b:close() +end + +---------------------------------------------------------------------- +-- Test 4: line-style read using terminator +---------------------------------------------------------------------- + +local function test_line_terminator() + local a_io, b_io = mem.pipe(1024) + + local a = stream.open(a_io, true, true) + local b = stream.open(b_io, true, true) + + local data = "line1\nline2\n" + + fibers.spawn(function() + local ev = a:write_string_op(data) + local n, err = fibers.perform(ev) + assert_nil(err, "line write: unexpected error") + assert_eq(n, #data, "line write: wrong byte count") + a:close() + end) + + -- Read up to and including first "\n" + local ev1 = b:read_string_op{ + min = 1, + max = #data, + terminator = "\n", + eof_ok = true, + } + + local s1, cnt1, err1 = fibers.perform(ev1) + assert_nil(err1, "line read(1): unexpected error") + assert_eq(s1, "line1\n", "line read(1): wrong data") + assert_eq(cnt1, #s1, "line read(1): wrong count") + + -- Read up to and including second "\n" + local ev2 = b:read_string_op{ + min = 1, + max = #data, + terminator = "\n", + eof_ok = true, + } + + local s2, cnt2, err2 = fibers.perform(ev2) + assert_nil(err2, "line read(2): unexpected error") + assert_eq(s2, "line2\n", "line read(2): wrong data") + assert_eq(cnt2, #s2, "line read(2): wrong count") + + -- Third read should see EOF + local ev3 = b:read_string_op{ + min = 1, + max = 16, + terminator = "\n", + eof_ok = true, + } + + local s3, cnt3, err3 = fibers.perform(ev3) + assert_nil(err3, "line read(3): unexpected error") + assert_nil(s3, "line read(3): expected nil at EOF") + assert_eq(cnt3, 0, "line read(3): expected count == 0 at EOF") + + b:close() +end + +---------------------------------------------------------------------- +-- Test 5: write after peer close +---------------------------------------------------------------------- + +local function test_write_after_peer_close() + local a_io, b_io = mem.pipe(1024) + + local a = stream.open(a_io, true, true) + local b = stream.open(b_io, true, true) + + -- Close B immediately. + b:close() + + -- Writing from A should report "closed" from the backend. + local ev = a:write_string_op("x") + local _, err = fibers.perform(ev) + + -- Depending on exact semantics, n may be 0 or nil; err should be "closed". + assert_eq(err, "closed", "write after close: expected 'closed' error") + + a:close() +end + +---------------------------------------------------------------------- +-- Main test runner +---------------------------------------------------------------------- + +local function main() + test_simple_read_write() + test_backpressure_and_partial() + test_eof_behaviour() + test_line_terminator() + test_write_after_peer_close() +end + +fibers.run(main) + +print("OK\tstream + mem_backend tests passed") diff --git a/tests/test_io-stream.lua b/tests/test_io-stream.lua new file mode 100644 index 0000000..fcefa96 --- /dev/null +++ b/tests/test_io-stream.lua @@ -0,0 +1,124 @@ +-- tests/test_stream_mem.lua +-- +-- Synthetic tests for fibers.io.stream using an in-memory backend. +print('testing: fibers.io.stream') + +-- look one level up +package.path = "../src/?.lua;" .. package.path + +local fibers = require 'fibers' +local stream = require 'fibers.io.stream' +local wait = require 'fibers.wait' +local runtime = require 'fibers.runtime' +local sleep = require 'fibers.sleep' + +-- In-memory duplex backend with partial I/O and readiness notifications. +local function make_stream_pair() + local shared = { + buf = "", + closed = false, + waitset = wait.new_waitset(), -- key "rd" for readability + } + + local rd_io = { shared = shared } + local wr_io = { shared = shared } + + -- Backend read: string-or-nil as per StreamBackend contract. + function rd_io:read_string(max) + if #self.shared.buf == 0 then + if self.shared.closed then + return "", nil -- EOF + end + return nil, nil -- would block + end + max = max or 1 + -- Deliberately read at most 1 byte to exercise partial reads. + local n = math.min(1, max, #self.shared.buf) + local s = self.shared.buf:sub(1, n) + self.shared.buf = self.shared.buf:sub(n + 1) + return s, nil + end + + -- Backend write: always writes 1 byte to exercise partial writes. + function wr_io:write_string(str) + if self.shared.closed then + return nil, "closed" + end + if #str == 0 then + return 0, nil + end + local n = 1 + local ch = str:sub(1, n) + shared.buf = shared.buf .. ch + -- Notify any waiting readers. + shared.waitset:notify_all("rd", runtime.current_scheduler) + return n, nil + end + + function rd_io:on_readable(task) + return shared.waitset:add("rd", task) + end + + function wr_io:on_writable(task) + -- Always writable: just schedule immediately. + runtime.current_scheduler:schedule(task) + return { unlink = function() end } + end + + function rd_io:close() + shared.closed = true + end + + function wr_io:close() + shared.closed = true + end + + -- Optional methods used by Stream; safe no-ops here. + function rd_io:seek() return nil, "not seekable" end + function wr_io:seek() return nil, "not seekable" end + function rd_io:nonblock() end + function rd_io:block() end + function wr_io:nonblock() end + function wr_io:block() end + + local rd = stream.open(rd_io, true, false) + local wr = stream.open(wr_io, false, true) + return rd, wr, shared +end + +local function test() + local rd, wr, shared = make_stream_pair() + + wr:setvbuf('line') + assert(wr.line_buffering == true, "setvbuf('line') did not set line_buffering") + + local message = "hello, world\n" + + -- Writer runs in a fibre, so the first read will block and use on_readable. + fibers.spawn(function() + sleep.sleep(0.01) + local n, err = wr:write(message) + assert(err == nil, "write error: " .. tostring(err)) + assert(n == #message, "write wrote " .. tostring(n) .. " bytes, expected " .. #message) + wr:close() + end) + + -- Read full line including terminator (exercise waitable + partial I/O). + local line, err = rd:read("*L") + assert(err == nil, "read('*L') returned error: " .. tostring(err)) + assert(line == message, + ("read('*L') returned %q, expected %q"):format(tostring(line), tostring(message))) + + rd:close() + + -- After close, no readers should remain registered. + assert(shared.waitset:size("rd") == 0, "waitset still has readers after close") +end + +local function main() + test() +end + +fibers.run(main) + +print('selftest: ok') From 2acd9a4f745abf8a76797101fba69381f30cdc9c Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Fri, 28 Nov 2025 08:57:35 +0000 Subject: [PATCH 049/138] adds poller, fd_backend, file and socket + tests --- src/fibers/io/epoll.lua | 125 ++++++++++ src/fibers/io/fd_backend.lua | 195 +++++++++++++++ src/fibers/io/file.lua | 274 ++++++++++++++++++++ src/fibers/io/poller.lua | 145 +++++++++++ src/fibers/io/socket.lua | 333 +++++++++++++++++++++++++ tests/test_io-file.lua | 466 +++++++++++++++++++++++++++++++++++ tests/test_io-poller.lua | 39 +++ tests/test_io-socket.lua | 87 +++++++ 8 files changed, 1664 insertions(+) create mode 100644 src/fibers/io/epoll.lua create mode 100644 src/fibers/io/fd_backend.lua create mode 100644 src/fibers/io/file.lua create mode 100644 src/fibers/io/poller.lua create mode 100644 src/fibers/io/socket.lua create mode 100644 tests/test_io-file.lua create mode 100644 tests/test_io-poller.lua create mode 100644 tests/test_io-socket.lua diff --git a/src/fibers/io/epoll.lua b/src/fibers/io/epoll.lua new file mode 100644 index 0000000..14275ca --- /dev/null +++ b/src/fibers/io/epoll.lua @@ -0,0 +1,125 @@ +-- (c) Snabb project +-- (c) Jangala + +-- Use of this source code is governed by the XXXXXXXXX license; see COPYING. + +-- Epoll. +---@module 'fibers.io.epoll' + +local sc = require 'fibers.utils.syscall' +local bit = rawget(_G, "bit") or require 'bit32' + +--- Epoll handle and state. +---@class Epoll +---@field epfd integer # epoll file descriptor +---@field active_events table # fd -> current event mask +---@field maxevents integer # current maximum events per epoll_wait +local Epoll = {} + +---@type integer +local INITIAL_MAXEVENTS = 8 + +--- Create a new Epoll instance. +---@return Epoll +local function new() + local ret = { + epfd = assert(sc.epoll_create()), + active_events = {}, -- fd -> mask + maxevents = INITIAL_MAXEVENTS, + } + return setmetatable(ret, { __index = Epoll }) +end + +---@type integer +local RD = sc.EPOLLIN + sc.EPOLLRDHUP +---@type integer +local WR = sc.EPOLLOUT +---@type integer +local RDWR = RD + WR +---@type integer +local ERR = sc.EPOLLERR + sc.EPOLLHUP + +--- Add or modify interest for a file descriptor. +--- +--- The descriptor is registered with EPOLLONESHOT; after an event +--- fires it becomes inactive until re-armed via this method. +---@param s integer # file descriptor +---@param events integer # epoll event mask +function Epoll:add(s, events) + -- local fd = type(s) == 'number' and s or sc.fileno(s) + local fd = s + local active = self.active_events[fd] or 0 + local eventmask = bit.bor(events, active, sc.EPOLLONESHOT) + local ok, _ = sc.epoll_modify(self.epfd, fd, eventmask) + if not ok then + assert(sc.epoll_register(self.epfd, fd, eventmask)) + end + self.active_events[fd] = eventmask +end + +--- Wait for events. +--- +--- Returns a map of fd -> event mask. EINTR is treated as benign and +--- yields an empty table. +---@param timeout? integer # timeout in milliseconds (default 0) +---@return table events, string|nil err +function Epoll:poll(timeout) + -- Returns a table, an iterator would be more efficient. + local events, err, errno = sc.epoll_wait(self.epfd, timeout or 0, self.maxevents) + if not events then + -- Treat EINTR as a benign interruption and report no events. + if errno == sc.EINTR then + return {}, nil + end + -- Other errors are considered fatal at this level. + error(err) + end + local count = 0 + -- Since we add fd's with EPOLL_ONESHOT, now that the event has + -- fired, the fd is now deactivated. Record that fact. + for fd, _ in pairs(events) do + count = count + 1 + self.active_events[fd] = nil + end + if count == self.maxevents then + -- If we received `maxevents' events, it means that probably there + -- are more active fd's in the queue that we were unable to + -- receive. Expand our event buffer in that case. + self.maxevents = self.maxevents * 2 + end + return events, err +end + +--- Remove interest in a file descriptor. +--- +--- ENOENT/EBADF are treated as benign and only clear bookkeeping. +---@param fd integer +function Epoll:del(fd) + local ok, err, errno = sc.epoll_unregister(self.epfd, fd) + if not ok then + -- It is possible to see ENOENT/EBADF here if the fd was + -- already closed or never registered. Treat those as benign + -- and just clear our bookkeeping. + if errno == sc.ENOENT or errno == sc.EBADF then + self.active_events[fd] = nil + return + end + error(err) + end + self.active_events[fd] = nil +end + +--- Close the epoll instance. +function Epoll:close() + sc.epoll_close(self.epfd) + self.epfd = nil +end + +return { + new = new, + + RD = RD, + WR = WR, + RDWR = RDWR, + ERR = ERR, +} diff --git a/src/fibers/io/fd_backend.lua b/src/fibers/io/fd_backend.lua new file mode 100644 index 0000000..9f8d809 --- /dev/null +++ b/src/fibers/io/fd_backend.lua @@ -0,0 +1,195 @@ +-- fibers/io/fd_backend.lua +-- +-- FD-backed backend for files/sockets. +-- +-- Backend contract towards fibers.io.stream: +-- * kind() -> "fd" +-- * fileno() -> fd +-- * read_string(max) -> str|nil, err|nil +-- - str == nil : would block +-- - str == "" : EOF +-- * write_string(str) -> n|nil, err|nil +-- - n == nil : would block +-- * on_readable(task) -> token{ unlink = fn } +-- * on_writable(task) -> token{ unlink = fn } +-- * close() -> ok, err|nil +-- * seek(whence, off) -> pos|nil, err|nil +-- - whence: "set" | "cur" | "end" +---@module 'fibers.io.fd_backend' + +local sc = require 'fibers.utils.syscall' +local poller = require 'fibers.io.poller' + +--- FD-backed stream backend for use with fibers.io.stream. +---@class FdBackend : StreamBackend +---@field filename string|nil + +--- Create a new FD backend instance. +---@param fd integer|nil +---@param opts? { filename?: string } +---@return FdBackend +local function new(fd, opts) + opts = opts or {} + + -- Ensure the descriptor is in non-blocking mode. + if fd ~= nil then + sc.set_nonblock(fd) + end + + ---@class FdBackend + local B = { + filename = opts.filename, + } + + --- Return backend kind identifier. + ---@return '"fd"' + function B:kind() + return "fd" + end + + --- Return underlying file descriptor number, or nil if closed. + ---@return integer|nil + function B:fileno() + return fd + end + + -------------------------------------------------------------------- + -- String-oriented I/O, for use by fibers.io.stream + -------------------------------------------------------------------- + + --- Read up to max bytes as a Lua string. + --- + --- Returns: + --- s : string ("" at EOF) or nil + --- err : string or nil + --- + --- Semantics: + --- * s == nil, err == nil : would block + --- * s == nil, err ~= nil : hard error + --- * s == "" : EOF + --- * s ~= "" : data + ---@param max? integer + ---@return string|nil s, string|nil err + function B:read_string(max) + if not fd then + return nil, "closed" + end + + max = max or 4096 + + local s, err, errno = sc.read(fd, max) + if s == nil then + -- Would block. + if errno == sc.EAGAIN or errno == sc.EWOULDBLOCK then + return nil, nil + end + -- Hard error. + return nil, err or ("errno " .. tostring(errno)) + end + + -- Success, including EOF when s == "". + return s, nil + end + + --- Write a Lua string. + --- + --- Returns: + --- n : number of bytes written, or nil + --- err : string or nil + --- + --- Semantics: + --- * n == nil, err == nil : would block + --- * n == nil, err ~= nil : hard error + --- * n >= 0 : bytes written + ---@param str string + ---@return integer|nil n, string|nil err + function B:write_string(str) + if not fd then + return nil, "closed" + end + + local n, err, errno = sc.write(fd, str) + if n == nil then + if errno == sc.EAGAIN or errno == sc.EWOULDBLOCK then + -- Would block. + return nil, nil + end + -- Hard error. + return nil, err or ("errno " .. tostring(errno)) + end + + return n, nil + end + + -------------------------------------------------------------------- + -- Seek (used by Stream:seek) + -------------------------------------------------------------------- + + local SEEK = { + set = sc.SEEK_SET, + cur = sc.SEEK_CUR, + ["end"] = sc.SEEK_END, + } + + --- Seek within the file descriptor. + --- + --- whence: "set" | "cur" | "end" + --- off : byte offset + --- + --- Returns: + --- pos : new offset, or nil + --- err : string or nil + ---@param whence '"set"'|'"cur"'|'"end"' + ---@param off integer + ---@return integer|nil pos, string|nil err + function B:seek(whence, off) + if not fd then + return nil, "closed" + end + + local w = SEEK[whence] + if not w then + return nil, "bad whence: " .. tostring(whence) + end + + return sc.lseek(fd, off, w) + end + + -------------------------------------------------------------------- + -- Readiness registration (for waitable/poller) + -------------------------------------------------------------------- + + --- Register for readability events on this fd. + ---@param task Task + ---@return WaitToken + function B:on_readable(task) + return poller.get():wait(assert(fd, "closed fd"), "rd", task) + end + + --- Register for writability events on this fd. + ---@param task Task + ---@return WaitToken + function B:on_writable(task) + return poller.get():wait(assert(fd, "closed fd"), "wr", task) + end + + -------------------------------------------------------------------- + -- Lifecycle + -------------------------------------------------------------------- + + --- Close the backend and underlying fd. + ---@return boolean ok, string|nil err + function B:close() + if fd == nil then + return true + end + + local ok, err = sc.close(fd) + fd = nil + return ok, err + end + + return B +end + +return { new = new } diff --git a/src/fibers/io/file.lua b/src/fibers/io/file.lua new file mode 100644 index 0000000..4cd2996 --- /dev/null +++ b/src/fibers/io/file.lua @@ -0,0 +1,274 @@ +-- fibers/io/file.lua +-- +-- File-backed streams on top of fd_backend + stream. +-- +-- Exposes: +-- fdopen(fd[, flags[, filename]]) -> Stream +-- open(filename[, mode[, perms]]) -> Stream | nil, err +-- pipe() -> read_stream, write_stream +-- mktemp(prefix[, perms]) -> fd, tmpname +-- tmpfile([perms[, tmpdir]]) -> Stream (auto-unlink on close) +-- init_nonblocking(fd) -> sets fd non-blocking (compat) +---@module 'fibers.io.file' + +local sc = require 'fibers.utils.syscall' +local stream = require 'fibers.io.stream' +local fd_back = require 'fibers.io.fd_backend' + +local bit = rawget(_G, "bit") or require 'bit32' + +-- Ignore SIGPIPE so write() failures are reported via errno. +sc.signal(sc.SIGPIPE, sc.SIG_IGN) + +---------------------------------------------------------------------- +-- Mode and permission tables +---------------------------------------------------------------------- + +---@type table +local modes = { + r = sc.O_RDONLY, + w = bit.bor(sc.O_WRONLY, sc.O_CREAT, sc.O_TRUNC), + a = bit.bor(sc.O_WRONLY, sc.O_CREAT, sc.O_APPEND), + ["r+"] = sc.O_RDWR, + ["w+"] = bit.bor(sc.O_RDWR, sc.O_CREAT, sc.O_TRUNC), + ["a+"] = bit.bor(sc.O_RDWR, sc.O_CREAT, sc.O_APPEND), +} + +do + local binary_modes = {} + for k, v in pairs(modes) do + binary_modes[k .. "b"] = v + end + for k, v in pairs(binary_modes) do + modes[k] = v + end +end + +---@type table +local permissions = {} +permissions["rw-r--r--"] = bit.bor(sc.S_IRUSR, sc.S_IWUSR, sc.S_IRGRP, sc.S_IROTH) +permissions["rw-rw-rw-"] = bit.bor(permissions["rw-r--r--"], sc.S_IWGRP, sc.S_IWOTH) + +---------------------------------------------------------------------- +-- Internal: wrap fd as a Stream +---------------------------------------------------------------------- + +--- Wrap an fd in a Stream using fd_backend. +---@param fd integer +---@param flags? integer +---@param filename? string +---@return Stream +local function fdopen(fd, flags, filename) + -- If flags are not supplied, query them. + if flags == nil then + flags = assert(sc.fcntl(fd, sc.F_GETFL)) + else + -- Historically needed for some 32-bit environments. + if sc.O_LARGEFILE then + flags = bit.bor(flags, sc.O_LARGEFILE) + end + end + + -- Determine readability / writability from flags. + local readable, writable = false, false + local mode = bit.band(flags, sc.O_ACCMODE) + if mode == sc.O_RDONLY or mode == sc.O_RDWR then + readable = true + end + if mode == sc.O_WRONLY or mode == sc.O_RDWR then + writable = true + end + + local stat = sc.fstat(fd) + local blksize = stat and stat.st_blksize or nil + + local io = fd_back.new(fd, { filename = filename }) + + return stream.open(io, readable, writable, blksize) +end + +---------------------------------------------------------------------- +-- Open by filename +---------------------------------------------------------------------- + +--- Open a file by name as a Stream. +---@param filename string +---@param mode? string +---@param perms? integer|string +---@return Stream|nil f, string|nil err +local function open_file(filename, mode, perms) + mode = mode or "r" + local flags = modes[mode] + if not flags then + return nil, "invalid mode: " .. tostring(mode) + end + + -- Default permissions; umask still applies. + if perms == nil then + perms = permissions["rw-rw-rw-"] + else + perms = permissions[perms] or perms + end + + local fd, err = sc.open(filename, flags, perms) + if not fd then + return nil, err + end + + return fdopen(fd, flags, filename) +end + +---------------------------------------------------------------------- +-- Pipes +---------------------------------------------------------------------- + +--- Create a unidirectional pipe as two Streams (read, write). +---@return Stream r_stream, Stream w_stream +local function pipe() + local rd, wr = assert(sc.pipe()) + local r_stream = fdopen(rd, sc.O_RDONLY) + local w_stream = fdopen(wr, sc.O_WRONLY) + return r_stream, w_stream +end + +---------------------------------------------------------------------- +-- mktemp / tmpfile +---------------------------------------------------------------------- + +--- Create a temporary file with a unique name. +---@param prefix string +---@param perms? integer +---@return integer|nil fd, string tmpname_or_err +local function mktemp(prefix, perms) + perms = perms or permissions["rw-r--r--"] + + -- Caller is responsible for seeding math.random appropriately. + local start = math.random(1e7) + local tmpnam, fd, err + + for i = start, start + 10 do + tmpnam = prefix .. "." .. i + fd, err = sc.open(tmpnam, bit.bor(sc.O_CREAT, sc.O_RDWR, sc.O_EXCL), perms) + if fd then + return fd, tmpnam + end + end + + -- Environmental failure: report as (nil, err) rather than raising. + return nil, ("failed to create temporary file %s: %s"):format( + tostring(tmpnam), + tostring(err) + ) +end + +--- Create a temporary file wrapped as a Stream, with unlink-on-close semantics. +---@param perms? integer +---@param tmpdir? string +---@return Stream|nil f, string|nil err +local function tmpfile(perms, tmpdir) + perms = perms or permissions["rw-r--r--"] + tmpdir = tmpdir or os.getenv("TMPDIR") or "/tmp" + ---@cast tmpdir string -- narrow for LuaLS + + local fd, tmpnam_or_err = mktemp(tmpdir .. "/tmp", perms) + if not fd then + -- Propagate mktemp failure as (nil, err). + return nil, tmpnam_or_err + end + + ---@type Stream + local f = fdopen(fd, sc.O_RDWR, tmpnam_or_err) + + -- We want unlink-on-close semantics by default, with a way to + -- disable that via :rename(). + local io = f.io + assert(io, "tmpfile backend missing") + ---@cast io StreamBackend + + ---@type fun(self: StreamBackend): boolean, string|nil + local old_close = io.close + + --- Rename the temporary file and disable unlink-on-close behaviour. + ---@param newname string + ---@return boolean|nil ok, string|nil err + function f:rename(newname) + -- Flush buffered data first (various stream flavours). + if self.flush_output then + self:flush_output() + elseif self.flush then + self:flush() + end + + local real_fd = io.fileno and io:fileno() or fd + if real_fd then + sc.fsync(real_fd) + end + + local fname = assert(io.filename, "tmpfile has no filename") + local ok, err = sc.rename(fname, newname) + if not ok then + -- Environmental failure: return nil, err. + return nil, ("failed to rename %s to %s: %s"):format( + tostring(fname), + tostring(newname), + tostring(err) + ) + end + + io.filename = newname + -- Disable remove-on-close: restore original close. + io.close = old_close + return true + end + + --- Close the fd and unlink the temporary file. + ---@return boolean ok, string|nil err + function io:close() + -- First close the descriptor. + local ok, err = old_close(self) + if not ok then + return ok, err + end + + local fname = assert(self.filename, "tmpfile has no filename") + -- Then unlink the temporary file. If this fails we report it, but + -- do not raise. + local ok2, err2 = sc.unlink(fname) + if not ok2 then + return false, ("failed to remove %s: %s"):format( + tostring(fname), + tostring(err2) + ) + end + + return true, nil + end + + return f +end + +---------------------------------------------------------------------- +-- Compatibility helper +---------------------------------------------------------------------- + +--- Put an fd into non-blocking mode. +---@param fd integer +---@return boolean ok, string|nil err +local function init_nonblocking(fd) + return assert(sc.set_nonblock(fd)) +end + +---------------------------------------------------------------------- +-- Public API +---------------------------------------------------------------------- + +return { + fdopen = fdopen, + open = open_file, + pipe = pipe, + mktemp = mktemp, + tmpfile = tmpfile, + init_nonblocking = init_nonblocking, + modes = modes, + permissions = permissions, +} diff --git a/src/fibers/io/poller.lua b/src/fibers/io/poller.lua new file mode 100644 index 0000000..bc0928b --- /dev/null +++ b/src/fibers/io/poller.lua @@ -0,0 +1,145 @@ +--- +-- Epoll-based poller integration for fibers. +-- +-- Provides a singleton Poller that: +-- * integrates an epoll backend with the scheduler, +-- * exposes a wait(fd, dir, task) API used by IO backends, +-- * acts both as a TaskSource and as the scheduler's event_waiter. +-- +-- schedule_tasks is used in two modes: +-- * as a normal source: non-blocking poll (timeout coerced to 0), +-- * as event_waiter: blocking poll with the scheduler's timeout. +---@module 'fibers.io.poller' + +local runtime = require 'fibers.runtime' +local epoll = require 'fibers.io.epoll' +local bit = rawget(_G, "bit") or require 'bit32' +local wait = require 'fibers.wait' + +--- Epoll-based poller registered as a scheduler TaskSource. +---@class Poller : TaskSource +---@field ep any # epoll backend handle +---@field rd Waitset # fd -> tasks waiting for read +---@field wr Waitset # fd -> tasks waiting for write +local Poller = {} +Poller.__index = Poller + +--- Create a new poller instance. +---@return Poller +local function new_poller() + return setmetatable({ + ep = epoll.new(), + rd = wait.new_waitset(), -- fd -> tasks waiting for read + wr = wait.new_waitset(), -- fd -> tasks waiting for write + }, Poller) +end + +--- Recompute epoll interest mask for a single fd from rd/wr waitsets. +---@param self Poller +---@param fd integer +local function recompute_mask(self, fd) + local need_rd = not self.rd:is_empty(fd) + local need_wr = not self.wr:is_empty(fd) + local mask = 0 + if need_rd then mask = bit.bor(mask, epoll.RD) end + if need_wr then mask = bit.bor(mask, epoll.WR) end + if mask ~= 0 then + -- ep:add is expected to behave as add-or-modify for existing fds. + self.ep:add(fd, mask) + else + self.ep:del(fd) + end +end + +--- Register a task as waiting on an fd for read or write readiness. +--- +--- The returned token's unlink() will deregister the task from the +--- relevant Waitset and keep the epoll mask in sync. +---@param fd integer +---@param dir '"rd"'|'"wr"' +---@param task Task +---@return WaitToken +function Poller:wait(fd, dir, task) + assert(type(fd) == 'number', "fd must be number") + assert(dir == "rd" or dir == "wr", "dir must be 'rd' or 'wr'") + + local ws = (dir == "rd") and self.rd or self.wr + local token = ws:add(fd, task) + + -- Ensure epoll is armed now there is at least one waiter. + recompute_mask(self, fd) + + -- Wrap unlink so we keep epoll mask in sync when buckets empty. + local original_unlink = token.unlink + local owner = self + + ---@param tok WaitToken + ---@return boolean + function token.unlink(tok) + local emptied = original_unlink(tok) + if emptied then + recompute_mask(owner, fd) + end + return emptied + end + + return token +end + +--- TaskSource hook: poll epoll and schedule any ready tasks. +--- +--- Called in two contexts: +--- * from Scheduler:schedule_tasks_from_sources(now) with timeout=nil +--- (effectively non-blocking), +--- * from Scheduler:wait_for_events(now, timeout) as event_waiter. +---@param sched Scheduler +---@param _ number|nil -- current monotonic time (unused here) +---@param timeout number|nil -- seconds +function Poller:schedule_tasks(sched, _, timeout) + -- timeout in seconds; epoll_wait in milliseconds. + if timeout == nil then timeout = 0 end + if timeout >= 0 then timeout = timeout * 1e3 end + + for fd, ev in pairs(self.ep:poll(timeout)) do + if bit.band(ev, epoll.RD + epoll.ERR) ~= 0 then + self.rd:notify_all(fd, sched) + end + if bit.band(ev, epoll.WR + epoll.ERR) ~= 0 then + self.wr:notify_all(fd, sched) + end + recompute_mask(self, fd) + end +end + +--- Event waiter hook; aliased to schedule_tasks. +--- The scheduler treats this as a blocking wait with a timeout. +Poller.wait_for_events = Poller.schedule_tasks + +--- Close the underlying epoll handle. +function Poller:close() + self.ep:close() + self.ep = nil +end + +-- Singleton wiring. +local singleton + +--- Get the process-wide poller instance, creating and registering it if needed. +---@return Poller +local function get() + if singleton then return singleton end + singleton = new_poller() + local sched = runtime.current_scheduler + if sched.add_task_source then + sched:add_task_source(singleton) + else + -- Fallback for older schedulers without add_task_source. + sched.sources = sched.sources or {} + table.insert(sched.sources, singleton) + end + return singleton +end + +return { + get = get +} diff --git a/src/fibers/io/socket.lua b/src/fibers/io/socket.lua new file mode 100644 index 0000000..a095e93 --- /dev/null +++ b/src/fibers/io/socket.lua @@ -0,0 +1,333 @@ +-- fibers/io/socket.lua +-- +-- Socket helpers on top of fd_backend + stream. +-- +-- Exposes: +-- socket(domain, stype, protocol?) -> Socket +-- listen_unix(path, opts?) -> Socket (listening AF_UNIX) +-- connect_unix(path, stype?, proto?) -> Stream +-- +-- Where Socket supports: +-- :listen_unix(path) +-- :accept_op() -> Op (resolves to Stream | nil, err) +-- :accept() -> Stream | nil, err +-- :connect_op(sa)-> Op (resolves to Stream | nil, err) +-- :connect(sa) -> Stream | nil, err +-- :connect_unix(path) / :connect_unix_op(path) +-- :close() +---@module 'fibers.io.socket' + +local sc = require 'fibers.utils.syscall' +local wait = require 'fibers.wait' +local poller_mod = require 'fibers.io.poller' +local fd_backend = require 'fibers.io.fd_backend' +local stream_mod = require 'fibers.io.stream' +local perform = require 'fibers.performer'.perform + +---@class Socket +---@field fd integer|nil +---@field listen_unix fun(self: Socket, path: string): (boolean|nil, string|nil) +---@field accept_op fun(self: Socket): Op +---@field accept fun(self: Socket): (Stream|nil, string|nil) +---@field connect_op fun(self: Socket, sa: any): Op +---@field connect fun(self: Socket, sa: any): (Stream|nil, string|nil) +---@field connect_unix_op fun(self: Socket, path: string): Op +---@field connect_unix fun(self: Socket, path: string): (Stream|nil, string|nil) +---@field close fun(self: Socket): (boolean, string|nil) +local Socket = {} +Socket.__index = Socket + +-- Ignore SIGPIPE once; write errors will be reported via errno instead. +sc.signal(sc.SIGPIPE, sc.SIG_IGN) + +--- Wrap a raw fd in a Socket. +---@param fd integer +---@return Socket +local function new_socket(fd) + -- The fd itself is left for fd_backend.new to put into non-blocking mode + -- and to register with the poller when used for I/O. + return setmetatable({ fd = fd }, Socket) +end + +--- Create a new non-blocking socket. +---@param domain integer +---@param stype integer +---@param protocol? integer +---@return Socket|nil s, any err +local function socket(domain, stype, protocol) + local fd, err = sc.socket(domain, stype, protocol or 0) + if not fd then + return nil, err + end + -- We expect non-blocking behaviour; let fd_backend enforce this when + -- the fd is wrapped. For defensive programming you can also call: + -- sc.set_nonblock(fd) + sc.set_nonblock(fd) + return new_socket(fd) +end + +---------------------------------------------------------------------- +-- Helpers: wrap an fd into a Stream +---------------------------------------------------------------------- + +--- Wrap an fd as a full-duplex Stream. +---@param fd integer +---@param filename? string +---@return Stream +local function fd_to_stream(fd, filename) + local stat = sc.fstat(fd) + local blksize = stat and stat.st_blksize or nil + + local io = fd_backend.new(fd, { filename = filename }) + -- For sockets we assume readable + writable. + return stream_mod.open(io, true, true, blksize) +end + +---------------------------------------------------------------------- +-- Listening and address helpers +---------------------------------------------------------------------- + +--- Listen on a UNIX-domain path using this Socket. +---@param path string +---@return boolean|nil ok, any err +function Socket:listen_unix(path) + assert(self.fd, "socket is closed") + + local sa = sc.getsockname(self.fd) + sa.path = path + + local ok, err = sc.bind(self.fd, sa) + if not ok then + -- Environmental failure: address in use, permissions, etc. + return nil, ("bind failed: %s"):format(tostring(err)) + end + + ok, err = sc.listen(self.fd) + if not ok then + return nil, ("listen failed: %s"):format(tostring(err)) + end + + return true +end + +---------------------------------------------------------------------- +-- accept() as an Op +---------------------------------------------------------------------- + +--- Build an Op that accepts a connection and returns a Stream. +---@return Op +function Socket:accept_op() + assert(self.fd, "socket is closed") + + local P = poller_mod.get() + local fd = self.fd + + local function step() + local new_fd, err, errno = sc.accept(fd) + if new_fd then + -- Successful accept. + return true, new_fd, nil + end + if errno == sc.EAGAIN or errno == sc.EWOULDBLOCK then + -- Would block: wait for readability. + return false + end + -- Hard error. + return true, nil, err or ("errno " .. tostring(errno)) + end + + -- Only declare the parameter you actually use. + local function register(task) + -- wait.waitable will call this as register(task, suspension, leaf_wrap), + -- but the extra arguments are harmlessly discarded by Lua. + return P:wait(fd, "rd", task) + end + + local function wrap(new_fd, err) + if not new_fd then + return nil, err + end + sc.set_nonblock(new_fd) + return fd_to_stream(new_fd) + end + + return wait.waitable(register, step, wrap) +end + +--- Accept a connection synchronously into a Stream. +---@return Stream|nil client, any err +function Socket:accept() + return perform(self:accept_op()) +end + +---------------------------------------------------------------------- +-- connect() as an Op +---------------------------------------------------------------------- + +--- Build an Op that connects this Socket to a sockaddr. +---@param sa any +---@return Op +function Socket:connect_op(sa) + assert(self.fd, "socket is closed") + + local P = poller_mod.get() + local fd = self.fd + local state = "initial" + + local function step() + if state == "initial" then + local ok, err, errno = sc.connect(fd, sa) + if ok then + -- Immediate success. + return true, true, nil + end + if errno == sc.EINPROGRESS then + -- Standard non-blocking connect semantics: wait for writability. + state = "waiting" + return false + end + -- Hard failure. + return true, false, err or ("errno " .. tostring(errno)) + elseif state == "waiting" then + -- Connection completed or failed; check SO_ERROR. + local soerr = sc.getsockopt(fd, sc.SOL_SOCKET, sc.SO_ERROR) + if soerr == nil then + return true, false, "getsockopt(SO_ERROR) failed" + end + if soerr == 0 then + return true, true, nil + else + return true, false, "connect error errno " .. tostring(soerr) + end + else + return true, false, "invalid connect state" + end + end + + -- Again, only the argument you use. + local function register(task) + return P:wait(fd, "wr", task) + end + + local function wrap(ok, err) + if not ok then + return nil, err + end + local new_fd = fd + self.fd = nil + return fd_to_stream(new_fd) + end + + return wait.waitable(register, step, wrap) +end + +--- Connect synchronously and return a Stream. +---@param sa any +---@return Stream|nil stream, any err +function Socket:connect(sa) + return perform(self:connect_op(sa)) +end + +---------------------------------------------------------------------- +-- UNIX-domain convenience +---------------------------------------------------------------------- + +--- Build an Op that connects this socket to a UNIX-domain path. +---@param path string +---@return Op +function Socket:connect_unix_op(path) + assert(self.fd, "socket is closed") + + local sa = sc.getsockname(self.fd) + sa.path = path + return self:connect_op(sa) +end + +--- Connect synchronously to a UNIX-domain path. +---@param path string +---@return Stream|nil stream, any err +function Socket:connect_unix(path) + return perform(self:connect_unix_op(path)) +end + +--- Listen on a UNIX-domain path and return a listening Socket. +---@param path string +---@param opts? { stype?: integer, protocol?: integer, ephemeral?: boolean } +---@return Socket|nil s, any err +local function listen_unix(path, opts) + opts = opts or {} + + local s, err = socket(sc.AF_UNIX, opts.stype or sc.SOCK_STREAM, opts.protocol) + if not s then + return nil, err + end + + local ok, lerr = s:listen_unix(path) + if not ok then + -- Clean up the socket on failure, then propagate the error. + s:close() + return nil, lerr + end + + if opts.ephemeral then + local parent_close = s.close + ---@diagnostic disable-next-line: inject-field + function s:close() + local ok1, err1 = parent_close(self) + + local ok2, err2, errno2 = sc.unlink(path) + -- Treat ENOENT as benign; other failures are reported. + if not ok2 and errno2 ~= sc.ENOENT then + return false, ("failed to remove %s: %s"):format(tostring(path), tostring(err2)) + end + + -- If the socket close was fine, report success overall. + if ok1 == false then return false, err1 end + return true, nil + end + end + + return s +end + +--- Connect to a UNIX-domain socket path and return a Stream. +---@param path string +---@param stype? integer +---@param protocol? integer +---@return Stream|nil stream, any err +local function connect_unix(path, stype, protocol) + local s, err = socket(sc.AF_UNIX, stype or sc.SOCK_STREAM, protocol) + if not s then + return nil, err + end + local stream, cerr = s:connect_unix(path) + if not stream then + return nil, cerr + end + return stream +end + +---------------------------------------------------------------------- +-- Lifecycle +---------------------------------------------------------------------- + +--- Close the underlying socket fd. +---@return boolean ok, any err +function Socket:close() + if self.fd then + sc.close(self.fd) + self.fd = nil + end + return true +end + +---------------------------------------------------------------------- +-- Public API +---------------------------------------------------------------------- + +return { + socket = socket, + listen_unix = listen_unix, + connect_unix = connect_unix, + Socket = Socket, +} diff --git a/tests/test_io-file.lua b/tests/test_io-file.lua new file mode 100644 index 0000000..4052810 --- /dev/null +++ b/tests/test_io-file.lua @@ -0,0 +1,466 @@ +-- tests/test_stream_mem.lua +-- +-- Integration tests for: +-- - fibers.io.file +-- - fibers.io.fd_backend +-- - fibers.io.stream +-- +-- Uses the real scheduler, ops and wait/waitset machinery. +print('testing: fibers.io.file') + +-- look one level up +package.path = "../src/?.lua;" .. package.path + +-- Assertion-based checks for fibers.io.file and stream I/O. +-- Exercises: +-- - tmpfile round-trip +-- - pipe round-trip + EOF behaviour +-- - use-after-close errors on streams +-- - cancellation of a scope while an IO op is blocked +-- - line-based reads via read/read_op +-- - read_all and read_exactly helpers +-- - numeric read formats, including n == 0 +-- - write_op/write with multiple arguments +-- - merge_lines_op across multiple streams +-- - setvbuf, filename, rename, nonblock/block, is_stream + +local fibers = require 'fibers' +local sleep = require 'fibers.sleep' +local file_mod = require 'fibers.io.file' +local scope_mod = require 'fibers.scope' +local stream_mod = require 'fibers.io.stream' + +local perform = fibers.perform + +---------------------------------------------------------------------- +-- 1. tmpfile round-trip +---------------------------------------------------------------------- + +local function test_tmpfile_roundtrip() + local f, err = file_mod.tmpfile() + assert(f, "tmpfile() failed: " .. tostring(err)) + + local msg = "hello, tmpfile" + local n, werr = perform(f:write_string_op(msg)) + assert(n == #msg, "write_string_op wrote " .. tostring(n) .. " bytes, expected " .. #msg) + assert(werr == nil, "write_string_op returned error: " .. tostring(werr)) + + -- Rewind to the start. + local pos, serr = f:seek("set", 0) + assert(pos ~= nil, "seek failed: " .. tostring(serr)) + + local s, cnt, rerr = perform(f:read_string_op{ + min = #msg, + max = #msg, + eof_ok = true, + }) + + assert(rerr == nil, "read_string_op returned error: " .. tostring(rerr)) + assert(cnt == #msg, "read_string_op read " .. tostring(cnt) .. " bytes, expected " .. #msg) + assert(s == msg, ("read_string_op returned %q, expected %q"):format(tostring(s), tostring(msg))) + + local ok, cerr = f:close() + assert(ok, "tmpfile:close() failed: " .. tostring(cerr)) +end + +---------------------------------------------------------------------- +-- 2. pipe round-trip + EOF semantics +---------------------------------------------------------------------- + +local function test_pipe_roundtrip_and_eof() + local r, w = file_mod.pipe() + assert(r and w, "pipe() did not return read and write streams") + + local msg = "pipe-test" + local n, werr = perform(w:write_string_op(msg)) + assert(n == #msg, "pipe write_string_op wrote " .. tostring(n) .. " bytes, expected " .. #msg) + assert(werr == nil, "pipe write_string_op returned error: " .. tostring(werr)) + + local s, cnt, rerr = perform(r:read_string_op{ + min = #msg, + max = #msg, + eof_ok = true, + }) + + assert(rerr == nil, "pipe read_string_op returned error: " .. tostring(rerr)) + assert(cnt == #msg, "pipe read_string_op read " .. tostring(cnt) .. " bytes, expected " .. #msg) + assert(s == msg, ("pipe read_string_op returned %q, expected %q"):format(tostring(s), tostring(msg))) + + -- Close the write end and ensure the reader sees EOF. + local okw, errw = w:close() + assert(okw, "pipe write stream close failed: " .. tostring(errw)) + + local s2, cnt2, rerr2 = perform(r:read_string_op{ + min = 1, + eof_ok = true, + }) + + -- At EOF, read_string_op should return (nil, 0, nil). + assert(s2 == nil, "expected nil at EOF, got " .. tostring(s2)) + assert(cnt2 == 0, "expected byte count 0 at EOF, got " .. tostring(cnt2)) + assert(rerr2 == nil, "expected no error at EOF, got " .. tostring(rerr2)) + + local okr, errr = r:close() + assert(okr, "pipe read stream close failed: " .. tostring(errr)) +end + +---------------------------------------------------------------------- +-- 3. use-after-close error paths +---------------------------------------------------------------------- + +local function test_closed_stream_errors() + -- Use a fresh pipe for write-after-close. + local r1, w1 = file_mod.pipe() + assert(r1 and w1, "pipe() did not return read and write streams") + + local okw1, errw1 = w1:close() + assert(okw1, "write stream close failed: " .. tostring(errw1)) + + -- Writing via an already-closed Stream should raise "stream is not writable". + local ok, err = pcall(function() + return perform(w1:write_string_op("abc")) + end) + assert(not ok, "expected write after close to fail with an assertion") + assert(tostring(err):match("stream is not writable"), + "unexpected write-after-close error: " .. tostring(err)) + + -- Use a fresh pipe for read-after-close. + local r2, w2 = file_mod.pipe() + assert(r2 and w2, "pipe() did not return read and write streams") + + local okr2, errr2 = r2:close() + assert(okr2, "read stream close failed: " .. tostring(errr2)) + + -- Reading via an already-closed Stream should raise "stream is not readable". + ok, err = pcall(function() + return perform(r2:read_string_op{ + min = 1, + eof_ok = false, + }) + end) + assert(not ok, "expected read after close to fail with an assertion") + assert(tostring(err):match("stream is not readable"), + "unexpected read-after-close error: " .. tostring(err)) + + -- Clean up the remaining write end. + local okw2, errw2 = w2:close() + assert(okw2, "second write stream close failed: " .. tostring(errw2)) +end + +---------------------------------------------------------------------- +-- 4. Cancellation + IO: a child scope with a blocked read +---------------------------------------------------------------------- + +local function test_cancellation_cancels_blocked_read() + -- Use scope.run to create a nested child scope whose cancellation + -- does not affect the top-level scope used by fibers.run. + local status, err = scope_mod.run(function(child) + local r, w = file_mod.pipe() + assert(r and w, "pipe() did not return read and write streams") + + -- Spawn a canceller fiber under the child scope. + child:spawn(function(s) + -- Give the reader time to block. + perform(sleep.sleep_op(0.01)) + s:cancel("test cancellation") + end) + + -- Perform a read that will block (nothing is written). + local v1, v2, v3 = perform(r:read_string_op{ + min = 1, + eof_ok = true, + }) + + -- Under Scope:run_ev semantics, cancellation races the IO op. + -- When cancellation wins, the cancel_op returns: + -- false, reason, nil + assert(v1 == false, + "expected first result false from cancelled op, got " .. tostring(v1)) + assert(v2 == "test cancellation", + "expected cancellation reason 'test cancellation', got " .. tostring(v2)) + assert(v3 == nil, + "expected third result nil from cancel_op, got " .. tostring(v3)) + + -- Close streams to avoid leaks; at this point cancellation has + -- already been signalled. + local okr, errr = r:close() + local okw, errw = w:close() + assert(okr, "reader close failed after cancellation: " .. tostring(errr)) + assert(okw, "writer close failed after cancellation: " .. tostring(errw)) + end) + + if status == "failed" then + error(err) + end + + -- From the outer point of view, the nested scope should report as cancelled. + assert(status == "cancelled", + "expected child scope status 'cancelled', got " .. tostring(status)) + assert(err == "test cancellation", + "unexpected child scope cancellation error: " .. tostring(err)) +end + +---------------------------------------------------------------------- +-- 5. Line-based reads via Stream:read / read_op +---------------------------------------------------------------------- + +local function test_read_line_variants() + local f, err = file_mod.tmpfile() + assert(f, "tmpfile() failed: " .. tostring(err)) + + local content = "line1\nline2\n" + local n, werr = f:write(content) + assert(werr == nil, "write failed in test_read_line_variants: " .. tostring(werr)) + assert(n == #content, "write wrote " .. tostring(n) .. " bytes, expected " .. #content) + + local pos, serr = f:seek("set", 0) + assert(pos == 0, "seek to start failed: " .. tostring(serr)) + + -- Default / "*l": line without terminator. + local l1, e1 = f:read() -- equivalent to "*l" + assert(e1 == nil, "read('*l') returned error: " .. tostring(e1)) + assert(l1 == "line1", "read('*l') returned " .. tostring(l1) .. ", expected 'line1'") + + -- "*L": line with terminator. + local l2, e2 = f:read("*L") + assert(e2 == nil, "read('*L') returned error: " .. tostring(e2)) + assert(l2 == "line2\n", "read('*L') returned " .. tostring(l2) .. ", expected 'line2\\n'") + + -- EOF: another line should give nil, nil. + local l3, e3 = f:read("*l") + assert(l3 == nil, "expected nil at EOF from read('*l'), got " .. tostring(l3)) + assert(e3 == nil, "expected nil error at EOF from read('*l'), got " .. tostring(e3)) + + local ok, cerr = f:close() + assert(ok, "tmpfile close failed in test_read_line_variants: " .. tostring(cerr)) +end + +---------------------------------------------------------------------- +-- 6. read_all, read_exactly and numeric read formats +---------------------------------------------------------------------- + +local function test_read_all_and_exactly() + local f, err = file_mod.tmpfile() + assert(f, "tmpfile() failed: " .. tostring(err)) + + local content = "abc123" + local n, werr = f:write(content) + assert(werr == nil, "write failed in test_read_all_and_exactly: " .. tostring(werr)) + assert(n == #content, "write wrote " .. tostring(n) .. " bytes, expected " .. #content) + + local pos, serr = f:seek("set", 0) + assert(pos == 0, "seek to start failed: " .. tostring(serr)) + + -- read_all should return the whole string. + local all, eall = f:read_all() + assert(eall == nil, "read_all returned error: " .. tostring(eall)) + assert(all == content, "read_all returned " .. tostring(all) .. ", expected " .. tostring(content)) + + -- Rewind and exercise read_exactly. + pos, serr = f:seek("set", 0) + assert(pos == 0, "seek to start failed (2): " .. tostring(serr)) + + local s1, e1 = f:read_exactly(3) + assert(e1 == nil, "read_exactly(3) returned error: " .. tostring(e1)) + assert(s1 == "abc", "read_exactly(3) returned " .. tostring(s1) .. ", expected 'abc'") + + local s2, e2 = f:read_exactly(3) + assert(e2 == nil, "read_exactly(3) second returned error: " .. tostring(e2)) + assert(s2 == "123", "read_exactly(3) second returned " .. tostring(s2) .. ", expected '123'") + + -- EOF: attempting to read beyond end should give "short read". + local s3, e3 = f:read_exactly(1) + assert(s3 == nil, "expected nil from read_exactly beyond EOF, got " .. tostring(s3)) + assert(e3 == "short read", "expected 'short read' error, got " .. tostring(e3)) + + local ok, cerr = f:close() + assert(ok, "tmpfile close failed in test_read_all_and_exactly: " .. tostring(cerr)) +end + +local function test_read_numeric_formats() + local f, err = file_mod.tmpfile() + assert(f, "tmpfile() failed: " .. tostring(err)) + + local content = "xyz" + local n, werr = f:write(content) + assert(werr == nil, "write failed in test_read_numeric_formats: " .. tostring(werr)) + assert(n == #content, "write wrote " .. tostring(n) .. " bytes, expected " .. #content) + + local pos, serr = f:seek("set", 0) + assert(pos == 0, "seek to start failed: " .. tostring(serr)) + + -- n == 0: should return "" immediately. + local s0, e0 = f:read(0) + assert(e0 == nil, "read(0) returned error: " .. tostring(e0)) + assert(s0 == "", "read(0) returned " .. tostring(s0) .. ", expected empty string") + + -- n > 0: read up to n bytes; EOF semantics. + local s1, e1 = f:read(1) + assert(e1 == nil, "read(1) returned error: " .. tostring(e1)) + assert(s1 == "x", "read(1) returned " .. tostring(s1) .. ", expected 'x'") + + local s2, e2 = f:read(10) -- remaining two bytes + assert(e2 == nil, "read(10) returned error: " .. tostring(e2)) + assert(s2 == "yz", "read(10) returned " .. tostring(s2) .. ", expected 'yz'") + + local s3, e3 = f:read(1) -- EOF now + assert(s3 == nil, "expected nil at EOF from read(1), got " .. tostring(s3)) + assert(e3 == nil, "expected nil error at EOF from read(1), got " .. tostring(e3)) + + local ok, cerr = f:close() + assert(ok, "tmpfile close failed in test_read_numeric_formats: " .. tostring(cerr)) +end + +---------------------------------------------------------------------- +-- 7. write_op / write wrappers +---------------------------------------------------------------------- + +local function test_write_variants() + local f, err = file_mod.tmpfile() + assert(f, "tmpfile() failed: " .. tostring(err)) + + -- Zero-argument write should succeed and write nothing. + local n0, e0 = f:write() + assert(e0 == nil, "zero-arg write returned error: " .. tostring(e0)) + assert(n0 == 0, "zero-arg write returned " .. tostring(n0) .. ", expected 0") + + -- Multi-argument write should tostring each argument. + local _, werr = f:write("foo", 123, true) + assert(werr == nil, "write returned error: " .. tostring(werr)) + + local pos, serr = f:seek("set", 0) + assert(pos == 0, "seek to start failed: " .. tostring(serr)) + + local s, e = f:read_all() + assert(e == nil, "read_all returned error after write: " .. tostring(e)) + assert(s == "foo123true", + "read_all returned " .. tostring(s) .. ", expected 'foo123true'") + + local ok, cerr = f:close() + assert(ok, "tmpfile close failed in test_write_variants: " .. tostring(cerr)) +end + +---------------------------------------------------------------------- +-- 8. merge_lines_op across multiple streams +---------------------------------------------------------------------- + +local function test_merge_lines_op() + local r1, w1 = file_mod.pipe() + local r2, w2 = file_mod.pipe() + assert(r1 and w1 and r2 and w2, "pipe() did not return streams") + + local named = { + a = r1, + b = r2, + } + + -- Spawn writers under the top-level scope; ignore the scope argument. + fibers.spawn(function(_, w) + local _, err = w:write("line-a\n") + assert(err == nil, "writer a write error: " .. tostring(err)) + local ok, cerr = w:close() + assert(ok, "writer a close error: " .. tostring(cerr)) + end, w1) + + fibers.spawn(function(_, w) + local _, err = w:write("line-b\n") + assert(err == nil, "writer b write error: " .. tostring(err)) + local ok, cerr = w:close() + assert(ok, "writer b close error: " .. tostring(cerr)) + end, w2) + + local op = stream_mod.merge_lines_op(named, { terminator = "\n" }) + local name, line, err = perform(op) + + assert(err == nil, "merge_lines_op returned error: " .. tostring(err)) + assert(name == "a" or name == "b", + "merge_lines_op returned unexpected name: " .. tostring(name)) + + if name == "a" then + assert(line == "line-a", "expected 'line-a' from arm 'a', got " .. tostring(line)) + else + assert(line == "line-b", "expected 'line-b' from arm 'b', got " .. tostring(line)) + end + + -- Close the remaining reader. + local ok1, cerr1 = r1:close() + local ok2, cerr2 = r2:close() + assert(ok1, "reader r1 close failed: " .. tostring(cerr1)) + assert(ok2, "reader r2 close failed: " .. tostring(cerr2)) +end + +---------------------------------------------------------------------- +-- 9. setvbuf, filename, rename, nonblock/block, is_stream +---------------------------------------------------------------------- + +local function test_stream_properties_and_rename() + local f, err = file_mod.tmpfile() + assert(f, "tmpfile() failed: " .. tostring(err)) + + -- is_stream + assert(stream_mod.is_stream(f), "is_stream did not recognise a Stream") + assert(not stream_mod.is_stream(123), "is_stream misclassified a number") + + -- filename should be non-nil for tmpfile + local fname = f:filename() + assert(fname ~= nil, "tmpfile:filename() returned nil") + + -- setvbuf toggles line_buffering flag + assert(f.line_buffering == false, "expected line_buffering default false") + f:setvbuf("line") + assert(f.line_buffering == true, "setvbuf('line') did not set line_buffering") + f:setvbuf("no") + assert(f.line_buffering == false, "setvbuf('no') did not clear line_buffering") + f:setvbuf("full") + assert(f.line_buffering == false, "setvbuf('full') did not clear line_buffering") + + -- nonblock/block should be safe no-ops for fd-backed streams. + f:nonblock() + f:block() + + -- Rename the tmpfile to a stable name and ensure it persists after close. + local newname = fname .. ".renamed" + local rok, rerr = f:rename(newname) + assert(rok, "rename failed: " .. tostring(rerr)) + assert(f:filename() == newname, + "filename after rename was " .. tostring(f:filename()) .. ", expected " .. tostring(newname)) + + local ok, cerr = f:close() + assert(ok, "tmpfile close after rename failed: " .. tostring(cerr)) + + -- The renamed file should now exist and be openable via file_mod.open. + local f2, oerr = file_mod.open(newname, "r") + assert(f2, "expected to reopen renamed file, got error: " .. tostring(oerr)) + + local _, e = f2:read_all() + assert(e == nil, "read_all from reopened file returned error: " .. tostring(e)) + -- Content is not prescribed here; just ensure read_all works and the stream is valid. + + local ok2, cerr2 = f2:close() + assert(ok2, "reopened stream close failed: " .. tostring(cerr2)) + + -- Clean up the renamed file to avoid littering. + os.remove(newname) +end + +---------------------------------------------------------------------- +-- Main +---------------------------------------------------------------------- + +local function main() + test_tmpfile_roundtrip() + test_pipe_roundtrip_and_eof() + test_closed_stream_errors() + test_cancellation_cancels_blocked_read() + + test_read_line_variants() + test_read_all_and_exactly() + test_read_numeric_formats() + test_write_variants() + test_merge_lines_op() + test_stream_properties_and_rename() +end + +fibers.run(main) + +print("test_file.lua: all assertions passed") diff --git a/tests/test_io-poller.lua b/tests/test_io-poller.lua new file mode 100644 index 0000000..79d30d8 --- /dev/null +++ b/tests/test_io-poller.lua @@ -0,0 +1,39 @@ +--- Tests the File implementation. +print('testing: fibers.fd') + +-- look one level up +package.path = "../src/?.lua;" .. package.path + +local file_stream = require 'fibers.io.file' +local runtime = require 'fibers.runtime' + +local equal = require 'fibers.utils.helper'.equal +local log = {} +local function record(x) table.insert(log, x) end + +runtime.current_scheduler:run() +assert(equal(log, {})) + +local rd, wr = file_stream.pipe() +local message = "hello, world\n" +runtime.spawn_raw(function() + record('rd-a') + local str = rd:read_string{min=1,max=math.huge} + record('rd-b') + record(str) +end) +runtime.spawn_raw(function() + record('wr-a') + wr:write(message) + record('wr-b') + wr:flush() + record('wr-c') +end) + +runtime.current_scheduler:run() +assert(equal(log, { 'rd-a', 'wr-a', 'wr-b', 'wr-c' })) +runtime.current_scheduler:run() +runtime.current_scheduler:run() +assert(equal(log, { 'rd-a', 'wr-a', 'wr-b', 'wr-c', 'rd-b', message })) + +print('test: ok') diff --git a/tests/test_io-socket.lua b/tests/test_io-socket.lua new file mode 100644 index 0000000..31d1c45 --- /dev/null +++ b/tests/test_io-socket.lua @@ -0,0 +1,87 @@ +-- tests/test_io-socket.lua +-- +-- Integration tests for: +-- - fibers.io.socket +-- - fibers.io.fd_backend +-- - fibers.io.stream +-- +-- Uses the real scheduler, ops and wait/waitset machinery. +print('testing: fibers.io.socket') + +-- look one level up +package.path = "../src/?.lua;" .. package.path + +-- test_socket.lua +-- +-- Simple assertion-based checks for fibers.io.socket over AF_UNIX. + +local fibers = require 'fibers' +local socket_mod = require 'fibers.io.socket' + +local perform = fibers.perform + +local function test_unix_socket_roundtrip(scope) + -- Construct a unique path under /tmp for this test run. + local base = os.getenv("TMPDIR") or "/tmp" + local path = string.format("%s/fibers_socket_test.%d.%d", + base, os.time(), math.random(1e6)) + + -- Start listening server. + local server, err = socket_mod.listen_unix(path, { ephemeral = true }) + assert(server, "listen_unix failed: " .. tostring(err)) + + -- Server fiber: accept one connection, echo a response, then close. + scope:spawn(function(_) + local s, aerr = server:accept() + assert(s, "server accept failed: " .. tostring(aerr)) + + local msg, cnt, rerr = perform(s:read_string_op{ + min = 5, + max = 5, + eof_ok = true, + }) + + assert(rerr == nil, "server read_string_op error: " .. tostring(rerr)) + assert(cnt == 5, "server read_string_op read " .. tostring(cnt) .. " bytes, expected 5") + assert(msg == "hello", ("server received %q, expected %q"):format(tostring(msg), "hello")) + + local n, werr = perform(s:write_string_op("world")) + assert(werr == nil, "server write_string_op error: " .. tostring(werr)) + assert(n == 5, "server write_string_op wrote " .. tostring(n) .. " bytes, expected 5") + + local okc, cerr = s:close() + assert(okc, "server stream close failed: " .. tostring(cerr)) + + local oks, serr = server:close() + assert(oks, "server socket close failed: " .. tostring(serr)) + end) + + -- Client side: connect, send "hello", read "world". + local client, cerr = socket_mod.connect_unix(path) + assert(client, "connect_unix failed: " .. tostring(cerr)) + + local n, werr = perform(client:write_string_op("hello")) + assert(werr == nil, "client write_string_op error: " .. tostring(werr)) + assert(n == 5, "client write_string_op wrote " .. tostring(n) .. " bytes, expected 5") + + local resp, cnt, rerr = perform(client:read_string_op{ + min = 5, + max = 5, + eof_ok = true, + }) + + assert(rerr == nil, "client read_string_op error: " .. tostring(rerr)) + assert(cnt == 5, "client read_string_op read " .. tostring(cnt) .. " bytes, expected 5") + assert(resp == "world", ("client received %q, expected %q"):format(tostring(resp), "world")) + + local okc, cclose_err = client:close() + assert(okc, "client stream close failed: " .. tostring(cclose_err)) +end + +local function main(scope) + test_unix_socket_roundtrip(scope) +end + +fibers.run(main) + +print("test_socket.lua: all assertions passed") From 2a2b5fb64c5691aa898d90c1184fa2c73cb82db8 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Fri, 28 Nov 2025 09:08:16 +0000 Subject: [PATCH 050/138] adds new process abstraction --- src/fibers/io/proc_backend.lua | 224 ++++++++++++++++++ src/fibers/io/process.lua | 406 +++++++++++++++++++++++++++++++++ src/fibers/utils/syscall.lua | 15 +- tests/test_io-proc_backend.lua | 145 ++++++++++++ tests/test_io-process.lua | 231 +++++++++++++++++++ 5 files changed, 1017 insertions(+), 4 deletions(-) create mode 100644 src/fibers/io/proc_backend.lua create mode 100644 src/fibers/io/process.lua create mode 100644 tests/test_io-proc_backend.lua create mode 100644 tests/test_io-process.lua diff --git a/src/fibers/io/proc_backend.lua b/src/fibers/io/proc_backend.lua new file mode 100644 index 0000000..7c00d7c --- /dev/null +++ b/src/fibers/io/proc_backend.lua @@ -0,0 +1,224 @@ +-- fibers/io/proc_backend.lua +-- +-- Low-level OS process backend for fibers.process. +-- +-- Linux-specific: uses fork + execp + pidfd_open + wait(WNOHANG). +-- +---@module 'fibers.io.proc_backend' + +local sc = require 'fibers.utils.syscall' + +---@class ProcSpec +---@field argv string[] -- argv[1] is executable +---@field env table|nil -- env overrides/unsets; nil: inherit +---@field cwd string|nil -- working directory for child +---@field stdin_fd integer|nil -- child stdin fd (nil = inherit) +---@field stdout_fd integer|nil -- child stdout fd (nil = inherit) +---@field stderr_fd integer|nil -- child stderr fd (nil = inherit) +---@field flags table|nil -- optional flags, e.g. { setsid = true } + +---@class ProcBackend +---@field pid integer +---@field pidfd integer +---@field exited boolean +---@field status integer|nil -- raw status / code / signal +---@field code integer|nil -- exit code if exited normally +---@field signal integer|nil -- signal number if killed/stopped +---@field err string|nil +local ProcBackend = {} +ProcBackend.__index = ProcBackend + +---------------------------------------------------------------------- +-- Internal helpers +---------------------------------------------------------------------- + +local function errno_msg(prefix, err, errno) + return err or (prefix .. " (errno " .. tostring(errno) .. ")") +end + +local function must_ok(ok) + if not ok then + sc._exit(127) + end +end + +local function build_argt(argv) + local cmd = assert(argv[1], "ProcSpec.argv[1] must be executable") + local argt = {} + argt[0] = cmd + for i = 2, #argv do + argt[i - 1] = argv[i] + end + return cmd, argt +end + +--- Duplicate src_fd onto dest_fd in the child. +local function setup_child_fd(src_fd, dest_fd) + if src_fd == nil or src_fd == dest_fd then + return + end + must_ok(sc.dup2(src_fd, dest_fd)) +end + +--- Apply environment overrides in the child. +---@param env table +local function apply_child_env(env) + for name, value in pairs(env) do + must_ok(sc.setenv(name, value and tostring(value) or nil)) + end +end + +---@param spec ProcSpec +local function child_exec(spec) + if spec.cwd then + must_ok(sc.chdir(spec.cwd)) + end + + if spec.flags and spec.flags.setsid then + must_ok(sc.setpid("s", 0)) + end + + if spec.env then + apply_child_env(spec.env) + end + + setup_child_fd(spec.stdin_fd, 0) + setup_child_fd(spec.stdout_fd, 1) + setup_child_fd(spec.stderr_fd, 2) + + -- Close child-only descriptors used for stdio, where safe. + do + local seen = {} + for _, fd in ipairs{ spec.stdin_fd, spec.stdout_fd, spec.stderr_fd } do + if fd and fd > 2 and not seen[fd] then + seen[fd] = true + sc.close(fd) -- ignore errors + end + end + end + + local cmd, argt = build_argt(spec.argv) + sc.execp(cmd, argt) + + sc._exit(127) +end + +---------------------------------------------------------------------- +-- Public spawn +---------------------------------------------------------------------- + +---@param spec ProcSpec +---@return ProcBackend|nil backend, string|nil err +local function spawn(spec) + assert(type(spec) == "table", "ProcBackend.spawn: spec must be a table") + assert(type(spec.argv) == "table" and spec.argv[1], + "ProcBackend.spawn: spec.argv must be a non-empty array") + + local pid, err, errno = sc.fork() + if not pid then + return nil, errno_msg("fork failed", err, errno) + end + + if pid == 0 then + child_exec(spec) + end + + local pidfd, perr, perrno = sc.pidfd_open(pid, 0) + if not pidfd then + sc.kill(pid, sc.SIGKILL) + sc.wait(pid, 0) + return nil, errno_msg("pidfd_open failed", perr, perrno) + end + + local ok, e1 = sc.set_nonblock(pidfd) + assert(ok, "set_nonblock pidfd failed: " .. tostring(e1)) + + ok, e1 = sc.set_cloexec(pidfd) + assert(ok, "set_cloexec pidfd failed: " .. tostring(e1)) + + local retval = setmetatable({ + pid = pid, + pidfd = pidfd, + exited = false, + status = nil, + code = nil, + signal = nil, + err = nil, + }, ProcBackend) + + return retval +end + +---------------------------------------------------------------------- +-- Non-blocking wait +---------------------------------------------------------------------- + +function ProcBackend:_finalise(status, code, signal, err) + if not self.exited then + self.exited = true + self.status = status + self.code = code + self.signal = signal + self.err = err + self:close() + end + return true, self.status, self.code, self.signal, self.err +end + +function ProcBackend:nonblock_wait() + if self.exited then + return true, self.status, self.code, self.signal, self.err + end + + local pid, how, v3 = sc.wait(self.pid, sc.WNOHANG) + if not pid then + local err, errno = how, v3 + if errno == sc.ECHILD or errno == sc.ESRCH then + return self:_finalise(nil, nil, nil, nil) + end + return self:_finalise(nil, nil, nil, errno_msg("wait failed", err, errno)) + end + + if how == "running" then + return false, nil, nil, nil, nil + end + + local status, code, signal + if how == "exited" then + status, code, signal = v3, v3, nil + else + status, code, signal = v3, nil, v3 + end + + return self:_finalise(status, code, signal, nil) +end + +---------------------------------------------------------------------- +-- Signalling and close +---------------------------------------------------------------------- + +function ProcBackend:send_signal(sig) + sig = sig or sc.SIGTERM + + local ok, err, errno = sc.kill(self.pid, sig) + if not ok then + if errno == sc.ESRCH then return true, nil end + return false, errno_msg("kill failed", err, errno) + end + + return true, nil +end + +function ProcBackend:close() + if self.pidfd then + local ok, err = sc.close(self.pidfd) + self.pidfd = nil + if not ok then return false, err end + end + return true, nil +end + +return { + ProcBackend = ProcBackend, + spawn = spawn, +} diff --git a/src/fibers/io/process.lua b/src/fibers/io/process.lua new file mode 100644 index 0000000..0b0c522 --- /dev/null +++ b/src/fibers/io/process.lua @@ -0,0 +1,406 @@ +-- fibers/process.lua +-- +-- Process abstraction built on top of proc_backend, streams and CML Ops. +-- +---@module 'fibers.process' + +local sc = require 'fibers.utils.syscall' +local op = require 'fibers.op' +local perform = require 'fibers.performer'.perform +local wait = require 'fibers.wait' +local file_io = require 'fibers.io.file' +local proc_mod = require 'fibers.io.proc_backend' +local poller = require 'fibers.io.poller' +local sleep = require 'fibers.sleep' + +---@class SpawnOptions +---@field argv string[] # required, argv[1] is executable +---@field cwd string|nil # working directory for child +---@field env table|nil # environment overrides/unsets +---@field flags table|nil # passed through to ProcSpec.flags +---@field stdin '"inherit"'|'"null"'|'"pipe"'|nil +---@field stdout '"inherit"'|'"null"'|'"pipe"'|nil +---@field stderr '"inherit"'|'"null"'|'"pipe"'|'"stdout"'|nil + +---@class Process +---@field backend ProcBackend|nil +---@field stdin Stream|nil +---@field stdout Stream|nil +---@field stderr Stream|nil +---@field _done boolean +---@field _status integer|nil +---@field _code integer|nil +---@field _signal integer|nil +---@field _err string|nil +local Process = {} +Process.__index = Process + +local DEV_NULL = "/dev/null" + +--- Build a ProcSpec and derived stdio information. +--- +--- On success returns: +--- spec, child_only_set, parent_stdin_fd, parent_stdout_fd, parent_stderr_fd, nil +--- +--- On failure due to OS/environment: +--- nil, nil, nil, nil, nil, err +--- +--- On programmer error (bad options), raises. +---@param opts SpawnOptions +---@return table|nil spec +---@return table|nil child_only +---@return integer|nil parent_stdin_fd +---@return integer|nil parent_stdout_fd +---@return integer|nil parent_stderr_fd +---@return string|nil err +local function build_proc_spec(opts) + local argv = assert(opts.argv, "spawn: opts.argv is required") + assert(type(argv) == "table" and argv[1], "spawn: opts.argv must be a non-empty array") + + local spec = { + argv = argv, + cwd = opts.cwd, + env = opts.env, + flags = opts.flags, + stdin_fd = nil, + stdout_fd = nil, + stderr_fd = nil, + } + + local opened = {} -- all fds opened in this function + local child_only = {} -- subset used only in the child + local parent_fds = {} -- parent-side pipe ends by name + + local function remember(fd) + if fd and fd >= 0 then + opened[fd] = true + end + return fd + end + + local function mark_child_only(fd) + if fd and fd >= 0 then + child_only[fd] = true + end + return fd + end + + local function cleanup_all() + for fd in pairs(opened) do + sc.close(fd) + end + end + + local function fail_env(msg) + cleanup_all() + return nil, nil, nil, nil, nil, msg + end + + local function prog_error(msg) + -- Programmer error: clean up fds for politeness, then raise. + cleanup_all() + error(msg, 3) -- level 3: attribute to caller of build_proc_spec + end + + local function open_dev_null(flags) + local fd, err = sc.open(DEV_NULL, flags, 0) + if not fd then + return nil, err or ("failed to open " .. DEV_NULL) + end + return remember(fd), nil + end + + local function make_pipe() + local rd, wr, perr = sc.pipe() + if not rd then + return nil, nil, perr or "pipe() failed" + end + remember(rd) + remember(wr) + return rd, wr, nil + end + + local function handle_stream(stream_type, is_output) + local opt = opts[stream_type] or "inherit" + if opt == "inherit" then + return nil -- no error + end + + if opt == "null" then + local flags = is_output and sc.O_WRONLY or sc.O_RDONLY + local fd, err = open_dev_null(flags) + if not fd then return err end + spec[stream_type .. "_fd"] = mark_child_only(fd) + return nil + + elseif opt == "pipe" then + local rd, wr, err = make_pipe() + if not rd then return err or "pipe() failed" end + + if is_output then + -- child writes, parent reads + spec[stream_type .. "_fd"] = mark_child_only(wr) + parent_fds[stream_type] = rd + else + -- child reads, parent writes + spec[stream_type .. "_fd"] = mark_child_only(rd) + parent_fds[stream_type] = wr + end + + local ok, cerr = sc.set_cloexec(parent_fds[stream_type]) + if not ok then + return cerr or ("set_cloexec(" .. stream_type .. " parent end) failed") + end + + return nil + + elseif stream_type == "stderr" and opt == "stdout" then + -- stderr shares stdout in the child. + if opts.stdout == "inherit" or opts.stdout == nil then + spec.stderr_fd = 1 + else + -- stdout must already have been configured. + if not spec.stdout_fd then + prog_error("spawn: stderr='stdout' but stdout not configured") + end + spec.stderr_fd = spec.stdout_fd + end + return nil + + else + prog_error("spawn: invalid " .. stream_type .. " option " .. tostring(opt)) + end + end + + local err = handle_stream("stdin", false) + if err then return fail_env(err) end + + err = handle_stream("stdout", true) + if err then return fail_env(err) end + + err = handle_stream("stderr", true) + if err then return fail_env(err) end + + return spec, child_only, parent_fds.stdin, parent_fds.stdout, parent_fds.stderr, nil +end + +---------------------------------------------------------------------- +-- Process spawning +---------------------------------------------------------------------- + +--- Spawn a new Process according to SpawnOptions. +---@param opts SpawnOptions +---@return Process|nil proc, string|nil err +local function spawn(opts) + assert(type(opts) == "table", "spawn: opts must be a table") + + local spec, child_only, stdin_fd, stdout_fd, stderr_fd, cfg_err = build_proc_spec(opts) + if not spec then return nil, cfg_err end + + local backend, spawn_err = proc_mod.spawn(spec) + if not backend then + for fd in pairs(child_only or {}) do sc.close(fd) end + for _, fd in ipairs{ stdin_fd, stdout_fd, stderr_fd } do + if fd then sc.close(fd) end + end + return nil, spawn_err + end + + -- Child-only ends are not needed in the parent. + if child_only then + for fd in pairs(child_only) do sc.close(fd) end + end + + local function open_stream(fd, mode) + if not fd then return nil end + return file_io.fdopen(fd, mode) + end + + local proc = setmetatable({ + backend = backend, + stdin = open_stream(stdin_fd, sc.O_WRONLY), + stdout = open_stream(stdout_fd, sc.O_RDONLY), + stderr = open_stream(stderr_fd, sc.O_RDONLY), + + _done = false, + _status = nil, + _code = nil, + _signal = nil, + _err = nil, + }, Process) + + return proc, nil +end + +--- Spawn wrapped as an Op. +---@param opts SpawnOptions +---@return Op -- when performed: proc:Process +local function spawn_op(opts) + return op.guard(function() + local proc, err = spawn(opts) + if not proc then error(err or "failed to spawn process") end + return op.always(proc) + end) +end + +---------------------------------------------------------------------- +-- Waiting for exit +---------------------------------------------------------------------- + +--- Op that completes when the process exits. +--- Returns (status, code, signal, err). +---@return Op +function Process:wait_op() + local backend = assert(self.backend, "process backend is closed") + + local function step() + if self._done then + return true, self._status, self._code, self._signal, self._err + end + + local exited, status, code, sig, err = backend:nonblock_wait() + if not exited then + return false + end + + self._done = true + self._status = status + self._code = code + self._signal = sig + self._err = err + + return true, status, code, sig, err + end + + local function register(task) + local pidfd = assert(backend.pidfd, "pidfd closed") + return poller.get():wait(pidfd, "rd", task) + end + + local function wrap(status, code, sig, err) + return status, code, sig, err + end + + return wait.waitable(register, step, wrap) +end + +function Process:wait() + return perform(self:wait_op()) +end + +function Process:wait_raw() + return op.perform_raw(self:wait_op()) +end + +---------------------------------------------------------------------- +-- Signalling and status +---------------------------------------------------------------------- + +function Process:kill(sig) + if not self.backend then + return false, "process backend closed" + end + return self.backend:send_signal(sig or sc.SIGKILL) +end + +function Process:terminate() + return self:kill(sc.SIGTERM) +end + +function Process:status() + if not self._done then return "running", nil end + + if self._signal then return "signalled", self._signal end + + return "exited", self._code +end + +---------------------------------------------------------------------- +-- Closing and shutdown +---------------------------------------------------------------------- + +function Process:close() + for _, name in ipairs{ "stdin", "stdout", "stderr" } do + local s = self[name] + if s then + s:close() + self[name] = nil + end + end + + if self.backend then + self.backend:close() + self.backend = nil + end +end + +--- Best-effort shutdown: TERM, grace period, then KILL; wait and close. +---@param grace number|nil -- seconds before SIGKILL; default 1.0 +function Process:shutdown(grace) + grace = grace or 1.0 + + if self.backend and not self._done then + self:terminate() + + local ev = op.boolean_choice( + self:wait_op():wrap(function(status, code, sig, err) + return true, status, code, sig, err + end), + sleep.sleep_op(grace):wrap(function() + return false + end) + ) + + local ok, is_exit = pcall(op.perform_raw, ev) + if not ok or not is_exit then + self:kill() + pcall(function() + self:wait_raw() + end) + end + end + + self:close() +end + +---------------------------------------------------------------------- +-- Bracket helper +---------------------------------------------------------------------- + +---@param spec SpawnOptions +---@param build_op fun(proc: Process): Op +local function with_process(spec, build_op) + assert(type(build_op) == "function", "with_process: build_op must be a function") + + return op.bracket( + function() + local proc, err = spawn(spec) + if not proc then + error(err or "failed to spawn process") + end + return proc + end, + function(proc, aborted) + if aborted then + proc:shutdown(1.0) + else + pcall(function() + proc:wait_raw() + end) + proc:close() + end + end, + build_op + ) +end + +---------------------------------------------------------------------- +-- Public API +---------------------------------------------------------------------- + +return { + Process = Process, + spawn = spawn, + spawn_op = spawn_op, + with_process = with_process, +} diff --git a/src/fibers/utils/syscall.lua b/src/fibers/utils/syscall.lua index d684f81..9ecf60d 100644 --- a/src/fibers/utils/syscall.lua +++ b/src/fibers/utils/syscall.lua @@ -10,6 +10,7 @@ local p_signal = require 'posix.signal' local p_socket = require 'posix.sys.socket' local p_errno = require 'posix.errno' local p_time = require 'posix.time' +local p_stdlib = require 'posix.stdlib' local bit = rawget(_G, "bit") or require 'bit32' local M = { ffi = {} } -- used this module format due to large number of exported functions @@ -189,26 +190,32 @@ function M.unlink(path) return p_unistd.unlink(path) end function M.write(fd, buf) return p_unistd.write(fd, buf) end -function M.waitpid(pid, options) return p_wait.wait(pid, options) end +function M.wait(pid, options) return p_wait.wait(pid, options) end function M.exit(status) return os.exit(status) end +function M.getenv(name) return p_stdlib.getenv(name) end + +function M.setenv(name, value, overwrite) return p_stdlib.setenv(name, value, overwrite) end + +function M._exit(status) return p_unistd._exit(status) end + ------------------------------------------------------------------------------- -- Convenience functions function M.set_nonblock(fd) local flags = assert(M.fcntl(fd, M.F_GETFL)) - assert(M.fcntl(fd, M.F_SETFL, bor(flags, M.O_NONBLOCK))) + return assert(M.fcntl(fd, M.F_SETFL, bor(flags, M.O_NONBLOCK))) end function M.set_block(fd) local flags = assert(M.fcntl(fd, M.F_GETFL)) - assert(M.fcntl(fd, M.F_SETFL, band(flags, bnot(M.O_NONBLOCK)))) + return assert(M.fcntl(fd, M.F_SETFL, band(flags, bnot(M.O_NONBLOCK)))) end function M.set_cloexec(fd) local flags = assert(M.fcntl(fd, M.F_GETFD)) - assert(M.fcntl(fd, M.F_SETFD, bor(flags, M.FD_CLOEXEC))) + return assert(M.fcntl(fd, M.F_SETFD, bor(flags, M.FD_CLOEXEC))) end function M.monotime() diff --git a/tests/test_io-proc_backend.lua b/tests/test_io-proc_backend.lua new file mode 100644 index 0000000..d04ee05 --- /dev/null +++ b/tests/test_io-proc_backend.lua @@ -0,0 +1,145 @@ +-- tests/test_proc_backend.lua + +-- Uses the real scheduler, ops and wait/waitset machinery. +print('testing: fibers.io.proc_backend') + +-- look one level up +package.path = "../src/?.lua;" .. package.path + +local proc_backend = require 'fibers.io.proc_backend' +local sc = require 'fibers.utils.syscall' +local stdlib = require 'posix.stdlib' + +local function read_all(fd) + local chunks = {} + while true do + local s, err, errno = sc.read(fd, 4096) + assert(s ~= nil or err, "read returned nil without error") + if s == nil then + error(("read failed: %s (errno %s)"):format(tostring(err), tostring(errno))) + end + if #s == 0 then + break + end + chunks[#chunks + 1] = s + end + return table.concat(chunks) +end + +local function wait_blocking(backend) + while true do + local exited, status, code, sig, err = backend:nonblock_wait() + assert(not err, "nonblock_wait error: " .. tostring(err)) + if exited then + return status, code, sig + end + end +end + +---------------------------------------------------------------------- +-- Test 1: simple exit code +---------------------------------------------------------------------- + +local function test_simple_exit() + local spec = { + argv = { "sh", "-c", "exit 7" }, + cwd = nil, + env = nil, + stdin_fd = nil, + stdout_fd = nil, + stderr_fd = nil, + } + + local backend, err = proc_backend.spawn(spec) + assert(backend, "spawn failed: " .. tostring(err)) + + local status, code, sig = wait_blocking(backend) + assert(code == 7, + ("expected exit code 7, got %s (status %s)"):format(tostring(code), tostring(status))) + assert(sig == nil, "expected no terminating signal") +end + +---------------------------------------------------------------------- +-- Test 2: inherited environment +---------------------------------------------------------------------- + +local function test_env_inherit() + -- Ensure a parent variable is set. + assert(stdlib.setenv("PROC_BACKEND_TEST", "parent_inherit")) + + -- Pipe to capture stdout. + local rd, wr = assert(sc.pipe()) + + local spec = { + argv = { "sh", "-c", 'printf "%s\n" "$PROC_BACKEND_TEST"' }, + cwd = nil, + env = nil, -- inherit environment + stdin_fd = nil, + stdout_fd = wr, -- child stdout -> pipe write end + stderr_fd = wr, -- send stderr the same way for simplicity + } + + local backend, err = proc_backend.spawn(spec) + assert(backend, "spawn failed: " .. tostring(err)) + + -- Parent no longer needs write end. + assert(sc.close(wr)) + + local out = read_all(rd) + assert(sc.close(rd)) + + local _, code, sig = wait_blocking(backend) + assert(code == 0, "expected shell to exit 0") + assert(sig == nil) + + assert(out == "parent_inherit\n", + ("expected 'parent_inherit', got %q"):format(out)) +end + +---------------------------------------------------------------------- +-- Test 3: environment override (env table) +---------------------------------------------------------------------- + +local function test_env_override() + -- Parent value that should be hidden/overridden. + assert(stdlib.setenv("PROC_BACKEND_TEST", "parent_value")) + + local rd, wr = assert(sc.pipe()) + + local spec = { + argv = { "sh", "-c", 'printf "%s\n" "$PROC_BACKEND_TEST"' }, + cwd = nil, + env = { PROC_BACKEND_TEST = "child_value" }, -- override + stdin_fd = nil, + stdout_fd = wr, + stderr_fd = wr, + } + + local backend, err = proc_backend.spawn(spec) + assert(backend, "spawn failed: " .. tostring(err)) + + assert(sc.close(wr)) + + local out = read_all(rd) + assert(sc.close(rd)) + + local _, code, sig = wait_blocking(backend) + assert(code == 0, "expected shell to exit 0") + assert(sig == nil) + + assert(out == "child_value\n", + ("expected 'child_value', got %q"):format(out)) +end + +---------------------------------------------------------------------- +-- Run tests +---------------------------------------------------------------------- + +local function main() + test_simple_exit() + test_env_inherit() + test_env_override() + io.stdout:write("proc_backend tests passed\n") +end + +main() diff --git a/tests/test_io-process.lua b/tests/test_io-process.lua new file mode 100644 index 0000000..cc30704 --- /dev/null +++ b/tests/test_io-process.lua @@ -0,0 +1,231 @@ +-- tests/test_process.lua +-- +-- Ad hoc tests and usage examples for fibers.process. +-- +-- Run as: luajit test_process.lua + +print("testing: fibers.io.process") + +-- Look one level up for src modules. +package.path = "../src/?.lua;" .. package.path + +local fibers = require 'fibers' +local process = require 'fibers.io.process' +local op = require 'fibers.op' +local sleep = require 'fibers.sleep' + +---------------------------------------------------------------------- +-- Simple test harness +---------------------------------------------------------------------- + +local function run_test(name, fn) + io.stderr:write("=== ", name, " ===\n") + fibers.run(function(scope) + fn(scope) + end) +end + +---------------------------------------------------------------------- +-- Tests +---------------------------------------------------------------------- + +-- 1. Simple spawn: check we can see a non-zero exit code and no signal. +run_test("simple exit code", function() + local proc, err = process.spawn{ + argv = { "sh", "-c", "exit 7" }, + stdin = "null", + stdout = "null", + stderr = "null", + } + assert(proc and not err, "spawn failed: " .. tostring(err)) + + local _, code, sig, werr = proc:wait() + assert(werr == nil, "wait error: " .. tostring(werr)) + assert(sig == nil, "expected no signal, got " .. tostring(sig)) + assert(code == 7, "expected exit code 7, got " .. tostring(code)) + + proc:close() +end) + +-- 2. stdin/stdout pipes: cat echoes what we send. +run_test("stdin/stdout pipe round-trip", function() + local proc, err = process.spawn{ + argv = { "sh", "-c", "cat" }, + stdin = "pipe", + stdout = "pipe", + stderr = "pipe", + } + assert(proc and not err, "spawn failed: " .. tostring(err)) + assert(proc.stdin and proc.stdout, "expected stdin/stdout streams") + + local msg = "line1\nline2\n" + local n, werr = proc.stdin:write(msg) + assert(werr == nil, "write error: " .. tostring(werr)) + assert(n == #msg, "short write: " .. tostring(n)) + proc.stdin:close() -- send EOF + + local out, rerr = proc.stdout:read_all() + assert(rerr == nil, "read_all stdout error: " .. tostring(rerr)) + assert(out == msg, ("unexpected echo: %q"):format(out)) + + local _, code, sig, werr2 = proc:wait() + assert(werr2 == nil, "wait error: " .. tostring(werr2)) + assert(sig == nil, "expected no signal") + assert(code == 0, "expected exit 0") + + proc:close() +end) + +-- 3. stderr as separate pipe vs stderr redirected to stdout. +run_test("stderr pipe vs stderr=stdout", function() + -- Separate stderr. + local proc1, err1 = process.spawn{ + argv = { "sh", "-c", "echo out; echo err 1>&2" }, + stdin = "null", + stdout = "pipe", + stderr = "pipe", + } + assert(proc1 and not err1, "spawn failed: " .. tostring(err1)) + + local out1, oerr1 = proc1.stdout:read_all() + local errout1, eerr1 = proc1.stderr:read_all() + assert(oerr1 == nil, "stdout read error: " .. tostring(oerr1)) + assert(eerr1 == nil, "stderr read error: " .. tostring(eerr1)) + assert(out1 == "out\n", ("unexpected stdout: %q"):format(out1)) + assert(errout1 == "err\n", ("unexpected stderr: %q"):format(errout1)) + proc1:wait() + proc1:close() + + -- stderr merged into stdout. + local proc2, err2 = process.spawn{ + argv = { "sh", "-c", "echo out; echo err 1>&2" }, + stdin = "null", + stdout = "pipe", + stderr = "stdout", + } + assert(proc2 and not err2, "spawn failed: " .. tostring(err2)) + assert(proc2.stderr == nil, "expected stderr to be nil when redirected to stdout") + + local merged, merr = proc2.stdout:read_all() + assert(merr == nil, "merged stdout read error: " .. tostring(merr)) + assert(merged:match("out"), ("merged output missing 'out': %q"):format(merged)) + assert(merged:match("err"), ("merged output missing 'err': %q"):format(merged)) + proc2:wait() + proc2:close() +end) + +-- 4. wait_op and a simple timeout pattern using boolean_choice. +run_test("wait_op with timeout pattern", function() + local proc, err = process.spawn{ + argv = { "/bin/sh", "-c", "sleep 0.5" }, + stdin = "null", + stdout = "null", + stderr = "null", + } + assert(proc and not err, "spawn failed: " .. tostring(err)) + + -- Correct use of boolean_choice: it injects the leading boolean itself. + local ev = op.boolean_choice( + proc:wait_op(), + sleep.sleep_op(2.0) + ) + + local is_exit, status, code, _, werr = fibers.perform(ev) + assert(is_exit == true, "process did not finish before timeout") + assert(werr == nil, "wait_op error: " .. tostring(werr)) + assert(code == 0, "expected exit code 0, got " .. tostring(code)) + -- sig may be nil or 0 depending on your syscall wrapper; we do not insist. + + -- Second wait should be immediate and return the same result. + local status2, code2, sig2, werr2 = proc:wait() + assert(status2 == status, "status changed between waits") + assert(code2 == code, "code changed between waits") + assert(sig2 == sig2, "signal comparison is trivial here") + assert(werr2 == nil, "unexpected error on second wait") + + proc:close() +end) + +-- 5. shutdown: terminate a long-running process (TERM then KILL if needed). +run_test("shutdown long-running process", function() + local proc, err = process.spawn{ + argv = { "sh", "-c", "while true; do sleep 1; done" }, + stdin = "null", + stdout = "null", + stderr = "null", + } + assert(proc and not err, "spawn failed: " .. tostring(err)) + + local t0 = fibers.now() + proc:shutdown(0.2) + local t1 = fibers.now() + + assert((t1 - t0) < 5.0, "shutdown took too long") + + local state, code_or_sig = proc:status() + assert(state == "exited" or state == "signalled", + ("unexpected final state: %s (%s)"):format(tostring(state), tostring(code_or_sig))) +end) + +-- 6. with_process: normal completion (bracket acquire/use/release). +run_test("with_process normal completion", function() + local ev = process.with_process({ + argv = { "sh", "-c", "echo bracket" }, + stdin = "null", + stdout = "pipe", + stderr = "pipe", + }, function(proc) + return proc.stdout:read_line_op() + end) + + local line, err = fibers.perform(ev) + assert(err == nil, "with_process line error: " .. tostring(err)) + assert(line == "bracket", ("unexpected line: %q"):format(line)) +end) + +-- 7. with_process aborted via choice: losing arm triggers shutdown(). +run_test("with_process aborted via choice", function() + local long_spec = { + argv = { "sh", "-c", "sleep 10" }, + stdin = "null", + stdout = "null", + stderr = "null", + } + + local ev = op.boolean_choice( + process.with_process(long_spec, function(proc) + return proc:wait_op() + end), + sleep.sleep_op(0.1) + ) + + local is_first = fibers.perform(ev) + -- We expect the timeout arm to win, so is_first should be false. + assert(is_first == false, "expected timeout arm to win in boolean_choice") +end) + +-- 8. spawn_op: spawning as an Op for CML-shaped code. +run_test("spawn_op basic usage", function() + local spawn_ev = process.spawn_op{ + argv = { "sh", "-c", "printf 'via_op'" }, + stdin = "null", + stdout = "pipe", + stderr = "pipe", + } + + local proc = fibers.perform(spawn_ev) + assert(proc, "spawn_op did not return a process") + + local out, rerr = proc.stdout:read_all() + assert(rerr == nil, "read_all error: " .. tostring(rerr)) + assert(out == "via_op", ("unexpected stdout from spawn_op: %q"):format(out)) + + local _, code, sig, werr = proc:wait() + assert(werr == nil, "wait error: " .. tostring(werr)) + assert(sig == nil, "expected no signal") + assert(code == 0, "expected exit 0 from spawn_op process") + + proc:close() +end) + +io.stderr:write("All process tests completed.\n") From b41ca992ff44c54df94dac715bcbd0172cc115c1 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Fri, 28 Nov 2025 09:13:20 +0000 Subject: [PATCH 051/138] deletes old stream abstraction and switches tests --- src/fibers/exec.lua | 247 --------------- src/fibers/stream.lua | 506 ------------------------------ src/fibers/stream/compat.lua | 124 -------- src/fibers/stream/file.lua | 208 ------------ src/fibers/stream/mem.lua | 123 -------- src/fibers/stream/socket.lua | 90 ------ src/fibers/utils/fixed_buffer.lua | 81 ----- tests/test.lua | 19 +- tests/test_pollio.lua | 44 --- 9 files changed, 10 insertions(+), 1432 deletions(-) delete mode 100644 src/fibers/exec.lua delete mode 100644 src/fibers/stream.lua delete mode 100644 src/fibers/stream/compat.lua delete mode 100644 src/fibers/stream/file.lua delete mode 100644 src/fibers/stream/mem.lua delete mode 100644 src/fibers/stream/socket.lua delete mode 100644 src/fibers/utils/fixed_buffer.lua delete mode 100644 tests/test_pollio.lua diff --git a/src/fibers/exec.lua b/src/fibers/exec.lua deleted file mode 100644 index 30f0247..0000000 --- a/src/fibers/exec.lua +++ /dev/null @@ -1,247 +0,0 @@ --- fibers/exec.lua --- Provides facilities for executing external commands asynchronously using fibers. -local file = require 'fibers.stream.file' -local pollio = require 'fibers.pollio' -local op = require 'fibers.op' -local buffer = require 'string.buffer' -local sc = require 'fibers.utils.syscall' - -local perform, choice = require 'fibers.performer'.perform, op.choice - -local io_mappings = { - stdin = sc.STDIN_FILENO, - stdout = sc.STDOUT_FILENO, - stderr = sc.STDERR_FILENO -} - --- Define the command type -local Cmd = {} -Cmd.__index = Cmd -- set metatable - ---- Constructor for Cmd. --- @param name The name or path of the command to execute. --- @param ... Additional arguments for the command. --- @return A new Cmd instance. -local function command(name, ...) - local self = setmetatable({}, Cmd) - self.path = name - self.args = { ... } - self.process = {} - self.pipes = { - child = {}, - parent = {} - } - return self -end - ---- Constructor for Cmd taking a `context`. --- @param ctx The context to run the command under. --- @param name The name or path of the command to execute. --- @param ... Additional arguments for the command. --- @return A new Cmd instance. -local function command_context(ctx, name, ...) - local cmd = command(name, ...) - cmd.ctx = ctx - return cmd -end - ---- Sets the command to launch with a different pgid. --- @param status True if diff pgid desired. -function Cmd:setpgid(status) - self._setpgid = status - return self -end - ---- Sets the signal to send to the child process on parent death. ---- @param sig integer The signal to send. -function Cmd:setprdeathsig(sig) - self._prdeathsig = sig - return self -end --- Re-emits the specified signals to the child process (or its process group, --- if setpgid(true) is used). This mirrors the behaviour of Go’s exec.Cmd and --- allows child processes to receive termination signals like SIGINT or SIGTERM. -function Cmd:forward_signals(...) - local signals = { ... } - for _, sig in ipairs(signals) do - sc.signal(sig, function() - if self.process and self.process.pid then - local target = self._setpgid and -self.process.pid or self.process.pid - sc.kill(target, sig) - end - end) - end -end - -function Cmd:_output_collector(pipes) - local function close_pipes() - for i = #pipes, 1, -1 do - pipes[i]:close() - pipes[i] = nil - end - end - - local err = self:start() - if err then - close_pipes() - return nil, err - end - - local buf = buffer.new() - - while #pipes > 0 do - local ops = {} - - -- build a read operation for each still-open pipe - for idx, pipe in ipairs(pipes) do - ops[#ops + 1] = pipe:read_some_chars_op():wrap(function(chunk) - if chunk then - buf:put(chunk) - else -- EOF: close and mark for removal - pipe:close() - table.remove(pipes, idx) - end - end) - end - - if self.ctx then - ops[#ops + 1] = self.ctx:done_op():wrap(close_pipes) - end - - perform(choice(unpack(ops))) - end - - return buf:tostring(), self:wait() -end - ---- Gets combined stdout + stderr as a single string. --- @return output string on success, or nil + error on failure -function Cmd:combined_output() - if self.ctx and self.ctx:err() then return nil, "context cancelled" end - - local pipes = { self:stdout_pipe(), self:stderr_pipe() } - return self:_output_collector(pipes) -end - ---- Gets the output of stdout. --- @return The output and any error. -function Cmd:output() - if self.ctx and self.ctx:err() then return nil, "context cancelled" end - - local pipes = { self:stdout_pipe() } - return self:_output_collector(pipes) -end - ---- Starts the command and waits for it to complete. --- @return Any error. -function Cmd:run() - local err = self:start() - return err and err or self:wait() -end - ---- Starts the command. --- @return Any error. -function Cmd:start() - if self.process.pid then return "process already started" end - if self.ctx and self.ctx:err() then - for _, v in pairs(self.pipes.child) do sc.close(v) end - return "context cancelled" - end - - local ready_read, ready_write = assert(sc.pipe()) - - local pid = assert(sc.fork()) - if pid == 0 then -- child - if self._prdeathsig then sc.prctl(sc.PR_SET_PDEATHSIG, self._prdeathsig) end - if self._setpgid then assert(sc.setpid('p', 0, 0) == 0) end - - -- pipework - for name, fd in pairs(io_mappings) do - if self.pipes.parent[name] then sc.close(self.pipes.parent[name]) end - - local child_fd = self.pipes.child[name] or - assert(sc.open("/dev/null", (fd == sc.STDIN_FILENO) and sc.O_RDONLY or sc.O_WRONLY)) - - assert(sc.dup2(child_fd, fd)) - if child_fd ~= fd then sc.close(child_fd) end -- always close duplicate if distinct - end - - sc.close(ready_read) - sc.set_cloexec(ready_write) -- Close on successful exec - local _, _, errno = sc.execp(self.path, self.args) -- will not return unless unsuccessful - if errno then - sc.write(ready_write, string.char(errno)) -- Write errno to pipe - sc.close(ready_write) - sc.exit(1) - end - end - -- parent - self.process.pid = pid - for _, v in pairs(self.pipes.child) do sc.close(v) end - - sc.close(ready_write) - ready_read = file.fdopen(ready_read) - local byte = ready_read:read_char() -- will politely block until child is ready - ready_read:close() - if byte then - return "child failed to start: errno=" .. string.byte(byte) - end - - self.process.pidfd = assert(sc.pidfd_open(self.process.pid, 0)) - - return nil -end - ---- Kills the command. --- @return Any error. -function Cmd:kill() - if not self.process.pid then return "process not started" end - if self.process.state then return "process has already completed" end - - local target = self._setpgid and -self.process.pid or self.process.pid - local res, err, errno = sc.kill(target, sc.SIGKILL) - assert(res == 0 or errno == sc.ESRCH, err) -end - -function Cmd:_pipe_creator(stdio) - local rd, wr = assert(sc.pipe()) - self.pipes.parent[stdio] = (stdio == 'stdin') and wr or rd - self.pipes.child[stdio] = (stdio == 'stdin') and rd or wr - return file.fdopen(self.pipes.parent[stdio]) -end - ---- Creates a pipe for stdout. Call `:close()` when finished. --- @return The stdout pipe or an error. -function Cmd:stdout_pipe() return self:_pipe_creator('stdout') end - ---- Creates a pipe for stderr. Call `:close()` when finished. --- @return The stderr pipe or an error. -function Cmd:stderr_pipe() return self:_pipe_creator('stderr') end - ---- Creates a pipe for stdin. Call `:close()` when finished. --- @return The stdin pipe or an error. -function Cmd:stdin_pipe() return self:_pipe_creator('stdin'):setvbuf('no') end - ---- Waits for the command to complete. --- @return The completion status or an error. -function Cmd:wait() - local ops = { pollio.fd_readable_op(self.process.pidfd) } - - if self.ctx then - ops[#ops + 1] = self.ctx:done_op():wrap(function() - self:kill() - perform(pollio.fd_readable_op(self.process.pidfd)) - end) - end - - perform(choice(unpack(ops))) - local _, _, status = sc.waitpid(self.process.pid) - self.process.state = status - sc.close(self.process.pidfd) - if status ~= 0 then return status end -end - -return { - command = command, - command_context = command_context -} diff --git a/src/fibers/stream.lua b/src/fibers/stream.lua deleted file mode 100644 index 3daa769..0000000 --- a/src/fibers/stream.lua +++ /dev/null @@ -1,506 +0,0 @@ ---- Module implementing a fiber-aware streaming I/O interface. --- This module provides an abstraction layer over typical I/O operations, --- facilitating buffered reads and writes with non-blocking support --- in a fiber-based concurrency framework. --- @module fibers.stream - -local sc = require 'fibers.utils.syscall' -local op = require 'fibers.op' -local fixed_buffer = require 'fibers.utils.fixed_buffer' -local buffer = require 'string.buffer' - -local perform = require 'fibers.performer'.perform - -local unpack = table.unpack or unpack -- luacheck: ignore -- Compatibility fallback - -local Stream = {} -Stream.__index = Stream - -local DEFAULT_BUFFER_SIZE = 2^12 -- 4096 bytes as a sensible default buffer size - ---- Open a new stream. --- Creates and returns a new stream object. --- @param io The underlying I/O object to wrap. Must support read, write, and optionally seek. --- @param readable Whether the stream should be readable. Defaults to true if not specified. --- @param writable Whether the stream should be writable. Defaults to true if not specified. --- @param buffer_size The size of the buffer to use for the stream. Defaults to 4096 bytes. --- @return A new stream object. -local function open(io, readable, writable, buffer_size) - local ret = setmetatable( - { io = io, line_buffering = false, random_access = false }, - Stream) - if readable ~= false then - ret.rx = fixed_buffer.new(buffer_size or DEFAULT_BUFFER_SIZE) - end - if writable ~= false then - ret.tx = fixed_buffer.new(buffer_size or DEFAULT_BUFFER_SIZE) - end - if io.seek and io:seek(sc.SEEK_CUR, 0) then ret.random_access = true end - return ret -end - ---- Check if an object is a stream. --- @param x The object to check. --- @return True if the object is a stream, false otherwise. -local function is_stream(x) - return type(x) == 'table' and getmetatable(x) == Stream -end - ---- Set the stream to non-blocking mode. -function Stream:nonblock() self.io:nonblock() end - ---- Set the stream to blocking mode. -function Stream:block() self.io:block() end - -local function core_write_op(stream, buf, count, flush_needed) - local ptr = nil - if buf then - ptr = buf:ref() - end - - local tally = 0 - local write_directly - - local function write_attempt() - while true do - -- Flush buffered writes first - if flush_needed then - while not stream.tx:is_empty() do - local written, err = stream.io:write(stream.tx:peek()) - if err then return true, tally, err end - if written == nil then - stream._part_write.tally = tally - return false - end - if written == 0 then return true, tally, nil end - stream.tx:advance_read(written) - end - flush_needed = nil - end - if tally == count then - return true, tally - end - if write_directly then - assert(ptr, "write_directly requires a non-nil buffer") - local written, err = stream.io:write(ptr + tally, count - tally) - if err then return true, tally, err end - if written == nil then - stream._part_write.tally = tally - return false - end - if written == 0 then return true, tally, nil end - tally = tally + written - else - assert(ptr, "buffer required for buffered write") - local to_write = math.min(stream.tx:write_avail(), count - tally) - stream.tx:write(ptr + tally, to_write) - tally = tally + to_write - flush_needed = stream.tx:is_full() or - (stream.line_buffering and stream.tx:find('\n')) - end - end - end - - local function try() - stream._part_write = { buf = buf, count = count, tally = 0 } - write_directly = buf ~= nil and count >= stream.tx.size - return write_attempt() - end - - local function block(suspension, wrap_fn) - local task = {} - task.run = function() - if not suspension:waiting() then return end - local success, _, err = write_attempt() - if success then - suspension:complete(wrap_fn, tally, err) - else - stream.io:task_on_writable(task) - end - end - stream.io:task_on_writable(task) - end - - local function wrap(...) - stream._part_write = nil - return ... - end - - return op.new_primitive(wrap, try, block) -end - - - -function Stream:partial_write() - if self._part_write then return self._part_write.tally end -end - -function Stream:reset_partial_write() - self._part_write = nil -end - -function Stream:write_chars_op(str) - local buf = require("string.buffer").new() - buf:set(str) -- zero-copy reference to the string - return core_write_op(self, buf, #str) -end - ---- Write a string to the stream. --- @param n string to write --- @return number of bytes written --- @return error encountered during the write -function Stream:write_chars(str) - return perform(self:write_chars_op(str)) -end - -local function core_read_op(stream, buf, min, max, terminator) - local tally = 0 - local function find_terminator() - local term_loc = stream.rx:find(terminator) - if term_loc then - local final = tally + term_loc + #terminator - return final, final - end - return min, max - end - - local function read_attempt() - while true do - if terminator then - min, max = find_terminator() - end - - local from_buffer = math.min(stream.rx:read_avail(), max - tally) - local ptr = buf:reserve(from_buffer) - stream.rx:read(ptr, from_buffer) - buf:commit(from_buffer) - tally = tally + from_buffer - if tally >= min then return true, buf, tally end - - stream.rx:reset() - local ptr2, _ = stream.rx:reserve(stream.rx.size) - local did_read, err = stream.io:read(ptr2, stream.rx.size) - stream.rx:commit(did_read or 0) - - if err then - return true, buf, tally, err - elseif did_read == nil then - stream._part_read.tally = tally - return false - elseif did_read == 0 then - return true, buf, tally, nil - end - end - end - - local function try() - stream._part_read = { buf = buf, tally = 0 } - return read_attempt() - end - - local function block(suspension, wrap_fn) - local task = {} - task.run = function() - if not suspension:waiting() then return end - local success, _, _, err = read_attempt() - if success then - suspension:complete_and_run(wrap_fn, buf, tally, err) - else - stream.io:task_on_readable(task) - end - end - stream.io:task_on_readable(task) - end - - local function wrap(...) - stream._part_read = nil - return ... - end - - return op.new_primitive(wrap, try, block) -end - -function Stream:partial_read() - if self._part_read then - return self._part_read.tally, self._part_read.buf:tostring() - end -end - -function Stream:reset_partial_read() - self._part_read = nil -end - ---- Operation to read a specified number of characters from the stream. --- @param count number of characters to read --- @return operation -function Stream:read_chars_op(count) - local buf = buffer.new(count) - return core_read_op(self, buf, count, count):wrap(function(ret_buf, cnt, err) - if cnt == 0 then return nil, err end - return ret_buf:tostring(), err - end) -end - ---- Read a specified number of characters from the stream. --- @param count number of characters to read --- @return string containing the characters read --- @return error during read, if any -function Stream:read_chars(count) - return perform(self:read_chars_op(count)) -end - ---- Operation to read up to a specified number of characters from the stream. --- @param count maximum number of characters to read --- @return operation -function Stream:read_some_chars_op(count) - if count == nil then count = self.rx.size end - local buf = buffer.new(count) - return core_read_op(self, buf, 1, count):wrap(function(ret_buf, cnt, err) - return cnt > 0 and ret_buf:tostring() or nil, err - end) -end - ---- Read up to a specified number of characters from the stream. --- @param count maximum number of characters to read --- @return string containing the characters read --- @return error during read, if any -function Stream:read_some_chars(count) - return perform(self:read_some_chars_op(count)) -end - ---- Operation to read all characters from the stream. --- @return operation -function Stream:read_all_chars_op() - local buf = buffer.new(self.rx.size) - return core_read_op(self, buf, math.huge, math.huge):wrap(function(ret_buf, _, err) - return ret_buf:tostring(), err - end) -end - ---- Read all characters from the stream. --- @return string containing all characters read --- @return error during read, if any -function Stream:read_all_chars() - return perform(self:read_all_chars_op()) -end - ---- Operation to read a single character from the stream. --- @return operation -function Stream:read_char_op() - local buf = buffer.new(1) - return core_read_op(self, buf, 1, 1):wrap(function(ret_buf, cnt, err) - return cnt == 1 and ret_buf:tostring() or nil, err - end) -end - ---- Read a single character from the stream. --- @return the character read, or nil if at end of file --- @return error during read, if any -function Stream:read_char() - return perform(self:read_char_op()) -end - ---- Operation to read a line from the stream. --- @param style 'keep' to keep the line terminator, 'discard' to remove it (default 'discard') --- @return operation -function Stream:read_line_op(style) - style = style or 'discard' - local buf = buffer.new(self.rx.size) - return core_read_op(self, buf, math.huge, math.huge, "\n"):wrap(function(ret_buf, cnt) - if cnt == 0 then return nil end - local str = ret_buf:tostring() - return style == 'keep' and str or str:sub(1, -2) - end) -end - ---- Read a line from the stream. --- @param style 'keep' to keep the line terminator, 'discard' to remove it (default 'discard') --- @return the line read, or nil if at end of file --- @return error during read, if any -function Stream:read_line(style) - return perform(self:read_line_op(style)) -end - -function Stream:flush_input() - if self.random_access and self.rx then - local buffered = self.rx:read_avail() - if buffered ~= 0 then - assert(self.io:seek('cur', -buffered)) - self.rx:reset() - end - end -end - -function Stream:flush_output_op() - return core_write_op(self, nil, 0, true) -end - ---- Flush the output buffer, writing all buffered data to the underlying IO. -function Stream:flush_output() - return perform(self:flush_output_op()) -end - -Stream.flush = Stream.flush_output - ---- Close the stream, optionally flushing remaining data. --- @return true on success, followed by any additional return values from the underlying IO close operation -function Stream:close() - if self.tx then self:flush_output() end - self.rx, self.tx = nil, nil - local success, exit_type, code = self.io:close() - self.io = nil - return success, exit_type, code -end - ---- Create an iterator over lines in the stream. --- The iterator returns each line, stripped of its end-of-line marker. --- @return function iterator over lines -function Stream:lines(...) - local formats = { ... } - if #formats == 0 then - return function() return self:read_line('discard') end -- Fast path. - end - return function() return self:read(unpack(formats)) end -end - ---- Lua 5.1 inspired file:read_op() method. --- The function supports various formats to control the reading behavior. --- @param ... format (optional) specifies the reading format: --- '*a': reads the whole file from the current position to the end. --- '*l': reads the next line not including the end of the line. --- '*L': reads the next line including the end of the line. --- number: reads a string up to the number of characters specified. --- If no format is specified, it defaults to reading the next line without the end-of-line marker. --- @return operation to perform the read based on the specified format -function Stream:read_op(...) - assert(self.rx, "expected a readable stream") - local args = { ... } - if #args == 0 then return self:read_line_op('discard') end -- Default format. - if #args > 1 then error('multiple formats unimplemented') end - local format = args[1] - if format == '*n' then - error('read numbers unimplemented') - elseif format == '*a' then - return self:read_all_chars_op() - elseif format == '*l' then - return self:read_line_op('discard') - elseif format == '*L' then - return self:read_line_op('keep') - else - assert(type(format) == 'number' and format > 0, 'bad format') - local number = format - return self:read_chars_op(number) - end -end - ---- Lua 5.1's file:read() method. --- This method simplifies reading data by automatically performing the operation initiated by `read_op`. --- @param ... format (optional) specifies the reading format. Refer to `read_op` for details. --- @return the data read from the stream according to the specified format, or nil on end of file --- @return error during read, if any -function Stream:read(...) - return perform(self:read_op(...)) -end - ---- Get or set the file position. --- @param whence base for the offset: 'set', 'cur', or 'end' --- @param offset offset from the base, in bytes --- @return new position, or nil and an error message if the operation fails -function Stream:seek(whence, offset) - if not self.random_access then return nil, 'stream is not seekable' end - if whence == nil then whence = sc.SEEK_CUR end - if offset == nil then offset = 0 end - if whence == sc.SEEK_CUR and offset == 0 then - -- Just a position query. - local ret, err = self.io:seek(sc.SEEK_CUR, 0) - if ret == nil then return ret, err end - if self.tx and self.tx:read_avail() ~= 0 then - return ret + self.tx:read_avail() - end - if self.rx and self.rx:read_avail() ~= 0 then - return ret - self.rx:read_avail() - end - return ret - end - if self.rx then self:flush_input() end; if self.tx then self:flush_output() end - return self.io:seek(whence, offset) -end - ---- Set the buffering mode for the stream. --- Adjusts the buffering strategy for the stream's input and output. --- @param mode The buffering mode: 'no', 'line', or 'full'. --- @param size The size of the buffer, in bytes. Defaults to DEFAULT_BUFFER_SIZE if not specified. --- @return The stream object. -function Stream:setvbuf(mode, size) - if mode == 'no' then - self.line_buffering, size = false, 1 - elseif mode == 'line' then - self.line_buffering = true - elseif mode == 'full' then - self.line_buffering = false - else - error('bad mode', mode) - end - - size = size or DEFAULT_BUFFER_SIZE - - local function transfer_buffered_bytes(old, new) - while old:read_avail() > 0 do - local buf, count = old:peek() - new:write(buf, count) - old:advance_read(count) - end - end - if self.rx and self.rx.size ~= size then - if self.rx:read_avail() > size then - error('existing buffered input exceeds new buffer size') - end - local new_rx = fixed_buffer.new(size) - transfer_buffered_bytes(self.rx, new_rx) - self.rx = new_rx - end - - if self.tx and self.tx.size ~= size then - while self.tx:read_avail() >= size do self:flush() end - local new_tx = fixed_buffer.new(size) - transfer_buffered_bytes(self.tx, new_tx) - self.tx = new_tx - end - - return self -end - ---- Lua 5.1 inspired file:write_op() method. --- Returns an op that will write the value of each of its arguments to the --- file. The arguments must be strings or numbers. To write other values, --- use tostring or string.format before write. --- @param ... arguments to write (must be strings or numbers) --- @return operation -function Stream:write_op(...) - local n = select('#', ...) - for i = 1, n do - local arg = select(i, ...) - if type(arg)~="number" and type(arg)~="string" then - return nil, 'arguments must be strings or numbers' - end - end - return self:write_chars_op(table.concat({...})) -end - ---- Lua 5.1 file:write() method. --- Write the value of each of its arguments to the file. The arguments must be --- strings or numbers. To write other values, use tostring or string.format --- before write. --- @param ... data to write (strings or numbers) --- @return true on success, or nil plus an error message on failure -function Stream:write(...) - return perform(self:write_op(...)) -end - --- The result may be nil. -function Stream:filename() return self.io.filename end - -return { - open = open, - is_stream = is_stream -} diff --git a/src/fibers/stream/compat.lua b/src/fibers/stream/compat.lua deleted file mode 100644 index b937db0..0000000 --- a/src/fibers/stream/compat.lua +++ /dev/null @@ -1,124 +0,0 @@ --- Use of this source code is governed by the Apache 2.0 license; see COPYING. - --- Shim to replace Lua's built-in IO module with streams. - -local exec = require 'fibers.exec' -local stream = require 'fibers.stream' -local file = require 'fibers.stream.file' -local sc = require 'fibers.utils.syscall' - -local unpack = table.unpack or unpack -- luacheck: ignore -- Compatibility fallback - -local original_io = _G.io -- Save the original io module -local io = {} - -function io.close(f) - if f == nil then f = io.current_output end - f:close() -end - -function io.flush() - io.current_output:flush() -end - -function io.input(new) - if new == nil then return io.current_input end - if type(new) == string then new = io.open(new, 'r') end - io.current_input = new -end - -function io.lines(filename, ...) - if filename == nil then return io.current_input:lines() end - local fileStream = assert(io.open(filename, 'r')) - local iter = fileStream:lines(...) - return function() - local line = { iter() } - if line[1] == nil then - fileStream:close() - return nil - end - return unpack(line) - end -end - -io.open = file.open - -function io.output(new) - if new == nil then return io.current_output end - if type(new) == string then new = io.open(new, 'w') end - io.current_output = new -end - - -function io.popen(prog, mode) - assert(type(prog) == 'string', "io.popen: prog must be a string") - mode = mode or 'r' - assert(mode == 'r' or mode == 'w', "io.popen: mode must be 'r' or 'w'") - - -- launch via /bin/sh -c (Lua-compatible semantics) - local cmd = exec.command('sh', '-c', prog) - - -- get a Stream for the appropriate end - local s = (mode == 'r') and cmd:stdout_pipe() or cmd:stdin_pipe() - - local err = cmd:start() - if err then - pcall(function() s:close() end) - return nil, "failed to start: " .. tostring(err) - end - - -- Override the *underlying IO* close, not the Stream close. - -- Stream:close() will call this and forward the return values. - local io_close = s.io.close - function s.io:close() - -- close our pipe end first - local _, _, _, _ = pcall(io_close, self) - -- wait for child using pidfd (handled inside exec:wait) - local status = cmd:wait() -- nil on success, non-zero exit code on failure - if status and status ~= 0 then - return nil, "exit", status - else - return true, "exit", 0 - end - end - - return s -end - - -function io.read(...) - return io.current_input:read(...) -end - -io.tmpfile = file.tmpfile - -function io.type(x) - if not stream.is_stream(x) then return nil end - if not x.io then return 'closed file' end - return 'file' -end - -function io.write(...) - return io.current_output:write(...) -end - -local function install() - if _G.io == io then return end - _G.io = io - io.stdin = file.fdopen(sc.STDIN_FILENO, sc.O_RDONLY) - io.stdout = file.fdopen(sc.STDOUT_FILENO, sc.O_WRONLY) - io.stderr = file.fdopen(sc.STDERR_FILENO, sc.O_WRONLY) - if sc.isatty(io.stdout.io.fd) then io.stdout:setvbuf('line') end - io.stderr:setvbuf('no') - io.input(io.stdin) - io.output(io.stdout) -end - -local function uninstall() - _G.io = original_io -end - -return { - install = install, - uninstall = uninstall -} diff --git a/src/fibers/stream/file.lua b/src/fibers/stream/file.lua deleted file mode 100644 index 5c83e52..0000000 --- a/src/fibers/stream/file.lua +++ /dev/null @@ -1,208 +0,0 @@ --- Use of this source code is governed by the Apache 2.0 license; see COPYING. - ---- fibers.stream.file module --- A stream IO implementation for file descriptors. --- @module fibers.stream.file - -package.path = "../../?.lua;../?.lua;" .. package.path - -local stream = require 'fibers.stream' -local pollio = require 'fibers.pollio' -local sc = require 'fibers.utils.syscall' - -local perform = require 'fibers.performer'.perform - -local pio_handler = pollio.install_poll_io_handler() - -local bit = rawget(_G, "bit") or require 'bit32' - --- A blocking handler provides for configurable handling of EWOULDBLOCK --- conditions. The goal is to allow for normal blocking operations, but --- also to allow for a cooperative coroutine-based multitasking system --- to run other tasks when a stream would block. --- --- In the case of normal, blocking file descriptors, the blocking --- handler will only be called if a read or a write returns EAGAIN, --- presumably because the sc was interrupted by a signal. In that --- case the correct behavior is to just return directly, which will --- cause the stream to try again. --- --- For nonblocking file descriptors, the blocking handler could suspend --- the current coroutine and arrange to restart it once the FD becomes --- readable or writable. However the default handler here doesn't --- assume that we're running in a coroutine. In that case we could --- block in a poll() without suspending. Currently however the default --- blocking handler just returns directly, which will cause the stream --- to busy-wait until the FD becomes active. - -local File = {} -File.__index = File - -sc.signal(sc.SIGPIPE, sc.SIG_IGN) - -local function new_file_io(fd, filename) - pollio.init_nonblocking(fd) - return setmetatable({ fd = fd, filename = filename }, File) -end - -function File:nonblock() sc.set_nonblock(self.fd) end - -function File:block() sc.set_block(self.fd) end - -function File:read(buf, count) - local did_read, err, errno = sc.ffi.read(self.fd, buf, count) - if errno then - -- If the read would block, indicate to caller with nil return. - if errno == sc.EAGAIN or errno == sc.EWOULDBLOCK then return nil, nil end - -- Otherwise, signal an error. - return nil, err - else - -- Success; return number of bytes read. If EOF, count is 0. - return did_read, nil - end -end - -function File:write(buf, count) - -- local success, did_write, err, errno = pcall(sc.ffi.write, self.fd, buf, count) - local did_write, err, errno = sc.ffi.write(self.fd, buf, count) - if err then - -- If the write would block, indicate to caller with nil return. - if errno == sc.EAGAIN or errno == sc.EWOULDBLOCK then return nil, nil end - -- Otherwise, signal an error. - return nil, err - elseif did_write == 0 then - -- This is a bit of a squirrely case: no bytes written, but no - -- error code. Return nil to indicate that it's probably a good - -- idea to wait until the FD is writable again. - return nil, nil - else - -- Success; return number of bytes written. - return did_write, nil - end -end - -function File:seek(whence, offset) - -- In case of success, return the final file position, measured in - -- bytes from the beginning of the file. On failure, return nil, - -- plus a string describing the error. - return sc.lseek(self.fd, offset, whence) -end - -function File:wait_for_readable_op() return pollio.fd_readable_op(self.fd) end - -function File:wait_for_readable() perform(self:wait_for_readable_op()) end - -function File:wait_for_writable_op() return pollio.fd_writable_op(self.fd) end - -function File:wait_for_writable() perform(self:wait_for_writable_op()) end - -function File:task_on_readable(t) if self.fd then pio_handler:task_on_readable(self.fd, t) end end - -function File:task_on_writable(t) if self.fd then pio_handler:task_on_writable(self.fd, t) end end - -function File:close() - sc.close(self.fd) - self.fd = nil -end - -local function fdopen(fd, flags, filename) - local io = new_file_io(fd, filename) - if flags == nil then - flags = assert(sc.fcntl(fd, sc.F_GETFL)) - -- this appears only to be relevant to 32 bit systems, ljsc has - -- reference to this being a flag with value octal('0100000') on such systems - else - flags = bit.bor(flags, sc.O_LARGEFILE) - end - local readable, writable = false, false - local mode = bit.band(flags, sc.O_ACCMODE) - if mode == sc.O_RDONLY or mode == sc.O_RDWR then readable = true end - if mode == sc.O_WRONLY or mode == sc.O_RDWR then writable = true end - local stat = sc.fstat(fd) - return stream.open(io, readable, writable, stat and stat.st_blksize) -end - -local modes = { - r = sc.O_RDONLY, - w = bit.bor(sc.O_WRONLY, sc.O_CREAT, sc.O_TRUNC), - a = bit.bor(sc.O_WRONLY, sc.O_CREAT, sc.O_APPEND), - ['r+'] = sc.O_RDWR, - ['w+'] = bit.bor(sc.O_RDWR, sc.O_CREAT, sc.O_TRUNC), - ['a+'] = bit.bor(sc.O_RDWR, sc.O_CREAT, sc.O_APPEND) -} -do - local binary_modes = {} - for k, v in pairs(modes) do binary_modes[k .. 'b'] = v end - for k, v in pairs(binary_modes) do modes[k] = v end -end - -local permissions = {} -permissions['rw-r--r--'] = bit.bor(sc.S_IRUSR, sc.S_IWUSR, sc.S_IRGRP, sc.S_IROTH) -permissions['rw-rw-rw-'] = bit.bor(permissions['rw-r--r--'], sc.S_IWGRP, sc.S_IWOTH) - -local function open(filename, mode, perms) - if mode == nil then mode = 'r' end - local flags = modes[mode] - if flags == nil then return nil, 'invalid mode: ' .. tostring(mode) end - -- This set of permissions is what open() uses. Note that these - -- permissions will be modulated by the umask. - if perms == nil then perms = permissions['rw-rw-rw-'] end - local fd, err, _ = sc.open(filename, flags, permissions[perms]) - if fd == nil then return nil, err end - return fdopen(fd, flags, filename) -end - -local function mktemp(name, perms) - if perms == nil then perms = permissions['rw-r--r--'] end - -- In practice this requires that someone seeds math.random with good - -- entropy. In Snabb that is the case (see core.main:initialize()). - local t = math.random(1e7) - local tmpnam, fd, err, _ - for i = t, t + 10 do - tmpnam = name .. '.' .. i - fd, err, _ = sc.open(tmpnam, bit.bor(sc.O_CREAT, sc.O_RDWR, sc.O_EXCL), perms) - if fd then return fd, tmpnam end - end - error("Failed to create temporary file " .. tmpnam .. ": " .. err) -end - -local function tmpfile(perms, tmpdir) - if tmpdir == nil then tmpdir = os.getenv("TMPDIR") or "/tmp" end - local fd, tmpnam = mktemp(tmpdir .. '/' .. 'tmp', perms) - local f = fdopen(fd, sc.O_RDWR, tmpnam) - -- FIXME: Doesn't arrange to ensure the file is removed in all cases; - -- calling close is required. - function f:rename(new) - self:flush() - sc.fsync(self.io.fd) - local res, err = sc.rename(self.io.filename, new) - if not res then - error("failed to rename " .. self.io.filename .. " to " .. new .. ": " .. tostring(err)) - end - self.io.filename = new - self.io.close = File.close -- Disable remove-on-close. - end - - function f.io:close() - File.close(self) - local res, err = sc.unlink(self.filename) - if not res then - error('failed to remove ' .. self.filename .. ': ' .. tostring(err)) - end - end - - return f -end - -local function pipe() - local rd, wr = assert(sc.pipe()) - return fdopen(rd, sc.O_RDONLY), fdopen(wr, sc.O_WRONLY) -end - -return { - init_nonblocking = pollio.init_nonblocking, - fdopen = fdopen, - open = open, - tmpfile = tmpfile, - pipe = pipe, -} diff --git a/src/fibers/stream/mem.lua b/src/fibers/stream/mem.lua deleted file mode 100644 index 4d74e1a..0000000 --- a/src/fibers/stream/mem.lua +++ /dev/null @@ -1,123 +0,0 @@ --- Use of this source code is governed by the Apache 2.0 license; see COPYING. - --- A memory-backed stream IO implementation. - -local stream = require 'fibers.stream' -local sc = require 'fibers.utils.syscall' - -local ffi = sc.is_LuaJIT and require 'ffi' or require 'cffi' - -local Mem = {} -Mem.__index = Mem - -local INITIAL_SIZE = 4096 - -local function new_buffer(len) return ffi.new('uint8_t[?]', len) end - -local function new_mem_io(buf, len, size, growable) - if buf == nil then - if size == nil then size = len or INITIAL_SIZE end - buf = new_buffer(size) - else - if size == nil then size = len end - assert(size ~= nil) - end - if len == nil then len = 0 end - return setmetatable( - { buf = buf, pos = 0, len = len, size = size, growable = growable }, - Mem) -end - -function Mem:nonblock() end - -function Mem:block() end - -function Mem:read(buf, count) - count = math.min(count, self.len - self.pos) - ffi.copy(buf, self.buf + self.pos, count) - self.pos = self.pos + count - return count -end - -function Mem:grow_buffer(count) - assert(self.growable, "ran out of space while writing") - if self.len == self.size then - self.size = math.max(self.size * 2, 1024) - local buf = new_buffer(self.size) - ffi.copy(buf, self.buf, self.len) - self.buf = buf - end - self.len = math.min(self.size, self.len + count) - return self.len -end - -function Mem:write(buf, count) - if self.pos == self.len then self:grow_buffer(count) end - count = math.min(count, self.len - self.pos) - ffi.copy(self.buf + self.pos, buf, count) - self.pos = self.pos + count - return count -end - -function Mem:seek(whence, offset) - if whence == sc.SEEK_CUR then - offset = self.pos + offset - elseif whence == sc.SEEK_END then - offset = self.len + offset - elseif whence ~= sc.SEEK_SET then - error('bad "whence": ' .. tostring(whence)) - end - if offset < 0 then return nil, "invalid offset" end - while self.len < offset do self:grow_buffer(offset - self.len) end - self.pos = offset - return offset -end - -function Mem:wait_for_readable() end - -function Mem:wait_for_writable() end - -function Mem:close() - self.buf, self.pos, self.len, self.size, self.growable = nil, nil, nil, nil, nil -end - -local readable_modes = { r = true, ['r+'] = true, ['w+'] = true } -local writable_modes = { ['r+'] = true, w = true, ['w+'] = true } - -local function open(buf, len, mode) - if mode == nil then mode = 'r+' end - local readable, writable = readable_modes[mode], writable_modes[mode] - assert(readable or writable) - local io = new_mem_io(buf, len, len, writable) - return stream.open(io, readable, writable) -end - -local function tmpfile() - return open() -end - -local function open_input_string(str) - local len = #str - local buf = new_buffer(len) - ffi.copy(buf, str, len) - local readable, writable = true, false - local io = new_mem_io(buf, len, len, writable) - return stream.open(io, readable, writable) -end - -local function call_with_output_string(f, ...) - local out = tmpfile() - local args = { ... } - table.insert(args, out) - f(unpack(args)) - out:flush_output() - -- Can take advantage of internals to read directly. - return ffi.string(out.io.buf, out.io.len) -end - -return { - open = open, - tmpfile = tmpfile, - open_input_string = open_input_string, - call_with_output_string = call_with_output_string -} diff --git a/src/fibers/stream/socket.lua b/src/fibers/stream/socket.lua deleted file mode 100644 index 45f69d9..0000000 --- a/src/fibers/stream/socket.lua +++ /dev/null @@ -1,90 +0,0 @@ --- Use of this source code is governed by the Apache 2.0 license; see COPYING. - --- A stream IO implementation for sockets. - -local file = require 'fibers.stream.file' -local sc = require 'fibers.utils.syscall' - -local Socket = {} -Socket.__index = Socket - --- local sigpipe_handler - -local function socket(domain, stype, protocol) - -- if sigpipe_handler == nil then sigpipe_handler = sc.signal(sc.SIGPIPE, sc.SIG_IGN) end - local fd = assert(sc.socket(domain, stype, protocol or 0)) - file.init_nonblocking(fd) - return setmetatable({ fd = fd }, Socket) -end - -function Socket:listen_unix(f) - local sa = sc.getsockname(self.fd) - sa.path = f - assert(sc.bind(self.fd, sa)) - assert(sc.listen(self.fd)) -end - -function Socket:accept() - while true do - local fd, err, errno = sc.accept(self.fd) - if fd then - return file.fdopen(fd) - elseif errno == sc.EAGAIN or errno == sc.EWOULDBLOCK then - file.wait_for_readable(self.fd) - else - error(err) - end - end -end - -function Socket:connect(sa) - local ok, err, errno = sc.connect(self.fd, sa) - if not ok and errno == sc.EINPROGRESS then - -- Bonkers semantics; see connect(2). - file.wait_for_writable(self.fd) - err = assert(sc.getsockopt(self.fd, sc.SOL_SOCKET, sc.SO_ERROR)) - if err == 0 then ok = true end - end - if ok then - local fd = self.fd - self.fd = nil - return file.fdopen(fd) - end - error(err) -end - -function Socket:connect_unix(f) - local sa = sc.getsockname(self.fd) - sa.path = f - return self:connect(sa) -end - -local function listen_unix(f, args) - args = args or {} - local s = socket(sc.AF_UNIX, args.stype or sc.SOCK_STREAM, args.protocol) - s:listen_unix(f) - if args.ephemeral then - local parent_close = s.close - function s:close() - parent_close(s) - sc.unlink(f) - end - end - return s -end - -local function connect_unix(f, stype, protocol) - local s = socket(sc.AF_UNIX, stype or sc.SOCK_STREAM, protocol) - return s:connect_unix(f) -end - -function Socket:close() - if self.fd then sc.close(self.fd) end - self.fd = nil -end - -return { - socket = socket, - listen_unix = listen_unix, - connect_unix = connect_unix -} diff --git a/src/fibers/utils/fixed_buffer.lua b/src/fibers/utils/fixed_buffer.lua deleted file mode 100644 index 211a2d4..0000000 --- a/src/fibers/utils/fixed_buffer.lua +++ /dev/null @@ -1,81 +0,0 @@ -local is_LuaJIT = rawget(_G, "jit") and true or false -local ffi = is_LuaJIT and require 'ffi' or require 'cffi' - -local ljbuf = require("string.buffer") - -local ring_buffer = {} - -function ring_buffer:reset() - self.buf:reset() -end - -function ring_buffer:read_avail() - return #self.buf -end - -function ring_buffer:write_avail() - return self.size - #self.buf -end - -function ring_buffer:is_empty() - return #self.buf == 0 -end - -function ring_buffer:is_full() - return #self.buf >= self.size -end - -function ring_buffer:advance_read(count) - assert(count >= 0 and count <= #self.buf, "advance_read out of range") - self.buf:skip(count) -end - -function ring_buffer:write(bytes, count) - assert(count >= 0, "invalid write count") - assert(count <= self:write_avail(), "write xrun") - self.buf:putcdata(bytes, count) -end - -function ring_buffer:read(bytes, count) - assert(count >= 0 and count <= #self.buf, "read xrun") - local data = self.buf:get(count) - ffi.copy(bytes, data, #data) -end - -function ring_buffer:peek() - local ptr, len = self.buf:ref() - return ptr, math.min(len, self.size) -end - -function ring_buffer:tostring() - return self.buf:tostring() -end - -function ring_buffer:find(pattern) - assert(type(pattern) == "string" and #pattern > 0, "pattern must be non-empty string") - local str = self.buf:tostring() - local i = string.find(str, pattern) - return i and (i - 1) or nil -end - -function ring_buffer:reserve(size) - return self.buf:reserve(size) -end - -function ring_buffer:commit(n) - return self.buf:commit(n) -end - -local function new(size) - assert(type(size) == "number" and size > 0, "new() requires positive size") - local self = { - buf = ljbuf.new(), - size = size - } - - return setmetatable(self, { __index = ring_buffer }) -end - -return { - new = new -} diff --git a/tests/test.lua b/tests/test.lua index 2004f61..ecc832b 100644 --- a/tests/test.lua +++ b/tests/test.lua @@ -4,12 +4,15 @@ package.path = package.path .. ';/usr/lib/lua/?.lua;/usr/lib/lua/?/init.lua' local sep = '-' local modules = { - { 'utils', 'fixed_buffer' }, - { 'stream' }, - { 'stream', 'file' }, - { 'stream', 'mem' }, - { 'stream', 'compat' }, - { 'stream', 'socket' }, + { 'utils', 'bytes' }, + { 'utils', 'bytes_stress' }, + { 'io', 'file' }, + { 'io', 'mem' }, + { 'io', 'stream' }, + { 'io', 'socket' }, + { 'io', 'proc_backend' }, + -- { 'io', 'pollio' }, + { 'pollio' }, { 'timer' }, { 'sched' }, { 'runtime' }, @@ -18,11 +21,9 @@ local modules = { { 'cond' }, { 'sleep' }, { 'epoll' }, - { 'pollio' }, - { 'exec' }, + { 'process' }, { 'waitgroup' }, -- { 'alarm' }, - { 'context' }, { 'scope' }, } diff --git a/tests/test_pollio.lua b/tests/test_pollio.lua deleted file mode 100644 index abba6db..0000000 --- a/tests/test_pollio.lua +++ /dev/null @@ -1,44 +0,0 @@ ---- Tests the File implementation. -print('testing: fibers.fd') - --- look one level up -package.path = "../src/?.lua;" .. package.path - -local pollio = require 'fibers.pollio' -local file_stream = require 'fibers.stream.file' -local runtime = require 'fibers.runtime' - -local equal = require 'fibers.utils.helper'.equal -local log = {} -local function record(x) table.insert(log, x) end - --- local handler = new_poll_io_handler() --- file.set_blocking_handler(handler) --- runtime.current_scheduler:add_task_source(handler) -pollio.install_poll_io_handler() - -runtime.current_scheduler:run() -assert(equal(log, {})) - -local rd, wr = file_stream.pipe() -local message = "hello, world\n" -runtime.spawn_raw(function() - record('rd-a') - local str = rd:read_some_chars() - record('rd-b') - record(str) -end) -runtime.spawn_raw(function() - record('wr-a') - wr:write(message) - record('wr-b') - wr:flush() - record('wr-c') -end) - -runtime.current_scheduler:run() -assert(equal(log, { 'rd-a', 'wr-a', 'wr-b', 'wr-c' })) -runtime.current_scheduler:run() -assert(equal(log, { 'rd-a', 'wr-a', 'wr-b', 'wr-c', 'rd-b', message })) - -print('test: ok') From 83fc38d208dc25d63f7bc15def50918dd50a33ab Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Fri, 28 Nov 2025 09:14:49 +0000 Subject: [PATCH 052/138] bugfix: fibers.run should now raise --- src/fibers.lua | 108 +++++++++++++++++++++++++++---------------------- 1 file changed, 60 insertions(+), 48 deletions(-) diff --git a/src/fibers.lua b/src/fibers.lua index b78c709..c3dd7b2 100644 --- a/src/fibers.lua +++ b/src/fibers.lua @@ -24,58 +24,69 @@ end ---------------------------------------------------------------------- --- Run a main function under the scheduler's root scope. --- --- main_fn is called as main_fn(scope, ...). --- --- Returns: --- status :: "ok" | "failed" | "cancelled" --- err :: primary error / cancellation reason, or nil on "ok". +--- +--- main_fn is called as main_fn(scope, ...). +--- +--- On success: +--- returns ...results... from main_fn directly. +--- +--- On failure or cancellation: +--- raises the primary error / cancellation reason. +--- ---@param main_fn fun(s: Scope, ...): any ---@param ... any ----@return ScopeStatus status ----@return any err ----@return any ... +---@return any ... -- only results from main_fn on success local function run(main_fn, ...) - assert(not Runtime.current_fiber(), - "fibers.run must not be called from inside a fiber") - local root = Scope.root() - local args = pack(...) - - local status, err - local results -- may be nil or a packed table - - root:spawn(function() - -- scope.run creates a child scope of the current scope, - -- runs main_fn(body_scope, ...) in its own fiber, and returns - -- (status, err, ...results_from_body_fn...). - local packed = pack(Scope.run(main_fn, unpack(args, 1, args.n or #args))) - status, err = packed[1], packed[2] - - if packed.n > 2 then - -- Preserve any results from main_fn, including nils. - local out = { n = packed.n - 2 } - local j = 1 - for i = 3, packed.n do - out[j] = packed[i] - j = j + 1 - end - results = out - else - results = nil - end - - -- In all cases, stop the scheduler so runtime.main() returns. - Runtime.stop() - end) - - -- Drive the scheduler until stopped by the main scope. - Runtime.main() - - if results then - return status, err, unpack(results, 1, results.n or #results) - else - return status, err + assert(not Runtime.current_fiber(), + "fibers.run must not be called from inside a fiber") + + local root = Scope.root() + local args = pack(...) + + -- Outcome container populated by the child fibre. + local outcome = { + status = nil, -- "ok" | "failed" | "cancelled" + err = nil, -- primary error / reason + results = nil, -- packed results from main_fn on success + } + + root:spawn(function() + -- Scope.run creates a child scope and runs main_fn(body_scope, ...), + -- returning (status, err, ...results...). + local packed = pack(Scope.run(main_fn, unpack(args, 1, args.n))) + outcome.status = packed[1] + outcome.err = packed[2] + + if packed.n > 2 and outcome.status == "ok" then + local out = { n = packed.n - 2 } + local j = 1 + for i = 3, packed.n do + out[j] = packed[i] + j = j + 1 + end + outcome.results = out end + + -- Stop the scheduler so Runtime.main() returns. + Runtime.stop() + end) + + -- Drive the scheduler until the main scope decides to stop it. + Runtime.main() + + -- Interpret the outcome. + local status, err, results = outcome.status, outcome.err, outcome.results + + if status ~= "ok" then + -- Re-raise the primary error / cancellation reason. + -- This may be any Lua value (string, table, etc.). + error(err or status) + end + + if results then + return unpack(results, 1, results.n) + end + -- No results from main_fn: return nothing. end ---------------------------------------------------------------------- @@ -117,4 +128,5 @@ return { run_scope = Scope.run, scope_op = Scope.with_op, set_unscoped_error_handler = Scope.set_unscoped_error_handler, + current_scope = Scope.current } From cf2e164656349732c8a1552687e2318c589f2d98 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Fri, 28 Nov 2025 09:23:35 +0000 Subject: [PATCH 053/138] removes context abstraction --- src/fibers/context.lua | 191 ----------------------------------------- 1 file changed, 191 deletions(-) delete mode 100644 src/fibers/context.lua diff --git a/src/fibers/context.lua b/src/fibers/context.lua deleted file mode 100644 index b8fb040..0000000 --- a/src/fibers/context.lua +++ /dev/null @@ -1,191 +0,0 @@ ---- A Lua context library for managing hierarchies of fibers with ---- cancellation, deadlines, and values. ---- This version more closely follows Go's context pattern: ---- - Only cancellation contexts (from with_cancel, with_deadline, ---- with_timeout) trigger cancellation. ---- - Value contexts merely hold extra keys and defer cancellation to their parent. ---- Each context is one of: ---- - base_context: The root logic for value lookup and for deferring .err() ---- and .done_op() to a parent. ---- - cancel_context: Extends base_context with a local cancellation cause and ---- a condition variable for signalling cancellation. ---- - value_context: Extends base_context with a local key/value, with no local ---- cancellation. --- @module context - -local runtime = require "fibers.runtime" -local op = require "fibers.op" -local cond = require "fibers.cond" - -local perform = require 'fibers.performer'.perform - --- ------------------------------------------------------------------------ --- base_context: --- Minimal context that defers cancellation, deadlines and values to its parent. --- background() returns a base_context with no parent. --- ------------------------------------------------------------------------ -local base_context = {} -base_context.__index = base_context - ---- Create a new base_context with the specified parent. ---- Typically used internally by derived contexts (cancel_context, ---- value_context). --- @param parent The parent context. --- @return A new base_context. -function base_context:new(parent) - local ctx = setmetatable({ parent = parent, children = {} }, self) - if parent then table.insert(parent.children, ctx) end - return ctx -end - ---- Returns an op that completes when this context is done. ---- For a background context (no parent), the op never completes. -function base_context:done_op() - if self.parent then - return self.parent:done_op() - else - -- Background context: never cancelled, so done_op never completes. - return op.new_primitive(nil, function() return false end, function() end) - end -end - ---- Returns any cancellation cause if known. ---- If none exists, defers to the parent. --- @return The cancellation cause or nil. -function base_context:err() - return self.parent and self.parent:err() or nil -end - ---- Lookup a value in this context. ---- By default, the lookup defers to the parent. --- @param key The key to look up. --- @return The associated value, or nil. -function base_context:value(key) - return self.parent and self.parent:value(key) or nil -end - --- ------------------------------------------------------------------------ --- cancel_context: --- A context that can be cancelled locally or via its parent. --- It uses a condition variable to signal cancellation. --- ------------------------------------------------------------------------ -local cancel_context = {} -cancel_context.__index = cancel_context -setmetatable(cancel_context, { __index = base_context }) - ---- Create a new cancel_context with the specified parent. ---- This arranges for the child's cancellation if the parent is cancelled. --- @param parent The parent context. --- @return A new cancel_context. -function cancel_context:new(parent) - local base = base_context.new(self, parent) - base.cond = cond.new() -- condition variable for signalling cancellation - base.cause = nil -- local cancellation cause - return base -end - ---- Cancel this context with an optional cause. ---- If no cause is provided, "canceled" is used. --- @param cause The cancellation reason. -function cancel_context:cancel(cause) - if self.cause then return end - self.cause = cause or "canceled" - self.cond:signal() -- signal cancellation to waiters - for _, child in ipairs(self.children) do - if child.cancel then child:cancel(cause) end - end -end - ---- Returns an op that completes when this context is cancelled. ---- This op is a choice between the parent's done op and the local cond wait op. -function cancel_context:done_op() - local local_op = self.cond:wait_op() - if self.parent then - return op.choice(self.parent:done_op(), local_op) - else - return local_op - end -end - -function cancel_context:done() - return perform(self:done_op()) -end - ---- Overridden err() that checks for a local cancellation cause. -function cancel_context:err() - return self.cause or (self.parent and self.parent:err() or nil) -end - --- ------------------------------------------------------------------------ --- value_context: --- A simple context that stores one additional key/value pair. --- It defers all cancellation to the parent. --- ------------------------------------------------------------------------ -local value_context = {} -value_context.__index = value_context -setmetatable(value_context, { __index = base_context }) - ---- Create a new value_context with the specified parent, key and value. -function value_context:new(parent, key, val) - local ctx = base_context.new(self, parent) - ctx.key, ctx.val = key, val - return ctx -end - ---- Lookup a value in this context. ---- If the key matches this context's key, its value is returned; ---- otherwise, the lookup defers to the parent. --- @param k The key to look up. --- @return The associated value, or nil. -function value_context:value(k) - return k == self.key and self.val or base_context.value(self, k) -end - --- ------------------------------------------------------------------------ --- Top-level functions for external use. --- ------------------------------------------------------------------------ - ---- The root context that is never cancelled and holds no values. --- @return A new background context. -local function background() - return setmetatable({ parent = nil, children = {} }, base_context) -end - ---- Returns a child cancel_context and a cancellation function. --- @param The parent context. --- @return The new cancel_context and a function to cancel it. -local function with_cancel(parent) - local ctx = cancel_context:new(parent) - return ctx, function(cause) ctx:cancel(cause) end -end - ---- Returns a cancel_context that is automatically cancelled when the specified deadline is reached. --- @param The parent context. --- @param The deadline at which the context will be cancelled. --- @return The new with_deadline_context and a function to cancel it. -local function with_deadline(parent, deadline) - local ctx, cancel_fn = with_cancel(parent) - runtime.current_scheduler:schedule_at_time(deadline, { run = function() ctx:cancel("deadline_exceeded") end }) - return ctx, cancel_fn -end - ---- Returns a cancel_context that is automatically cancelled after timeout seconds. --- @param The parent context. --- @param The timeout at which the context will be cancelled. --- @return The new with_timeout_context and a function to cancel it. -local function with_timeout(parent, timeout) - return with_deadline(parent, runtime.now() + timeout) -end - ---- Returns a value_context that stores a key/value pair. -local function with_value(parent, key, val) - return value_context:new(parent, key, val) -end - -return { - background = background, - with_cancel = with_cancel, - with_deadline = with_deadline, - with_timeout = with_timeout, - with_value = with_value -} From a54512d19b6b5e89be4699a95046ccaa62cf01ad Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Fri, 28 Nov 2025 10:48:04 +0000 Subject: [PATCH 054/138] corrects test file --- tests/test.lua | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test.lua b/tests/test.lua index ecc832b..5413f7a 100644 --- a/tests/test.lua +++ b/tests/test.lua @@ -11,8 +11,7 @@ local modules = { { 'io', 'stream' }, { 'io', 'socket' }, { 'io', 'proc_backend' }, - -- { 'io', 'pollio' }, - { 'pollio' }, + { 'io', 'process' }, { 'timer' }, { 'sched' }, { 'runtime' }, @@ -21,7 +20,6 @@ local modules = { { 'cond' }, { 'sleep' }, { 'epoll' }, - { 'process' }, { 'waitgroup' }, -- { 'alarm' }, { 'scope' }, From c291e68064108cd00e4f18430331acebf94655dd Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Fri, 28 Nov 2025 11:37:57 +0000 Subject: [PATCH 055/138] timer: fix linter issues --- src/fibers/timer.lua | 115 +++++++++++++++++++++++-------------------- tests/test_timer.lua | 6 ++- 2 files changed, 66 insertions(+), 55 deletions(-) diff --git a/src/fibers/timer.lua b/src/fibers/timer.lua index 95e4786..9092da7 100644 --- a/src/fibers/timer.lua +++ b/src/fibers/timer.lua @@ -7,6 +7,8 @@ ---@field time number # absolute due time (monotonic seconds) ---@field obj any # scheduled payload +local floor, huge = math.floor, math.huge + --- Simple min-heap keyed by node.time. ---@class Heap ---@field heap TimerNode[] @@ -21,63 +23,75 @@ end ---@param node TimerNode function Heap:push(node) - self.size = self.size + 1 - self.heap[self.size] = node - self:heapify_up(self.size) + local size = self.size + 1 + self.size = size + self.heap[size] = node + self:heapify_up(size) end ---@return TimerNode|nil function Heap:pop() - if self.size == 0 then + local size = self.size + if size == 0 then return nil end - local root = self.heap[1] - if self.size == 1 then - self.heap[1] = nil - self.size = 0 + local heap = self.heap + local root = heap[1] + + if size == 1 then + heap[1] = nil + self.size = 0 return root end - self.heap[1] = self.heap[self.size] - self.heap[self.size] = nil - self.size = self.size - 1 + heap[1] = heap[size] + heap[size] = nil + self.size = size - 1 self:heapify_down(1) + return root end ---@param idx integer function Heap:heapify_up(idx) - if idx <= 1 then return end - local parent = math.floor(idx / 2) - if self.heap[parent].time > self.heap[idx].time then - self.heap[parent], self.heap[idx] = self.heap[idx], self.heap[parent] - self:heapify_up(parent) + local heap = self.heap + while idx > 1 do + local parent = floor(idx / 2) + if heap[parent].time <= heap[idx].time then + break + end + heap[parent], heap[idx] = heap[idx], heap[parent] + idx = parent end end ---@param idx integer function Heap:heapify_down(idx) - local smallest = idx - local left = 2 * idx - local right = 2 * idx + 1 - - if left <= self.size and self.heap[left].time < self.heap[smallest].time then - smallest = left - end - if right <= self.size and self.heap[right].time < self.heap[smallest].time then - smallest = right - end - if smallest ~= idx then - self.heap[idx], self.heap[smallest] = self.heap[smallest], self.heap[idx] - self:heapify_down(smallest) + local heap = self.heap + local size = self.size + + while true do + local left = 2 * idx + local right = left + 1 + local smallest = idx + + if left <= size and heap[left].time < heap[smallest].time then + smallest = left + end + if right <= size and heap[right].time < heap[smallest].time then + smallest = right + end + + if smallest == idx then + break + end + + heap[idx], heap[smallest] = heap[smallest], heap[idx] + idx = smallest end end ----@class Scheduler ----@field schedule fun(self: Scheduler, obj: any) # called when a timer fires - ---- Monotonic timer used by the scheduler. ---@class Timer ---@field now number # current timer time (monotonic seconds) ---@field heap Heap @@ -85,57 +99,52 @@ local Timer = {} Timer.__index = Timer --- Create a new timer instance. ----@param now? number # initial monotonic time. +---@param now number # initial monotonic time. ---@return Timer local function new(now) - assert(now) return setmetatable({ now = now, heap = new_heap() }, Timer) end --- Schedule an object at absolute time t. ----@param t number # absolute due time (same clock as sc.monotime) +---@param t number # absolute due time ---@param obj any # payload to pass to the scheduler function Timer:add_absolute(t, obj) - self.heap:push({ time = t, obj = obj }) + self.heap:push { time = t, obj = obj } end --- Schedule an object after a delay from the current timer time. ---@param dt number # delay in seconds from self.now ---@param obj any # payload to pass to the scheduler function Timer:add_delta(dt, obj) - return self:add_absolute(self.now + dt, obj) + self:add_absolute(self.now + dt, obj) end --- Get the time of the next scheduled entry, or math.huge if none exist. ---- This is the only method used by the scheduler to determine wake-up time. ---@return number function Timer:next_entry_time() - if self.heap.size == 0 then - return math.huge - end - return self.heap.heap[1].time + local heap = self.heap + return heap.size > 0 and heap.heap[1].time or huge end --- Pop the next scheduled entry without dispatching it. ---- Returns the earliest TimerNode or nil if the timer is empty. ---@return TimerNode|nil function Timer:pop() return self.heap:pop() end --- Advance the timer to time t and dispatch all due entries. ---- For each entry with time <= t, sched:schedule(node.obj) is invoked. ----@param t number # new monotonic time ----@param sched Scheduler # scheduler that receives due objects +---@param t number # new monotonic time +---@param sched { schedule: fun(self:any, obj:any) } function Timer:advance(t, sched) - while self.heap.size > 0 and t >= self.heap.heap[1].time do - local node = assert(self.heap:pop()) - self.now = node.time + local heap = self.heap + + while heap.size > 0 and t >= heap.heap[1].time do + local node = assert(heap:pop()) -- non-nil since size>0 + self.now = node.time sched:schedule(node.obj) end + self.now = t end -return { - new = new, -} +return { new = new } diff --git a/tests/test_timer.lua b/tests/test_timer.lua index 3399720..e013a60 100644 --- a/tests/test_timer.lua +++ b/tests/test_timer.lua @@ -7,11 +7,13 @@ package.path = "../src/?.lua;" .. package.path local timer = require 'fibers.timer' local sc = require 'fibers.utils.syscall' +local noop_sched = { schedule = function() end } + local function test_advance_time() local wheel = timer.new(10) local hour = 60 * 60 local start_time = sc.monotime() - wheel:advance(hour) + wheel:advance(hour, noop_sched) local end_time = sc.monotime() print("Time to advance wheel by an hour: "..(end_time - start_time).." seconds") end @@ -79,7 +81,7 @@ local function test_advance_now_update() local wheel = timer.new(sc.monotime()) local advance_time = 100 -- Advance by 100 seconds local start_time = wheel.now - wheel:advance(start_time + advance_time) + wheel:advance(start_time + advance_time, noop_sched) local expected_time = start_time + advance_time assert(wheel.now == expected_time, "Advance method failed to update 'now' correctly") print("Test for 'now' update in advance method passed") From 519c9da04aad51eec826ecd34d6974edecc924f0 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Fri, 28 Nov 2025 21:56:20 +0000 Subject: [PATCH 056/138] make a fiber a subtype of task --- src/fibers/runtime.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fibers/runtime.lua b/src/fibers/runtime.lua index 40b2f48..57f71d9 100644 --- a/src/fibers/runtime.lua +++ b/src/fibers/runtime.lua @@ -43,7 +43,7 @@ function WaiterTask:run() end --- Cooperative fiber object managed by the runtime. ----@class Fiber +---@class Fiber : Task ---@field coroutine thread ---@field alive boolean ---@field sockets table From 55b52d6c1abb458390ba945a320176e7c4512904 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Fri, 28 Nov 2025 21:56:51 +0000 Subject: [PATCH 057/138] make a suspension a subtype of task --- src/fibers/op.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fibers/op.lua b/src/fibers/op.lua index 9bdb60f..256d34f 100644 --- a/src/fibers/op.lua +++ b/src/fibers/op.lua @@ -22,7 +22,7 @@ local function id_wrap(...) return ... end ---------------------------------------------------------------------- --- A suspension of a fiber waiting on an op. ----@class Suspension +---@class Suspension : Task ---@field state "waiting"|"synchronized" # whether the suspension is still pending ---@field sched Scheduler # scheduler used to reschedule the fiber ---@field fiber Fiber # fiber object to resume From 5e95cf72a5b69561c82125abd43fa15f5bf5d917 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Fri, 28 Nov 2025 22:26:37 +0000 Subject: [PATCH 058/138] use safe.pcall --- src/fibers/io/process.lua | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/fibers/io/process.lua b/src/fibers/io/process.lua index 0b0c522..14a8e79 100644 --- a/src/fibers/io/process.lua +++ b/src/fibers/io/process.lua @@ -1,4 +1,4 @@ --- fibers/process.lua +-- fibers/io/process.lua -- -- Process abstraction built on top of proc_backend, streams and CML Ops. -- @@ -13,6 +13,8 @@ local proc_mod = require 'fibers.io.proc_backend' local poller = require 'fibers.io.poller' local sleep = require 'fibers.sleep' +local safe = require 'coxpcall' + ---@class SpawnOptions ---@field argv string[] # required, argv[1] is executable ---@field cwd string|nil # working directory for child @@ -351,10 +353,10 @@ function Process:shutdown(grace) end) ) - local ok, is_exit = pcall(op.perform_raw, ev) + local ok, is_exit = safe.pcall(op.perform_raw, ev) if not ok or not is_exit then self:kill() - pcall(function() + safe.pcall(function() self:wait_raw() end) end @@ -384,7 +386,7 @@ local function with_process(spec, build_op) if aborted then proc:shutdown(1.0) else - pcall(function() + safe.pcall(function() proc:wait_raw() end) proc:close() From a72d762aff8d5ba4134a9b0e739535e592dfd42a Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Fri, 28 Nov 2025 22:31:46 +0000 Subject: [PATCH 059/138] sched concision --- src/fibers/sched.lua | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/fibers/sched.lua b/src/fibers/sched.lua index 7ad2070..7877d58 100644 --- a/src/fibers/sched.lua +++ b/src/fibers/sched.lua @@ -157,9 +157,8 @@ function Scheduler:wait_for_events() local next_time = self:next_wake_time() local timeout = math.min(self.maxsleep, next_time - now) - if timeout < 0 then - timeout = 0 - end + + if timeout < 0 then timeout = 0 end if self.event_waiter then self.event_waiter:wait_for_events(self, now, timeout) @@ -197,9 +196,7 @@ function Scheduler:shutdown() end end - if #self.next == 0 then - return true - end + if #self.next == 0 then return true end self:run() end From c974165fffbd157ebd0315d01f024c5fb9be166a Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sun, 30 Nov 2025 15:38:47 +0000 Subject: [PATCH 060/138] reworks bytes and adds ffi_compat layer --- src/fibers/utils/bytes.lua | 832 ++---------------------------- src/fibers/utils/bytes/ffi.lua | 223 ++++++++ src/fibers/utils/bytes/lua.lua | 251 +++++++++ src/fibers/utils/ffi_compat.lua | 59 +++ tests/test_utils-bytes.lua | 86 ++- tests/test_utils-bytes_stress.lua | 38 +- 6 files changed, 651 insertions(+), 838 deletions(-) create mode 100644 src/fibers/utils/bytes/ffi.lua create mode 100644 src/fibers/utils/bytes/lua.lua create mode 100644 src/fibers/utils/ffi_compat.lua diff --git a/src/fibers/utils/bytes.lua b/src/fibers/utils/bytes.lua index 11c7a41..ee5ee60 100644 --- a/src/fibers/utils/bytes.lua +++ b/src/fibers/utils/bytes.lua @@ -4,818 +4,48 @@ -- * bytes.RingBuf : ring buffer for bytes -- * bytes.LinearBuf : growable linear buffer -- --- Two implementations exist: --- - FFI-backed (LuaJIT or lua-cffi) under bytes.ffi --- - Pure Lua rope/string-based under bytes.lua +-- Backend selection: +-- - FFI-backed (LuaJIT or cffi) : fibers.utils.bytes.ffi +-- - Pure Lua rope/string-based : fibers.utils.bytes.lua -- --- The default backend is chosen at load time: --- _G.FIBERS_BYTES_BACKEND = "ffi" | "lua" | "auto" (default: "auto") --- "auto" => use FFI if available, else pure Lua. ----@module 'fibers.utils.bytes' - -local is_LuaJIT = rawget(_G, "jit") and true or false - -local bit = rawget(_G, "bit") or require 'bit32' +-- This shim picks the first supported backend. -local ok_ffi, ffi -if is_LuaJIT then - ok_ffi, ffi = pcall(require, 'ffi') -else - ok_ffi, ffi = pcall(require, 'cffi') -end - -local has_ffi = ok_ffi and (ffi ~= nil) +---@module 'fibers.utils.bytes' ---------------------------------------------------------------------- --- High-level type annotations +-- Forward type declarations for tooling ---------------------------------------------------------------------- ---- Fixed-capacity byte ring buffer. ---- Implementations: ---- * FFI: C struct with power-of-two size and index masking. ---- * Lua: rope of strings with explicit size limit. ---@class RingBuf ----@field size integer # configured capacity ----@field len integer # unread byte count (Lua impl; FFI uses indices) ----@field buf any # FFI backing storage (uint8_t[]); Lua impl ignores ----@field read_avail fun(self: RingBuf): integer ----@field write_avail fun(self: RingBuf): integer ----@field is_empty fun(self: RingBuf): boolean ----@field is_full fun(self: RingBuf): boolean ----@field reset fun(self: RingBuf) ----@field put fun(self: RingBuf, s: string) # enqueue bytes ----@field take fun(self: RingBuf, n: integer): string # dequeue up to n bytes ----@field tostring fun(self: RingBuf): string # non-destructive snapshot ----@field find fun(self: RingBuf, pattern: string): integer|nil ----@field init fun(self: RingBuf, size: integer): RingBuf +---@field size integer # total capacity (bytes) +---@field read_avail fun(self: RingBuf): integer # available bytes to read +---@field write_avail fun(self: RingBuf): integer # available space to write +---@field take fun(self: RingBuf, n: integer): string # remove n bytes and return them +---@field put fun(self: RingBuf, s: string) # append bytes +---@field reset fun(self: RingBuf) # clear buffer +---@field find fun(self: RingBuf, needle: string): integer|nil # find substring in readable region ---- Growable linear byte buffer. ---@class LinearBuf ----@field reset fun(self: LinearBuf) ----@field append fun(self: LinearBuf, s: string) ----@field tostring fun(self: LinearBuf): string - ---- RingBuf constructor module. ----@class RingBufModule ----@field new fun(size: integer): RingBuf - ---- LinearBuf constructor module. ----@class LinearBufModule ----@field new fun(size?: integer): LinearBuf - ----------------------------------------------------------------------- --- FFI-backed implementation builder ----------------------------------------------------------------------- - ---- Build the FFI-backed implementation tables, if FFI is available. ----@return { RingBuf: RingBufModule, LinearBuf: LinearBufModule }|nil -local function build_ffi_impl() - if not has_ffi then - return nil - end - - local band = bit.band - - -- Ring buffer struct: indices wrap modulo 2^32; size must be power of two. - ffi.cdef[[ - typedef struct { - uint32_t read_idx; - uint32_t write_idx; - uint32_t size; - uint8_t buf[?]; - } fibers_ringbuf_t; - ]] - - ---@class FfiRingBuf : RingBuf - local ring_mt = {} - ring_mt.__index = ring_mt - - -- Linear buffer is implemented as a Lua table with a growable - -- uint8_t[] backing store; no C struct is required. - ---@class FfiLinearBuf : LinearBuf - local lin_mt = {} - lin_mt.__index = lin_mt - - local ring_ct = ffi.metatype("fibers_ringbuf_t", ring_mt) - - --- Normalise to uint32 range for index arithmetic. - ---@param n integer - ---@return integer - local function to_uint32(n) - return n % 2^32 - end - - -------------------------------------------------------------------- - -- RingBuf (FFI) - -------------------------------------------------------------------- - - --- Initialise a ring buffer with a power-of-two size. - ---@param size integer - ---@return FfiRingBuf - function ring_mt:init(size) - assert(type(size) == "number" and size > 0, "RingBuf: positive size required") - assert(band(size, size - 1) == 0, "RingBuf: size must be power of two") - self.size = size - self.read_idx = 0 - self.write_idx = 0 - return self - end - - --- Reset indices; content is treated as discarded. - function ring_mt:reset() - self.read_idx = 0 - self.write_idx = 0 - end - - --- Number of bytes available to read. - ---@return integer - function ring_mt:read_avail() - return to_uint32(self.write_idx - self.read_idx) - end - - --- Remaining capacity for writes. - ---@return integer - function ring_mt:write_avail() - return self.size - self:read_avail() - end - - function ring_mt:is_empty() - return self.read_idx == self.write_idx - end - - function ring_mt:is_full() - return self:read_avail() == self.size - end - - --- Internal: absolute read position modulo size. - ---@return integer - function ring_mt:_read_pos() - return band(self.read_idx, self.size - 1) - end - - --- Internal: absolute write position modulo size. - ---@return integer - function ring_mt:_write_pos() - return band(self.write_idx, self.size - 1) - end - - --- Advance read index by count bytes. - ---@param count integer - function ring_mt:advance_read(count) - assert(count >= 0 and count <= self:read_avail(), "RingBuf:advance_read out of range") - self.read_idx = self.read_idx + ffi.cast("uint32_t", count) - end - - --- Advance write index by count bytes. - ---@param count integer - function ring_mt:advance_write(count) - assert(count >= 0 and count <= self:write_avail(), "RingBuf:advance_write out of range") - self.write_idx = self.write_idx + ffi.cast("uint32_t", count) - end - - --- Low-level pointer-based write into the ring. - ---@param src ffi.ct* -- pointer to bytes - ---@param count integer - function ring_mt:write(src, count) - assert(count >= 0 and count <= self:write_avail(), "RingBuf: write xrun") - if count == 0 then return end - local pos = self:_write_pos() - local size = self.size - local first = math.min(size - pos, count) - if first > 0 then - ffi.copy(self.buf + pos, src, first) - end - local rest = count - first - if rest > 0 then - ffi.copy(self.buf, src + first, rest) - end - self:advance_write(count) - end - - --- Low-level pointer-based read from the ring. - ---@param dst ffi.ct* -- pointer to destination - ---@param count integer - function ring_mt:read(dst, count) - assert(count >= 0 and count <= self:read_avail(), "RingBuf: read xrun") - if count == 0 then return end - local pos = self:_read_pos() - local size = self.size - local first = math.min(size - pos, count) - if first > 0 then - ffi.copy(dst, self.buf + pos, first) - end - local rest = count - first - if rest > 0 then - ffi.copy(dst + first, self.buf, rest) - end - self:advance_read(count) - end - - --- Peek at contiguous readable bytes without advancing. - ---@return ffi.ct*|nil ptr - ---@return integer len - function ring_mt:peek() - local pos = self:_read_pos() - local avail = self:read_avail() - local first = math.min(avail, self.size - pos) - if first <= 0 then - return nil, 0 - end - return self.buf + pos, first - end - - --- Reserve contiguous write space and return pointer and length. - --- Caller must follow with commit(count) after writing. - ---@param request? integer - ---@return ffi.ct*|nil ptr - ---@return integer len - function ring_mt:reserve(request) - local avail = self:write_avail() - if avail <= 0 then - return nil, 0 - end - local pos = self:_write_pos() - local first = math.min(avail, self.size - pos) - local n = first - if request and request < n then - n = request - end - if n <= 0 then - return nil, 0 - end - return self.buf + pos, n - end - - --- Commit count bytes previously reserved. - ---@param count integer - function ring_mt:commit(count) - self:advance_write(count) - end - - -- String-oriented helpers to match the pure Lua interface. - - --- Enqueue a string into the ring. - ---@param str string - function ring_mt:put(str) - assert(type(str) == "string", "RingBuf:put expects a string") - local n = #str - if n == 0 then return end - assert(n <= self:write_avail(), "RingBuf: write would exceed capacity") - local tmp = ffi.new("uint8_t[?]", n) - ffi.copy(tmp, str, n) - self:write(tmp, n) - end - - --- Dequeue up to n bytes and return as a string. - ---@param n integer - ---@return string - function ring_mt:take(n) - assert(type(n) == "number" and n >= 0, "RingBuf:take expects non-negative count") - local avail = self:read_avail() - if avail == 0 or n == 0 then - return "" - end - if n > avail then - n = avail - end - local tmp = ffi.new("uint8_t[?]", n) - self:read(tmp, n) - return ffi.string(tmp, n) - end - - --- Non-destructive snapshot of all readable data as a string. - --- Internally reads then rewinds read_idx. - ---@return string - function ring_mt:tostring() - local n = self:read_avail() - if n == 0 then - return "" - end - local tmp = ffi.new("uint8_t[?]", n) - self:read(tmp, n) - -- Restore read_idx so this is a non-destructive view. - self.read_idx = self.read_idx - ffi.cast("uint32_t", n) - return ffi.string(tmp, n) - end - - --- Find a literal substring in the readable region. - --- Returns zero-based offset or nil. - ---@param pattern string - ---@return integer|nil - function ring_mt:find(pattern) - assert(type(pattern) == "string" and #pattern > 0, "RingBuf:find expects non-empty string") - local s = self:tostring() - local i = s:find(pattern, 1, true) - return i and (i - 1) or nil - end - - -------------------------------------------------------------------- - -- LinearBuf (FFI, growable) - -- - -- Growable linear buffer backed by a uint8_t[] array. - -- cap is treated as an initial capacity hint; the buffer grows - -- geometrically when needed. - -------------------------------------------------------------------- - - --- Create a new FFI-backed LinearBuf. - ---@param cap? integer - ---@return FfiLinearBuf - local function LinearBuf_new(cap) - cap = cap or 4096 - assert(cap > 0, "LinearBuf.new: positive initial capacity required") - local buf = ffi.new("uint8_t[?]", cap) - return setmetatable({ - buf = buf, - len = 0, - cap = cap, - }, lin_mt) - end - - --- Reset to empty without releasing capacity. - function lin_mt:reset() - self.len = 0 - end - - --- Ensure space for at least extra bytes beyond current len. - ---@param extra integer - function lin_mt:ensure(extra) - assert(extra >= 0, "LinearBuf:ensure expects non-negative extra") - local needed = self.len + extra - if needed <= self.cap then - return - end - - local new_cap = self.cap - if new_cap <= 0 then - new_cap = 1 - end - while new_cap < needed do - new_cap = new_cap * 2 - end - - local new_buf = ffi.new("uint8_t[?]", new_cap) - if self.len > 0 then - ffi.copy(new_buf, self.buf, self.len) - end - - self.buf = new_buf - self.cap = new_cap - end - - --- Reserve raw space for n bytes and return a pointer. - --- Caller must follow with commit(n) after writing. - ---@param n integer - ---@return ffi.ct* ptr - function lin_mt:reserve(n) - assert(type(n) == "number" and n >= 0, "LinearBuf:reserve expects non-negative count") - if n == 0 then - return self.buf + self.len - end - self:ensure(n) - return self.buf + self.len - end - - --- Commit n bytes written after reserve(). - ---@param n integer - function lin_mt:commit(n) - assert(type(n) == "number" and n >= 0, "LinearBuf:commit expects non-negative count") - assert(self.len + n <= self.cap, "LinearBuf:commit overflow") - self.len = self.len + n - end - - --- Convert buffer contents to a string. - ---@return string - function lin_mt:tostring() - if self.len == 0 then - return "" - end - return ffi.string(self.buf, self.len) - end - - --- String-level append matching the pure Lua interface. - ---@param str string - function lin_mt:append(str) - assert(type(str) == "string", "LinearBuf:append expects a string") - local n = #str - if n == 0 then return end - self:ensure(n) - ffi.copy(self.buf + self.len, str, n) - self.len = self.len + n - end - - -------------------------------------------------------------------- - -- Public constructors (FFI) - -------------------------------------------------------------------- - - ---@class FfiImpl - ---@field RingBuf RingBufModule - ---@field LinearBuf LinearBufModule - local ffi_impl = {} - - --- Create a new FFI-backed RingBuf. - ---@param size integer - ---@return RingBuf - function ffi_impl.RingBuf_new(size) - local self = ring_ct(size) - return ring_mt.init(self, size) - end - - ffi_impl.RingBuf = { new = ffi_impl.RingBuf_new } - - ffi_impl.LinearBuf_new = LinearBuf_new - ffi_impl.LinearBuf = { new = LinearBuf_new } - - return ffi_impl -end - ----------------------------------------------------------------------- --- Pure Lua implementation builder ----------------------------------------------------------------------- - ---- Build the pure Lua implementation tables (no FFI). ----@return { RingBuf: RingBufModule, LinearBuf: LinearBufModule } -local function build_lua_impl() - -------------------------------------------------------------------- - -- RingBuf (pure Lua, rope-style: table of strings) - -------------------------------------------------------------------- - - ---@class LuaRingBuf : RingBuf - local RingBuf_mt = {} - RingBuf_mt.__index = RingBuf_mt - - --- Create a new rope-based RingBuf with fixed capacity. - ---@param size integer - ---@return LuaRingBuf - local function RingBuf_new(size) - assert(type(size) == "number" and size > 0, "RingBuf.new: positive size required") - return setmetatable({ - chunks = {}, -- array of strings - head_idx = 1, -- index of first chunk with unread data - head_off = 0, -- bytes already consumed from chunks[head_idx] - len = 0, -- total unread bytes - size = size, - }, RingBuf_mt) - end - - function RingBuf_mt:reset() - self.chunks = {} - self.head_idx = 1 - self.head_off = 0 - self.len = 0 - end - - function RingBuf_mt:read_avail() - return self.len - end - - function RingBuf_mt:write_avail() - return self.size - self.len - end - - function RingBuf_mt:is_empty() - return self.len == 0 - end - - function RingBuf_mt:is_full() - return self.len >= self.size - end - - --- Compact the chunks array when the head index has advanced far. - --- This keeps table size bounded over long runs. - ---@param self LuaRingBuf - local function compact(self) - local hi = self.head_idx - if hi <= 8 and hi <= (#self.chunks / 2) then - return - end - for i = 1, hi - 1 do - self.chunks[i] = nil - end - local k = 1 - for j = hi, #self.chunks do - self.chunks[k] = self.chunks[j] - if k ~= j then self.chunks[j] = nil end - k = k + 1 - end - self.head_idx = 1 - end - - --- Advance read position by n bytes. - ---@param n integer - function RingBuf_mt:advance_read(n) - assert(n >= 0 and n <= self.len, "RingBuf:advance_read out of range") - if n == 0 then return end - - self.len = self.len - n - - local i = self.head_idx - local off = self.head_off - - while n > 0 and i <= #self.chunks do - local chunk = self.chunks[i] - local rem = #chunk - off - if n < rem then - off = off + n - n = 0 - else - n = n - rem - i = i + 1 - off = 0 - end - end - - self.head_idx = i - self.head_off = off - compact(self) - end - - --- Low-level write used internally (string-based). - ---@param src string - ---@param count? integer - function RingBuf_mt:write(src, count) - assert(type(src) == "string", "RingBuf:write expects string in pure Lua mode") - local n = count or #src - assert(n <= #src, "RingBuf:write count > #src") - assert(n <= self:write_avail(), "RingBuf: write xrun") - - if n == 0 then return end - local s = src - if n < #s then - s = s:sub(1, n) - end - self.len = self.len + n - table.insert(self.chunks, s) - end - - --- Low-level read used internally; returns a string of count bytes. - ---@param _ any - ---@param count integer - ---@return string - function RingBuf_mt:read(_, count) - assert(count >= 0 and count <= self.len, "RingBuf: read xrun") - if count == 0 then return "" end - - local out = {} - local need = count - local i = self.head_idx - local off = self.head_off - local n = #self.chunks - - while need > 0 and i <= n do - local chunk = self.chunks[i] - local rem = #chunk - off - local take = math.min(need, rem) - out[#out + 1] = chunk:sub(off + 1, off + take) - need = need - take - if take == rem then - i = i + 1 - off = 0 - else - off = off + take - end - end - - local s = table.concat(out) - self.head_idx = i - self.head_off = off - self.len = self.len - count - compact(self) - return s - end - - --- Peek at next chunk as a single string without advancing. - ---@return string|nil chunk - ---@return integer len - function RingBuf_mt:peek() - if self.len == 0 then - return nil, 0 - end - - local i = self.head_idx - local off = self.head_off - local chunk = self.chunks[i] - local rem = #chunk - off - - if rem <= 0 then - return nil, 0 - end - local s = chunk:sub(off + 1) - return s, #s - end - - --- Reserve space; not supported in Lua backend. - ---@param _ any - ---@return nil, integer - function RingBuf_mt:reserve(_) - return nil, 0 - end - - function RingBuf_mt:commit(_) - end - - -- String-oriented helpers matching the FFI interface. - - --- Enqueue a string into the ring. - ---@param str string - function RingBuf_mt:put(str) - assert(type(str) == "string", "RingBuf:put expects a string") - local n = #str - if n == 0 then return end - assert(n <= self:write_avail(), "RingBuf: write would exceed capacity") - self:write(str, n) - end - - --- Dequeue up to n bytes and return as a string. - ---@param n integer - ---@return string - function RingBuf_mt:take(n) - assert(type(n) == "number" and n >= 0, "RingBuf:take expects non-negative count") - if self.len == 0 or n == 0 then - return "" - end - if n > self.len then - n = self.len - end - return self:read(nil, n) - end - - --- Non-destructive snapshot of all data. - ---@return string - function RingBuf_mt:tostring() - if self.len == 0 then - return "" - end - local out = {} - local i = self.head_idx - local off = self.head_off - local n = #self.chunks - - if i <= n then - local first = self.chunks[i] - if off > 0 then - first = first:sub(off + 1) - end - out[#out + 1] = first - for j = i + 1, n do - out[#out + 1] = self.chunks[j] - end - end - - return table.concat(out) - end - - --- Find a literal substring in the readable region. - --- Returns zero-based offset or nil. - ---@param pattern string - ---@return integer|nil - function RingBuf_mt:find(pattern) - assert(type(pattern) == "string" and #pattern > 0, "RingBuf:find expects non-empty string") - local s = self:tostring() - local i = s:find(pattern, 1, true) - return i and (i - 1) or nil - end - - -------------------------------------------------------------------- - -- LinearBuf (pure Lua, rope-style) - -------------------------------------------------------------------- - - ---@class LuaLinearBuf : LinearBuf - local LinearBuf_mt = {} - LinearBuf_mt.__index = LinearBuf_mt - - --- Create a new rope-based LinearBuf. - ---@param _ integer - ---@return LuaLinearBuf - local function LinearBuf_new(_) - return setmetatable({ - chunks = {}, - head_idx = 1, - head_off = 0, - len = 0, - }, LinearBuf_mt) - end - - function LinearBuf_mt:reset() - self.chunks = {} - self.head_idx = 1 - self.head_off = 0 - self.len = 0 - end - - --- Append a string to the linear buffer. - ---@param s string - function LinearBuf_mt:append(s) - if not s or #s == 0 then return end - self.len = self.len + #s - table.insert(self.chunks, s) - end - - --- Convert contents to a string. - ---@return string - function LinearBuf_mt:tostring() - if self.len == 0 then - return "" - end - local out = {} - local i = self.head_idx - local off = self.head_off - local n = #self.chunks - - if i <= n then - local first = self.chunks[i] - if off > 0 then - first = first:sub(off + 1) - end - out[#out + 1] = first - for j = i + 1, n do - out[#out + 1] = self.chunks[j] - end - end - - return table.concat(out) - end - - --- Advance read position by n bytes, discarding data. - ---@param n integer - function LinearBuf_mt:advance(n) - assert(n >= 0 and n <= self.len, "LinearBuf:advance out of range") - if n == 0 then return end - - self.len = self.len - n - local i = self.head_idx - local off = self.head_off - - while n > 0 and i <= #self.chunks do - local chunk = self.chunks[i] - local rem = #chunk - off - if n < rem then - off = off + n - n = 0 - else - n = n - rem - i = i + 1 - off = 0 - end - end - - self.head_idx = i - self.head_off = off - end - - function LinearBuf_mt:reserve(_) - return nil - end +---@field append fun(self: LinearBuf, s: string) # append bytes +---@field tostring fun(self: LinearBuf): string # materialise as a single string +---@field reset fun(self: LinearBuf) # clear buffer (optional but common) + +---@class BytesBackend +---@field RingBuf { new: fun(size?: integer): RingBuf } +---@field LinearBuf { new: fun(): LinearBuf } +---@field is_supported fun(): boolean + +local candidates = { + 'fibers.utils.bytes.ffi', + 'fibers.utils.bytes.lua', +} - function LinearBuf_mt:commit(_) +for _, name in ipairs(candidates) do + local ok, mod = pcall(require, name) + if ok and type(mod) == "table" and mod.is_supported and mod.is_supported() then + return mod end - - ---@class LuaImpl - ---@field RingBuf RingBufModule - ---@field LinearBuf LinearBufModule - local lua_impl = { - RingBuf = { new = RingBuf_new }, - LinearBuf = { new = LinearBuf_new }, - } - - return lua_impl end ----------------------------------------------------------------------- --- Assemble implementations and choose default ----------------------------------------------------------------------- - -local ffi_impl = build_ffi_impl() -local lua_impl = build_lua_impl() - -local backend = rawget(_G, "FIBERS_BYTES_BACKEND") or "auto" - -local use_ffi -if backend == "ffi" then - use_ffi = (ffi_impl ~= nil) -elseif backend == "lua" then - use_ffi = false -else -- "auto" - use_ffi = (ffi_impl ~= nil) -end - -local impl = use_ffi and ffi_impl or lua_impl - ----@class BytesModule ----@field RingBuf RingBufModule ----@field LinearBuf LinearBufModule ----@field has_ffi boolean ----@field ffi { RingBuf: RingBufModule, LinearBuf: LinearBufModule }|nil ----@field lua { RingBuf: RingBufModule, LinearBuf: LinearBufModule } - -local M_out = { - -- Default backend: - RingBuf = impl.RingBuf, - LinearBuf = impl.LinearBuf, - has_ffi = use_ffi, - - -- Explicit backends for testing / overrides: - ffi = ffi_impl, - lua = lua_impl, -} -return M_out +error("fibers.utils.bytes: no suitable bytes backend available on this platform") diff --git a/src/fibers/utils/bytes/ffi.lua b/src/fibers/utils/bytes/ffi.lua new file mode 100644 index 0000000..c087c09 --- /dev/null +++ b/src/fibers/utils/bytes/ffi.lua @@ -0,0 +1,223 @@ +-- fibers/utils/bytes/ffi.lua +-- +-- FFI-backed byte buffers: +-- * RingBuf : fixed-capacity ring buffer +-- * LinearBuf : growable buffer + +---@module 'fibers.utils.bytes.ffi' + +local bit = rawget(_G, "bit") or require 'bit32' +local ffi_c = require 'fibers.utils.ffi_compat' + +-- If there is no usable FFI layer, mark this backend unsupported. +if not (ffi_c.is_supported and ffi_c.is_supported()) then + return { + is_supported = function() return false end, + } +end + +local ffi = ffi_c.ffi +local band = bit.band + +ffi.cdef[[ + typedef unsigned int uint32_t; + typedef unsigned char uint8_t; + + typedef struct { + uint32_t read_idx; + uint32_t write_idx; + uint32_t size; + uint8_t buf[?]; + } fibers_ringbuf_t; +]] + +local ring_mt, lin_mt = {}, {} +ring_mt.__index = ring_mt +lin_mt.__index = lin_mt + +local ring_ct = ffi.metatype("fibers_ringbuf_t", ring_mt) + +local function to_u32(n) + return n % 2^32 +end + +local function pos(self, idx) + return band(idx, self.size - 1) +end + +---------------------------------------------------------------------- +-- RingBuf +---------------------------------------------------------------------- + +--- Initialise ring buffer. +function ring_mt:init(size) + assert(type(size) == "number" and size > 0, "RingBuf: positive size required") + assert(band(size, size - 1) == 0, "RingBuf: size must be power of two") + self.size = size + self.read_idx = 0 + self.write_idx = 0 + return self +end + +function ring_mt:reset() + self.read_idx, self.write_idx = 0, 0 +end + +function ring_mt:read_avail() + return to_u32(self.write_idx - self.read_idx) +end + +function ring_mt:write_avail() + return self.size - self:read_avail() +end + +function ring_mt:is_empty() + return self.read_idx == self.write_idx +end + +function ring_mt:is_full() + return self:read_avail() == self.size +end + +local function copy_out(self, n) + local tmp = ffi.new("uint8_t[?]", n) + local size = self.size + local start = pos(self, self.read_idx) + local first = math.min(n, size - start) + + if first > 0 then + ffi.copy(tmp, self.buf + start, first) + end + + local rest = n - first + if rest > 0 then + ffi.copy(tmp + first, self.buf, rest) + end + + self.read_idx = self.read_idx + ffi.cast("uint32_t", n) + return tmp +end + +local function copy_in(self, src, n) + local size = self.size + local start = pos(self, self.write_idx) + local first = math.min(n, size - start) + + if first > 0 then + ffi.copy(self.buf + start, src, first) + end + + local rest = n - first + if rest > 0 then + ffi.copy(self.buf, src + first, rest) + end + + self.write_idx = self.write_idx + ffi.cast("uint32_t", n) +end + +function ring_mt:put(str) + assert(type(str) == "string", "RingBuf:put expects a string") + local n = #str + if n == 0 then return end + assert(n <= self:write_avail(), "RingBuf: write would exceed capacity") + local tmp = ffi.new("uint8_t[?]", n) + ffi.copy(tmp, str, n) + copy_in(self, tmp, n) +end + +function ring_mt:take(n) + assert(type(n) == "number" and n >= 0, "RingBuf:take expects non-negative count") + local avail = self:read_avail() + if avail == 0 or n == 0 then + return "" + end + if n > avail then + n = avail + end + local tmp = copy_out(self, n) + return ffi.string(tmp, n) +end + +function ring_mt:tostring() + local n = self:read_avail() + if n == 0 then + return "" + end + local old = self.read_idx + local tmp = copy_out(self, n) + self.read_idx = old + return ffi.string(tmp, n) +end + +function ring_mt:find(pattern) + assert(type(pattern) == "string" and #pattern > 0, + "RingBuf:find expects non-empty string") + local s = self:tostring() + local i = s:find(pattern, 1, true) + return i and (i - 1) or nil +end + +local function RingBuf_new(size) + local self = ring_ct(size) + return ring_mt.init(self, size) +end + +---------------------------------------------------------------------- +-- LinearBuf +---------------------------------------------------------------------- + +local function LinearBuf_new(cap) + cap = cap or 4096 + assert(cap > 0, "LinearBuf.new: positive initial capacity required") + local buf = ffi.new("uint8_t[?]", cap) + return setmetatable({ buf = buf, len = 0, cap = cap }, lin_mt) +end + +function lin_mt:reset() + self.len = 0 +end + +function lin_mt:ensure(extra) + local needed = self.len + extra + if needed <= self.cap then + return + end + + local new_cap = self.cap > 0 and self.cap or 1 + while new_cap < needed do + new_cap = new_cap * 2 + end + + local new_buf = ffi.new("uint8_t[?]", new_cap) + if self.len > 0 then + ffi.copy(new_buf, self.buf, self.len) + end + self.buf, self.cap = new_buf, new_cap +end + +function lin_mt:append(str) + assert(type(str) == "string", "LinearBuf:append expects a string") + local n = #str + if n == 0 then return end + self:ensure(n) + ffi.copy(self.buf + self.len, str, n) + self.len = self.len + n +end + +function lin_mt:tostring() + if self.len == 0 then + return "" + end + return ffi.string(self.buf, self.len) +end + +---------------------------------------------------------------------- +-- Public API +---------------------------------------------------------------------- + +return { + RingBuf = { new = RingBuf_new }, + LinearBuf = { new = LinearBuf_new }, + has_ffi = true, + is_supported = function() return true end, +} diff --git a/src/fibers/utils/bytes/lua.lua b/src/fibers/utils/bytes/lua.lua new file mode 100644 index 0000000..368c038 --- /dev/null +++ b/src/fibers/utils/bytes/lua.lua @@ -0,0 +1,251 @@ +-- fibers/utils/bytes/lua.lua +-- +-- Pure Lua byte buffers: +-- * RingBuf : fixed-capacity ring buffer (rope of strings) +-- * LinearBuf : growable buffer (rope of strings) + +---@module 'fibers.utils.bytes.lua' + +---------------------------------------------------------------------- +-- Shared rope helpers +---------------------------------------------------------------------- + +local function rope_tostring(chunks, head_idx, head_off) + local n = #chunks + if head_idx > n then + return "" + end + + local out = {} + local first = chunks[head_idx] + if head_off > 0 then + first = first:sub(head_off + 1) + end + out[1] = first + + for i = head_idx + 1, n do + out[#out + 1] = chunks[i] + end + + return table.concat(out) +end + +---------------------------------------------------------------------- +-- RingBuf +---------------------------------------------------------------------- + +---@class LuaRingBuf : RingBuf +local RingBuf_mt = {} +RingBuf_mt.__index = RingBuf_mt + +local function RingBuf_new(size) + assert(type(size) == "number" and size > 0, + "RingBuf.new: positive size required") + return setmetatable({ + chunks = {}, + head_idx = 1, + head_off = 0, + len = 0, + size = size, + }, RingBuf_mt) +end + +local function compact(self) + local hi = self.head_idx + if hi <= 8 and hi <= (#self.chunks / 2) then + return + end + for i = 1, hi - 1 do + self.chunks[i] = nil + end + local k = 1 + for j = hi, #self.chunks do + self.chunks[k] = self.chunks[j] + if k ~= j then + self.chunks[j] = nil + end + k = k + 1 + end + self.head_idx = 1 +end + +function RingBuf_mt:reset() + self.chunks = {} + self.head_idx = 1 + self.head_off = 0 + self.len = 0 +end + +function RingBuf_mt:read_avail() + return self.len +end + +function RingBuf_mt:write_avail() + return self.size - self.len +end + +function RingBuf_mt:is_empty() + return self.len == 0 +end + +function RingBuf_mt:is_full() + return self.len >= self.size +end + +function RingBuf_mt:advance_read(n) + assert(n >= 0 and n <= self.len, "RingBuf:advance_read out of range") + if n == 0 then return end + + self.len = self.len - n + + local i = self.head_idx + local off = self.head_off + + while n > 0 and i <= #self.chunks do + local chunk = self.chunks[i] + local rem = #chunk - off + if n < rem then + off = off + n + n = 0 + else + n = n - rem + i = i + 1 + off = 0 + end + end + + self.head_idx = i + self.head_off = off + compact(self) +end + +function RingBuf_mt:write(src, count) + assert(type(src) == "string", "RingBuf:write expects string") + local n = count or #src + assert(n <= #src, "RingBuf:write count > #src") + assert(n <= self:write_avail(), "RingBuf: write xrun") + if n == 0 then return end + + local s = src + if n < #s then + s = s:sub(1, n) + end + self.len = self.len + n + self.chunks[#self.chunks + 1] = s +end + +function RingBuf_mt:read(_, count) + assert(count >= 0 and count <= self.len, "RingBuf: read xrun") + if count == 0 then + return "" + end + + local out = {} + local need = count + local i = self.head_idx + local off = self.head_off + local n = #self.chunks + + while need > 0 and i <= n do + local chunk = self.chunks[i] + local rem = #chunk - off + local take = math.min(need, rem) + out[#out + 1] = chunk:sub(off + 1, off + take) + need = need - take + if take == rem then + i = i + 1 + off = 0 + else + off = off + take + end + end + + local s = table.concat(out) + self.head_idx = i + self.head_off = off + self.len = self.len - count + compact(self) + return s +end + +function RingBuf_mt:put(str) + assert(type(str) == "string", "RingBuf:put expects a string") + local n = #str + if n == 0 then return end + assert(n <= self:write_avail(), "RingBuf: write would exceed capacity") + self:write(str, n) +end + +function RingBuf_mt:take(n) + assert(type(n) == "number" and n >= 0, "RingBuf:take expects non-negative count") + if self.len == 0 or n == 0 then + return "" + end + if n > self.len then + n = self.len + end + return self:read(nil, n) +end + +function RingBuf_mt:tostring() + if self.len == 0 then + return "" + end + return rope_tostring(self.chunks, self.head_idx, self.head_off) +end + +function RingBuf_mt:find(pattern) + assert(type(pattern) == "string" and #pattern > 0, + "RingBuf:find expects non-empty string") + local s = self:tostring() + local i = s:find(pattern, 1, true) + return i and (i - 1) or nil +end + +---------------------------------------------------------------------- +-- LinearBuf +---------------------------------------------------------------------- + +---@class LuaLinearBuf : LinearBuf +local LinearBuf_mt = {} +LinearBuf_mt.__index = LinearBuf_mt + +local function LinearBuf_new(_) + return setmetatable({ + chunks = {}, + head_idx = 1, + head_off = 0, + len = 0, + }, LinearBuf_mt) +end + +function LinearBuf_mt:reset() + self.chunks = {} + self.head_idx = 1 + self.head_off = 0 + self.len = 0 +end + +function LinearBuf_mt:append(s) + if not s or #s == 0 then return end + self.len = self.len + #s + self.chunks[#self.chunks + 1] = s +end + +function LinearBuf_mt:tostring() + if self.len == 0 then + return "" + end + return rope_tostring(self.chunks, self.head_idx, self.head_off) +end + +---------------------------------------------------------------------- +-- Public API +---------------------------------------------------------------------- + +return { + RingBuf = { new = RingBuf_new }, + LinearBuf = { new = LinearBuf_new }, + has_ffi = false, + is_supported = function() return true end, +} diff --git a/src/fibers/utils/ffi_compat.lua b/src/fibers/utils/ffi_compat.lua new file mode 100644 index 0000000..6c40a7e --- /dev/null +++ b/src/fibers/utils/ffi_compat.lua @@ -0,0 +1,59 @@ +-- fibers/utils/ffi_compat.lua +-- +-- Unified wrapper around LuaJIT ffi and cffi. +-- +-- Exposes: +-- M.ffi : the provider module (luajit ffi or cffi) +-- M.C : ffi.C +-- M.tonumber : cdata-aware tonumber +-- M.type : cdata-aware type +-- M.errno : errno getter +-- M.is_null : NULL / nullptr check for pointers + +local ok, ffi = pcall(require, 'ffi') +local provider = 'luajit_ffi' + +if not ok then + local ok2, cffi = pcall(require, 'cffi') + if not ok2 then + return { + is_supported = function() return false end, + } + end + ffi = cffi + provider = 'cffi' +end + +-- Normalise helper functions. +local tonumber_fn = ffi.tonumber or tonumber +local type_fn = ffi.type or type + +local function errno() + -- Both LuaJIT ffi and cffi expose errno() in their API. + return ffi.errno() +end + +local function is_null(ptr) + -- For LuaJIT ffi, NULL pointers compare equal to nil. + -- For cffi, you must compare with cffi.nullptr. + if ptr == nil then + return true + end + if ffi.nullptr and ptr == ffi.nullptr then + return true + end + return false +end + +local M = { + ffi = ffi, + C = ffi.C, + provider = provider, + tonumber = tonumber_fn, + type = type_fn, + errno = errno, + is_null = is_null, + is_supported = function() return true end, +} + +return M diff --git a/tests/test_utils-bytes.lua b/tests/test_utils-bytes.lua index f849fa0..6240601 100644 --- a/tests/test_utils-bytes.lua +++ b/tests/test_utils-bytes.lua @@ -1,5 +1,3 @@ --- tests/test_utils-bytes.lua - print('testing: fibers.utils.bytes') -- look one level up @@ -8,26 +6,36 @@ package.path = "../src/?.lua;" .. package.path -- Tests for fibers.utils.bytes -- - always exercises the pure Lua backend -- - additionally exercises the FFI backend if available +-- - also exercises the top-level shim (whatever it chooses) -- - all backends are tested via the unified string-level API: -- RingBuf: new, read_avail, write_avail, is_empty, put, take, -- tostring, find, reset -- LinearBuf:new, append, tostring, reset --- plus an extra FFI-only test for reserve/commit on LinearBuf. +-- plus an extra FFI-only test for reserve/commit on LinearBuf, +-- but only if the object actually supports reserve/commit and an +-- ffi/cffi library is available. local function get_ffi() local is_LuaJIT = rawget(_G, "jit") and true or false - local ok, ffi + local ok, ffi_mod if is_LuaJIT then - ok, ffi = pcall(require, "ffi") + ok, ffi_mod = pcall(require, "ffi") else - ok, ffi = pcall(require, "cffi") + ok, ffi_mod = pcall(require, "cffi") end if not ok then return nil end - return ffi + return ffi_mod +end + +local bytes_shim = require "fibers.utils.bytes" +local lua_backend = require "fibers.utils.bytes.lua" + +local ok_ffi_backend, ffi_backend = pcall(require, "fibers.utils.bytes.ffi") +if not ok_ffi_backend then + ffi_backend = nil end -local bytes = require "fibers.utils.bytes" -local ffi = get_ffi() +local ffi_obj = get_ffi() local function assert_eq(actual, expected, msg) if actual ~= expected then @@ -68,6 +76,10 @@ local function test_ring_basic(impl) assert_eq(s, "hello", "take(5) returns 'hello'") assert_eq(rb:read_avail(), 0, "read_avail after full take") assert(rb:is_empty(), "is_empty after draining") + + rb:reset() + assert_eq(rb:read_avail(), 0, "read_avail after reset") + assert(rb:is_empty(), "is_empty after reset") end local function test_ring_wrap(impl) @@ -109,25 +121,30 @@ local function test_linear_basic(impl) end ------------------------------------------------------------ --- FFI-only extra test for reserve/commit on LinearBuf +-- Optional FFI test for reserve/commit on LinearBuf ------------------------------------------------------------ -local function test_linear_reserve_commit_ffi(impl, ffi_obj) - if not ffi_obj then +local function test_linear_reserve_commit_ffi(impl, ffi_mod) + -- Only applicable if we have an ffi/cffi module. + if not ffi_mod then return end - if not impl or not impl.LinearBuf then + if not impl or not impl.LinearBuf or not impl.LinearBuf.new then return end - print(" LinearBuf reserve/commit (FFI)...") + local lb = impl.LinearBuf.new(32) - local LinearBuf = impl.LinearBuf - local lb = LinearBuf.new(32) + -- Only run if this backend actually exposes a pointer-level API. + if type(lb.reserve) ~= "function" or type(lb.commit) ~= "function" then + return + end + + print(" LinearBuf reserve/commit (FFI)...") -- reserve/commit path local p = lb:reserve(5) - ffi_obj.copy(p, "hello", 5) + ffi_mod.copy(p, "hello", 5) lb:commit(5) assert_eq(lb:tostring(), "hello", "LinearBuf tostring after reserve/commit") @@ -140,14 +157,18 @@ end -- Run tests for a given backend ------------------------------------------------------------ -local function run_backend_tests(name, impl, has_ffi_flag, ffi_obj) +local function run_backend_tests(name, impl, ffi_mod) print(("testing bytes backend: %s"):format(name)) + + if not impl or not impl.RingBuf or not impl.LinearBuf then + error(("backend %s missing RingBuf/LinearBuf"):format(name)) + end + test_ring_basic(impl) test_ring_wrap(impl) test_linear_basic(impl) - if has_ffi_flag then - test_linear_reserve_commit_ffi(impl, ffi_obj) - end + test_linear_reserve_commit_ffi(impl, ffi_mod) + print(("backend %s: OK\n"):format(name)) end @@ -158,17 +179,26 @@ end print("testing fibers.utils.bytes") -- Always test pure Lua backend -if not bytes.lua or not bytes.lua.RingBuf or not bytes.lua.LinearBuf then - error("bytes.lua backend not available") +if not lua_backend or not lua_backend.is_supported or not lua_backend.is_supported() then + error("Lua bytes backend not available or not supported") end +run_backend_tests("lua", lua_backend, nil) -run_backend_tests("lua", bytes.lua, false, nil) +-- Test FFI backend if present and supported +if ffi_backend and ffi_backend.is_supported and ffi_backend.is_supported() then + if not ffi_obj then + print("ffi backend available but no ffi/cffi library; skipping pointer-level FFI test") + end + run_backend_tests("ffi", ffi_backend, ffi_obj) +else + print("ffi backend not available or not supported; skipping FFI backend tests\n") +end --- Test FFI backend if present -if bytes.ffi and bytes.ffi.RingBuf and bytes.ffi.LinearBuf and ffi then - run_backend_tests("ffi", bytes.ffi, true, ffi) +-- Test the top-level shim (whatever it chose) +if bytes_shim and bytes_shim.RingBuf and bytes_shim.LinearBuf then + run_backend_tests("shim", bytes_shim, ffi_obj) else - print("ffi backend not available or no ffi/cffi; skipping FFI tests\n") + error("bytes shim backend missing RingBuf/LinearBuf") end print("all bytes tests done") diff --git a/tests/test_utils-bytes_stress.lua b/tests/test_utils-bytes_stress.lua index d5e985d..15ebaa9 100644 --- a/tests/test_utils-bytes_stress.lua +++ b/tests/test_utils-bytes_stress.lua @@ -1,20 +1,27 @@ --- tests/test_bytes_stress.lua -- -- Stress tests for fibers.utils.bytes -- - always exercises the pure Lua backend -- - also exercises the FFI backend if available +-- - also exercises the top-level shim (default backend) -- -- Uses a simple reference model (Lua strings) to verify correctness -- under randomised workloads, via the unified string-level interface: -- RingBuf:put(str), RingBuf:take(n), RingBuf:tostring() -- LinearBuf:append(str), LinearBuf:tostring() +-- print('testing: fibers.utils.bytes stress') -- look one level up package.path = "../src/?.lua;" .. package.path -local bytes = require "fibers.utils.bytes" +local bytes_shim = require "fibers.utils.bytes" +local lua_backend = require "fibers.utils.bytes.lua" + +local ok_ffi_backend, ffi_backend = pcall(require, "fibers.utils.bytes.ffi") +if not ok_ffi_backend then + ffi_backend = nil +end local function assert_eq(actual, expected, msg) if actual ~= expected then @@ -168,24 +175,37 @@ end local function run_backend(label, impl) print(("testing bytes backend (stress): %s"):format(label)) + + if not impl or not impl.RingBuf or not impl.LinearBuf then + error(("backend %s missing RingBuf/LinearBuf"):format(label)) + end + stress_ring(impl, label) stress_linear(impl, label) + print(("backend %s: OK\n"):format(label)) end print("testing fibers.utils.bytes (stress)") -- Always test pure Lua backend -if not bytes.lua or not bytes.lua.RingBuf or not bytes.lua.LinearBuf then - error("bytes.lua backend not available") +if not lua_backend or not lua_backend.is_supported or not lua_backend.is_supported() then + error("Lua bytes backend not available or not supported") +end +run_backend("lua", lua_backend) + +-- Test FFI backend if present and supported +if ffi_backend and ffi_backend.is_supported and ffi_backend.is_supported() then + run_backend("ffi", ffi_backend) +else + print("ffi backend not available or not supported; skipping FFI stress\n") end -run_backend("lua", bytes.lua) --- Test FFI backend if present -if bytes.ffi and bytes.ffi.RingBuf and bytes.ffi.LinearBuf then - run_backend("ffi", bytes.ffi) +-- Also test the shim (which chooses a default backend) +if bytes_shim and bytes_shim.RingBuf and bytes_shim.LinearBuf then + run_backend("shim", bytes_shim) else - print("ffi backend not available; skipping FFI stress\n") + error("bytes shim backend missing RingBuf/LinearBuf") end print("all bytes stress tests done") From 698a03dc469e525ee5307d299cedcdbcbafd0ba7 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sun, 30 Nov 2025 15:39:40 +0000 Subject: [PATCH 061/138] cleanly splits fd backend implementations --- src/fibers/io/fd_backend.lua | 186 ++------------------ src/fibers/io/fd_backend/ffi.lua | 269 +++++++++++++++++++++++++++++ src/fibers/io/fd_backend/posix.lua | 194 +++++++++++++++++++++ 3 files changed, 476 insertions(+), 173 deletions(-) create mode 100644 src/fibers/io/fd_backend/ffi.lua create mode 100644 src/fibers/io/fd_backend/posix.lua diff --git a/src/fibers/io/fd_backend.lua b/src/fibers/io/fd_backend.lua index 9f8d809..91a4b1e 100644 --- a/src/fibers/io/fd_backend.lua +++ b/src/fibers/io/fd_backend.lua @@ -1,6 +1,9 @@ -- fibers/io/fd_backend.lua -- --- FD-backed backend for files/sockets. +-- FD-backed backend shim. +-- Chooses the best available implementation: +-- 1. FFI-based (no luaposix dependency), +-- 2. luaposix-based (no FFI dependency). -- -- Backend contract towards fibers.io.stream: -- * kind() -> "fd" @@ -17,179 +20,16 @@ -- - whence: "set" | "cur" | "end" ---@module 'fibers.io.fd_backend' -local sc = require 'fibers.utils.syscall' -local poller = require 'fibers.io.poller' +local candidates = { + 'fibers.io.fd_backend.ffi', -- FFI / libc + -- 'fibers.io.fd_backend.posix', -- luaposix +} ---- FD-backed stream backend for use with fibers.io.stream. ----@class FdBackend : StreamBackend ----@field filename string|nil - ---- Create a new FD backend instance. ----@param fd integer|nil ----@param opts? { filename?: string } ----@return FdBackend -local function new(fd, opts) - opts = opts or {} - - -- Ensure the descriptor is in non-blocking mode. - if fd ~= nil then - sc.set_nonblock(fd) - end - - ---@class FdBackend - local B = { - filename = opts.filename, - } - - --- Return backend kind identifier. - ---@return '"fd"' - function B:kind() - return "fd" - end - - --- Return underlying file descriptor number, or nil if closed. - ---@return integer|nil - function B:fileno() - return fd - end - - -------------------------------------------------------------------- - -- String-oriented I/O, for use by fibers.io.stream - -------------------------------------------------------------------- - - --- Read up to max bytes as a Lua string. - --- - --- Returns: - --- s : string ("" at EOF) or nil - --- err : string or nil - --- - --- Semantics: - --- * s == nil, err == nil : would block - --- * s == nil, err ~= nil : hard error - --- * s == "" : EOF - --- * s ~= "" : data - ---@param max? integer - ---@return string|nil s, string|nil err - function B:read_string(max) - if not fd then - return nil, "closed" - end - - max = max or 4096 - - local s, err, errno = sc.read(fd, max) - if s == nil then - -- Would block. - if errno == sc.EAGAIN or errno == sc.EWOULDBLOCK then - return nil, nil - end - -- Hard error. - return nil, err or ("errno " .. tostring(errno)) - end - - -- Success, including EOF when s == "". - return s, nil - end - - --- Write a Lua string. - --- - --- Returns: - --- n : number of bytes written, or nil - --- err : string or nil - --- - --- Semantics: - --- * n == nil, err == nil : would block - --- * n == nil, err ~= nil : hard error - --- * n >= 0 : bytes written - ---@param str string - ---@return integer|nil n, string|nil err - function B:write_string(str) - if not fd then - return nil, "closed" - end - - local n, err, errno = sc.write(fd, str) - if n == nil then - if errno == sc.EAGAIN or errno == sc.EWOULDBLOCK then - -- Would block. - return nil, nil - end - -- Hard error. - return nil, err or ("errno " .. tostring(errno)) - end - - return n, nil +for _, name in ipairs(candidates) do + local ok, mod = pcall(require, name) + if ok and type(mod) == "table" and mod.is_supported and mod.is_supported() then + return mod end - - -------------------------------------------------------------------- - -- Seek (used by Stream:seek) - -------------------------------------------------------------------- - - local SEEK = { - set = sc.SEEK_SET, - cur = sc.SEEK_CUR, - ["end"] = sc.SEEK_END, - } - - --- Seek within the file descriptor. - --- - --- whence: "set" | "cur" | "end" - --- off : byte offset - --- - --- Returns: - --- pos : new offset, or nil - --- err : string or nil - ---@param whence '"set"'|'"cur"'|'"end"' - ---@param off integer - ---@return integer|nil pos, string|nil err - function B:seek(whence, off) - if not fd then - return nil, "closed" - end - - local w = SEEK[whence] - if not w then - return nil, "bad whence: " .. tostring(whence) - end - - return sc.lseek(fd, off, w) - end - - -------------------------------------------------------------------- - -- Readiness registration (for waitable/poller) - -------------------------------------------------------------------- - - --- Register for readability events on this fd. - ---@param task Task - ---@return WaitToken - function B:on_readable(task) - return poller.get():wait(assert(fd, "closed fd"), "rd", task) - end - - --- Register for writability events on this fd. - ---@param task Task - ---@return WaitToken - function B:on_writable(task) - return poller.get():wait(assert(fd, "closed fd"), "wr", task) - end - - -------------------------------------------------------------------- - -- Lifecycle - -------------------------------------------------------------------- - - --- Close the backend and underlying fd. - ---@return boolean ok, string|nil err - function B:close() - if fd == nil then - return true - end - - local ok, err = sc.close(fd) - fd = nil - return ok, err - end - - return B end -return { new = new } +error("fibers.io.fd_backend: no suitable fd backend available on this platform") diff --git a/src/fibers/io/fd_backend/ffi.lua b/src/fibers/io/fd_backend/ffi.lua new file mode 100644 index 0000000..598e34f --- /dev/null +++ b/src/fibers/io/fd_backend/ffi.lua @@ -0,0 +1,269 @@ +-- fibers/io/fd_backend/ffi.lua +-- +-- FFI-based FD backend (no luaposix / syscall dependency). +-- Intended to be selected via fibers.io.fd_backend. + +---@module 'fibers.io.fd_backend.ffi' + +local poller = require 'fibers.io.poller' +local ffi_c = require 'fibers.utils.ffi_compat' + +if not ffi_c.is_supported() then + return { is_supported = function() return false end } +end + +local ffi = ffi_c.ffi +local C = ffi_c.C +local toint = ffi_c.tonumber +local get_errno = ffi_c.errno + +local ok_bit, bit_mod = pcall(function() + return rawget(_G, "bit") or require 'bit32' +end) +if not ok_bit or not bit_mod then + return { is_supported = function() return false end } +end +local bit = bit_mod + +ffi.cdef[[ + typedef long ssize_t; + typedef long off_t; + + ssize_t read(int fd, void *buf, size_t count); + ssize_t write(int fd, const void *buf, size_t count); + off_t lseek(int fd, off_t offset, int whence); + int close(int fd); + int fcntl(int fd, int cmd, ...); + char *strerror(int errnum); +]] + +-- POSIX fcntl command numbers are stable (3/4) on Linux. +local F_GETFL = 3 +local F_SETFL = 4 + +-- Default Linux O_NONBLOCK +local O_NONBLOCK = 0x00000800 + +-- Errno values: EAGAIN/EWOULDBLOCK are both 11 on Linux. +local EAGAIN = 11 +local EWOULDBLOCK = 11 + +local function strerror(e) + local s = C.strerror(e) + if s == nil then return "errno " .. tostring(e) end + return ffi.string(s) +end + +---------------------------------------------------------------------- +-- fcntl helpers (casted to avoid varargs issues) +---------------------------------------------------------------------- + +local getfl_fp = ffi.cast("int (*)(int, int)", C.fcntl) +local setfl_fp = ffi.cast("int (*)(int, int, int)", C.fcntl) + +local function set_nonblock(fd) + local before = getfl_fp(fd, F_GETFL) + local before_l = toint(before) + if before_l < 0 then + local e = get_errno() + return false, ("F_GETFL failed: %s"):format(strerror(e)), e + end + + local new_flags = bit.bor(before_l, O_NONBLOCK) + local rc = setfl_fp(fd, F_SETFL, new_flags) + local rc_l = toint(rc) + if rc_l < 0 then + local e = get_errno() + return false, ("F_SETFL failed: %s"):format(strerror(e)), e + end + + -- Sanity check: did the kernel actually set O_NONBLOCK? + local after = getfl_fp(fd, F_GETFL) + local after_l = toint(after) + if after_l < 0 then + local e = get_errno() + return false, ("F_GETFL (post) failed: %s"):format(strerror(e)), e + end + + if bit.band(after_l, O_NONBLOCK) == 0 then + return false, + ("set_nonblock: O_NONBLOCK not set after F_SETFL; before=0x%x after=0x%x") + :format(before_l, after_l), + nil + end + + return true, nil, nil +end + +---------------------------------------------------------------------- +-- FdBackend implementation +---------------------------------------------------------------------- + +---@class FdBackend : StreamBackend +---@field filename string|nil + +---@param fd integer|nil +---@param opts? { filename?: string } +---@return FdBackend +local function new(fd, opts) + opts = opts or {} + + if fd ~= nil then + local ok, err = set_nonblock(fd) + if not ok then + error("fd_backend.ffi: set_nonblock(" .. tostring(fd) .. ") failed: " + .. tostring(err)) + end + end + + ---@class FdBackend + local B = { + filename = opts.filename, + } + + function B:kind() + return "fd" + end + + function B:fileno() + return fd + end + + -------------------------------------------------------------------- + -- String-oriented I/O + -------------------------------------------------------------------- + + ---@param max? integer + ---@return string|nil s, string|nil err + function B:read_string(max) + if not fd then + return nil, "closed" + end + + max = max or 4096 + if max <= 0 then + return "", nil + end + + local buf = ffi.new("char[?]", max) + local n = C.read(fd, buf, max) + local n_l = toint(n) + + if n_l < 0 then + local e = get_errno() + if e == EAGAIN or e == EWOULDBLOCK then + return nil, nil -- would block + end + return nil, strerror(e) + end + + if n_l == 0 then + return "", nil -- EOF + end + + if n_l > max then + return nil, "read returned " .. tostring(n_l) .. " bytes (max " .. tostring(max) .. ")" + end + + return ffi.string(buf, n_l), nil + end + + ---@param str string + ---@return integer|nil n, string|nil err + function B:write_string(str) + if not fd then + return nil, "closed" + end + + local len = #str + if len == 0 then + return 0, nil + end + + local buf = ffi.new("char[?]", len) + ffi.copy(buf, str, len) + + local n = C.write(fd, buf, len) + local n_l = toint(n) + + if n_l < 0 then + local e = get_errno() + if e == EAGAIN or e == EWOULDBLOCK then + return nil, nil -- would block + end + return nil, strerror(e) + end + + return n_l, nil + end + + -------------------------------------------------------------------- + -- Seek + -------------------------------------------------------------------- + + local SEEK = { set = 0, cur = 1, ["end"] = 2 } + + ---@param whence '"set"'|'"cur"'|'"end"' + ---@param off integer + ---@return integer|nil pos, string|nil err + function B:seek(whence, off) + if not fd then + return nil, "closed" + end + + local w = SEEK[whence] + if not w then + return nil, "bad whence: " .. tostring(whence) + end + + local res = C.lseek(fd, off, w) + local res_l = toint(res) + if res_l < 0 then + return nil, strerror(get_errno()) + end + + return res_l, nil + end + + -------------------------------------------------------------------- + -- Readiness registration + -------------------------------------------------------------------- + + function B:on_readable(task) + return poller.get():wait(assert(fd, "closed fd"), "rd", task) + end + + function B:on_writable(task) + return poller.get():wait(assert(fd, "closed fd"), "wr", task) + end + + -------------------------------------------------------------------- + -- Lifecycle + -------------------------------------------------------------------- + + ---@return boolean ok, string|nil err + function B:close() + if fd == nil then + return true, nil + end + + local rc = C.close(fd) + fd = nil + if toint(rc) ~= 0 then + return false, strerror(get_errno()) + end + return true, nil + end + + return B +end + +local function is_supported() + -- We already gated on ffi_compat + bit; this backend is intended for Linux. + return true +end + +return { + new = new, + is_supported = is_supported, +} diff --git a/src/fibers/io/fd_backend/posix.lua b/src/fibers/io/fd_backend/posix.lua new file mode 100644 index 0000000..3253567 --- /dev/null +++ b/src/fibers/io/fd_backend/posix.lua @@ -0,0 +1,194 @@ +-- fibers/io/fd_backend/posix.lua +-- +-- luaposix-based FD backend for files/sockets. +-- +-- Backend contract towards fibers.io.stream: +-- * kind() -> "fd" +-- * fileno() -> fd +-- * read_string(max) -> str|nil, err|nil +-- * write_string(str) -> n|nil, err|nil +-- * on_readable(task) -> token{ unlink = fn } +-- * on_writable(task) -> token{ unlink = fn } +-- * close() -> ok, err|nil +-- * seek(whence, off) -> pos|nil, err|nil + +---@module 'fibers.io.fd_backend.posix' + +local poller = require 'fibers.io.poller' + +local ok_unistd, unistd = pcall(require, 'posix.unistd') +local ok_fcntl, fcntl = pcall(require, 'posix.fcntl') +local ok_errno, errno = pcall(require, 'posix.errno') + +if not (ok_unistd and ok_fcntl and ok_errno) then + return { + is_supported = function() return false end, + } +end + +local bit = rawget(_G, "bit") or require 'bit32' + +local EAGAIN = errno.EAGAIN +local EWOULDBLOCK = errno.EWOULDBLOCK or errno.EAGAIN + +local SEEK_SET = fcntl.SEEK_SET or 0 +local SEEK_CUR = fcntl.SEEK_CUR or 1 +local SEEK_END = fcntl.SEEK_END or 2 + +local function set_nonblock(fd) + local flags, err, en = fcntl.fcntl(fd, fcntl.F_GETFL) + if flags == nil then + return nil, err or ("fcntl(F_GETFL) errno " .. tostring(en)), en + end + + local newflags = bit.bor(flags, fcntl.O_NONBLOCK) + local ok, err2, en2 = fcntl.fcntl(fd, fcntl.F_SETFL, newflags) + if ok == nil then + return nil, err2 or ("fcntl(F_SETFL) errno " .. tostring(en2)), en2 + end + + return true, nil, nil +end + +--- FD-backed stream backend for use with fibers.io.stream. +---@class FdBackend : StreamBackend +---@field filename string|nil + +--- Create a new FD backend instance. +---@param fd integer|nil +---@param opts? { filename?: string } +---@return FdBackend +local function new(fd, opts) + opts = opts or {} + + if fd ~= nil then + local ok, err = set_nonblock(fd) + if not ok then + error("set_nonblock failed: " .. tostring(err)) + end + end + + ---@class FdBackend + local B = { + filename = opts.filename, + } + + function B:kind() + return "fd" + end + + function B:fileno() + return fd + end + + -------------------------------------------------------------------- + -- String-oriented I/O + -------------------------------------------------------------------- + + function B:read_string(max) + if not fd then + return nil, "closed" + end + + max = max or 4096 + if max <= 0 then + return "", nil + end + + local s, err, en = unistd.read(fd, max) + if s == nil then + if en == EAGAIN or en == EWOULDBLOCK then + -- Would block. + return nil, nil + end + return nil, err or ("errno " .. tostring(en)) + end + + -- s may be "" at EOF; that is fine. + return s, nil + end + + function B:write_string(str) + if not fd then + return nil, "closed" + end + + local n, err, en = unistd.write(fd, str) + if n == nil then + if en == EAGAIN or en == EWOULDBLOCK then + -- Would block. + return nil, nil + end + return nil, err or ("errno " .. tostring(en)) + end + + return n, nil + end + + -------------------------------------------------------------------- + -- Seek (used by Stream:seek) + -------------------------------------------------------------------- + + local SEEK = { + set = SEEK_SET, + cur = SEEK_CUR, + ["end"] = SEEK_END, + } + + function B:seek(whence, off) + if not fd then + return nil, "closed" + end + + local w = SEEK[whence] + if not w then + return nil, "bad whence: " .. tostring(whence) + end + + local pos, err, en = unistd.lseek(fd, off, w) + if pos == nil then + return nil, err or ("errno " .. tostring(en)) + end + return pos, nil + end + + -------------------------------------------------------------------- + -- Readiness registration (for waitable/poller) + -------------------------------------------------------------------- + + function B:on_readable(task) + return poller.get():wait(assert(fd, "closed fd"), "rd", task) + end + + function B:on_writable(task) + return poller.get():wait(assert(fd, "closed fd"), "wr", task) + end + + -------------------------------------------------------------------- + -- Lifecycle + -------------------------------------------------------------------- + + function B:close() + if fd == nil then + return true, nil + end + + local ok, err, en = unistd.close(fd) + fd = nil + if ok == nil or ok == false then + return false, err or ("errno " .. tostring(en)) + end + return true, nil + end + + return B +end + +local function is_supported() + return true +end + +return { + new = new, + is_supported = is_supported, +} From 94bcd735f98ded476830f908b6430e36e79618aa Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sun, 30 Nov 2025 15:40:11 +0000 Subject: [PATCH 062/138] cleanly splits poller implementations --- src/fibers/io/poller.lua | 152 ++------------ src/fibers/io/poller/epoll.lua | 361 ++++++++++++++++++++++++++++++++ src/fibers/io/poller/select.lua | 159 ++++++++++++++ 3 files changed, 533 insertions(+), 139 deletions(-) create mode 100644 src/fibers/io/poller/epoll.lua create mode 100644 src/fibers/io/poller/select.lua diff --git a/src/fibers/io/poller.lua b/src/fibers/io/poller.lua index bc0928b..61190d1 100644 --- a/src/fibers/io/poller.lua +++ b/src/fibers/io/poller.lua @@ -1,145 +1,19 @@ ---- --- Epoll-based poller integration for fibers. +-- fibers/io/poller.lua -- --- Provides a singleton Poller that: --- * integrates an epoll backend with the scheduler, --- * exposes a wait(fd, dir, task) API used by IO backends, --- * acts both as a TaskSource and as the scheduler's event_waiter. --- --- schedule_tasks is used in two modes: --- * as a normal source: non-blocking poll (timeout coerced to 0), --- * as event_waiter: blocking poll with the scheduler's timeout. ----@module 'fibers.io.poller' - -local runtime = require 'fibers.runtime' -local epoll = require 'fibers.io.epoll' -local bit = rawget(_G, "bit") or require 'bit32' -local wait = require 'fibers.wait' +-- Poller shim: chooses the best available backend. +-- Order matters: we prefer epoll (FFI) when possible, then fall back +-- to a pure-luaposix select/poll implementation. ---- Epoll-based poller registered as a scheduler TaskSource. ----@class Poller : TaskSource ----@field ep any # epoll backend handle ----@field rd Waitset # fd -> tasks waiting for read ----@field wr Waitset # fd -> tasks waiting for write -local Poller = {} -Poller.__index = Poller - ---- Create a new poller instance. ----@return Poller -local function new_poller() - return setmetatable({ - ep = epoll.new(), - rd = wait.new_waitset(), -- fd -> tasks waiting for read - wr = wait.new_waitset(), -- fd -> tasks waiting for write - }, Poller) -end - ---- Recompute epoll interest mask for a single fd from rd/wr waitsets. ----@param self Poller ----@param fd integer -local function recompute_mask(self, fd) - local need_rd = not self.rd:is_empty(fd) - local need_wr = not self.wr:is_empty(fd) - local mask = 0 - if need_rd then mask = bit.bor(mask, epoll.RD) end - if need_wr then mask = bit.bor(mask, epoll.WR) end - if mask ~= 0 then - -- ep:add is expected to behave as add-or-modify for existing fds. - self.ep:add(fd, mask) - else - self.ep:del(fd) - end -end - ---- Register a task as waiting on an fd for read or write readiness. ---- ---- The returned token's unlink() will deregister the task from the ---- relevant Waitset and keep the epoll mask in sync. ----@param fd integer ----@param dir '"rd"'|'"wr"' ----@param task Task ----@return WaitToken -function Poller:wait(fd, dir, task) - assert(type(fd) == 'number', "fd must be number") - assert(dir == "rd" or dir == "wr", "dir must be 'rd' or 'wr'") - - local ws = (dir == "rd") and self.rd or self.wr - local token = ws:add(fd, task) - - -- Ensure epoll is armed now there is at least one waiter. - recompute_mask(self, fd) - - -- Wrap unlink so we keep epoll mask in sync when buckets empty. - local original_unlink = token.unlink - local owner = self - - ---@param tok WaitToken - ---@return boolean - function token.unlink(tok) - local emptied = original_unlink(tok) - if emptied then - recompute_mask(owner, fd) - end - return emptied - end - - return token -end - ---- TaskSource hook: poll epoll and schedule any ready tasks. ---- ---- Called in two contexts: ---- * from Scheduler:schedule_tasks_from_sources(now) with timeout=nil ---- (effectively non-blocking), ---- * from Scheduler:wait_for_events(now, timeout) as event_waiter. ----@param sched Scheduler ----@param _ number|nil -- current monotonic time (unused here) ----@param timeout number|nil -- seconds -function Poller:schedule_tasks(sched, _, timeout) - -- timeout in seconds; epoll_wait in milliseconds. - if timeout == nil then timeout = 0 end - if timeout >= 0 then timeout = timeout * 1e3 end - - for fd, ev in pairs(self.ep:poll(timeout)) do - if bit.band(ev, epoll.RD + epoll.ERR) ~= 0 then - self.rd:notify_all(fd, sched) - end - if bit.band(ev, epoll.WR + epoll.ERR) ~= 0 then - self.wr:notify_all(fd, sched) - end - recompute_mask(self, fd) - end -end - ---- Event waiter hook; aliased to schedule_tasks. ---- The scheduler treats this as a blocking wait with a timeout. -Poller.wait_for_events = Poller.schedule_tasks - ---- Close the underlying epoll handle. -function Poller:close() - self.ep:close() - self.ep = nil -end - --- Singleton wiring. -local singleton +local candidates = { + 'fibers.io.poller.epoll', -- Linux + FFI/epoll + -- 'fibers.io.poller.select', -- luaposix poll/select +} ---- Get the process-wide poller instance, creating and registering it if needed. ----@return Poller -local function get() - if singleton then return singleton end - singleton = new_poller() - local sched = runtime.current_scheduler - if sched.add_task_source then - sched:add_task_source(singleton) - else - -- Fallback for older schedulers without add_task_source. - sched.sources = sched.sources or {} - table.insert(sched.sources, singleton) +for _, name in ipairs(candidates) do + local ok, mod = pcall(require, name) + if ok and type(mod) == "table" and mod.is_supported and mod.is_supported() then + return mod end - return singleton end -return { - get = get -} +error("fibers.io.poller: no suitable poller backend available on this platform") diff --git a/src/fibers/io/poller/epoll.lua b/src/fibers/io/poller/epoll.lua new file mode 100644 index 0000000..d548c3a --- /dev/null +++ b/src/fibers/io/poller/epoll.lua @@ -0,0 +1,361 @@ +-- fibers/io/poller/epoll.lua +-- +-- Linux epoll-based poller backend. +-- Supports LuaJIT (ffi) and PUC Lua with cffi. +-- Intended to be selected via fibers.io.poller. + +local runtime = require 'fibers.runtime' +local wait = require 'fibers.wait' +local safe = require 'coxpcall' + +local bit = rawget(_G, "bit") or require 'bit32' +local ffi_c = require 'fibers.utils.ffi_compat' + +---------------------------------------------------------------------- +-- FFI / CFFI setup via ffi_compat +---------------------------------------------------------------------- + +if not (ffi_c.is_supported and ffi_c.is_supported()) then + return { is_supported = function() return false end } +end + +local ffi = ffi_c.ffi +local C = ffi_c.C +local ffi_tonumber = ffi_c.tonumber +local get_errno = ffi_c.errno + +local EINTR = 4 +local ENOENT = 2 +local EBADF = 9 + +local ARCH = ffi.arch or ((jit and jit.arch) or "x64") + +---------------------------------------------------------------------- +-- Low-level epoll bindings (local to this file) +---------------------------------------------------------------------- + +ffi.cdef[[ + typedef unsigned char uint8_t; + typedef unsigned int uint32_t; + typedef unsigned long long uint64_t; + + int epoll_create(int size); + int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); + int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); + + int close(int fd); + char *strerror(int errnum); +]] + +-- epoll_event layout differs by architecture. +if ARCH == "x64" or ARCH == "x86" then + ffi.cdef[[ + typedef struct epoll_event { + uint8_t raw[12]; // 4 bytes for events + 8 bytes for data + } epoll_event; + ]] +elseif ARCH == "mips" or ARCH == "mipsel" or ARCH == "arm64" then + ffi.cdef[[ + typedef struct epoll_event { + uint32_t events; + uint64_t data; + } epoll_event; + ]] +else + error("fibers.io.poller.epoll: unsupported architecture " .. tostring(ARCH)) +end + +-- Event bits. +local EPOLLIN = 0x00000001 +local EPOLLOUT = 0x00000004 +local EPOLLERR = 0x00000008 +local EPOLLHUP = 0x00000010 +local EPOLLRDHUP = 0x00002000 +local EPOLLONESHOT = bit.lshift(1, 30) + +local EPOLL_CTL_ADD = 1 +local EPOLL_CTL_DEL = 2 +local EPOLL_CTL_MOD = 3 + +-- Architecture-dependent field access. +local get_event, set_event, get_data, set_data + +if ARCH == "x64" or ARCH == "x86" then + get_event = function(ev) + return ffi.cast("uint32_t*", ev.raw)[0] + end + set_event = function(ev, value) + ffi.cast("uint32_t*", ev.raw)[0] = value + end + get_data = function(ev) + return ffi.cast("uint64_t*", ev.raw + 4)[0] + end + set_data = function(ev, value) + ffi.cast("uint64_t*", ev.raw + 4)[0] = value + end +else + get_event = function(ev) + return ev.events + end + set_event = function(ev, value) + ev.events = value + end + get_data = function(ev) + return ev.data + end + set_data = function(ev, value) + ev.data = value + end +end + +local function wrap_error(ret) + if ret == -1 then + local errno = get_errno() + local err = ffi.string(C.strerror(errno)) + return nil, err, errno + end + return ret, nil, nil +end + +local function epoll_create() + local fd, err, errno = wrap_error(C.epoll_create(1)) + if not fd then + error(err or ("epoll_create failed (errno " .. tostring(errno) .. ")")) + end + return fd +end + +local function epoll_ctl_add(epfd, fd, mask) + local ev = ffi.new("struct epoll_event") + set_event(ev, mask) + set_data(ev, fd) + return wrap_error(C.epoll_ctl(epfd, EPOLL_CTL_ADD, fd, ev)) +end + +local function epoll_ctl_mod(epfd, fd, mask) + local ev = ffi.new("struct epoll_event") + set_event(ev, mask) + set_data(ev, fd) + return wrap_error(C.epoll_ctl(epfd, EPOLL_CTL_MOD, fd, ev)) +end + +local function epoll_ctl_del(epfd, fd) + return wrap_error(C.epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nil)) +end + +local function epoll_wait(epfd, timeout_ms, max_events) + local events = ffi.new("struct epoll_event[?]", max_events) + local n = C.epoll_wait(epfd, events, max_events, timeout_ms) + if n == -1 then + local errno = get_errno() + if errno == EINTR then + -- Benign interruption: report “no events”. + return {}, nil, errno + end + local err = ffi.string(C.strerror(errno)) + return nil, err, errno + end + + local res = {} + for i = 0, n - 1 do + local fd = assert(ffi_tonumber(get_data(events[i]))) + local event = assert(ffi_tonumber(get_event(events[i]))) + res[fd] = event + end + + return res, nil, nil +end + +local function epoll_close(epfd) + return wrap_error(C.close(epfd)) +end + +---------------------------------------------------------------------- +-- Epoll wrapper object +---------------------------------------------------------------------- + +---@class Epoll +---@field epfd integer +---@field active_events table +---@field maxevents integer +local Epoll = {} +Epoll.__index = Epoll + +local INITIAL_MAXEVENTS = 8 + +local function new_epoll() + local ret = { + epfd = epoll_create(), + active_events = {}, + maxevents = INITIAL_MAXEVENTS, + } + return setmetatable(ret, Epoll) +end + +local RD = EPOLLIN + EPOLLRDHUP +local WR = EPOLLOUT +local ERR = EPOLLERR + EPOLLHUP + +function Epoll:add(fd, events) + local active = self.active_events[fd] or 0 + local eventmask = bit.bor(events, active, EPOLLONESHOT) + local ok = epoll_ctl_mod(self.epfd, fd, eventmask) + if not ok then + assert(epoll_ctl_add(self.epfd, fd, eventmask)) + end + self.active_events[fd] = eventmask +end + +function Epoll:poll(timeout_ms) + local events, err, errno = epoll_wait(self.epfd, timeout_ms or 0, self.maxevents) + if not events then + error(err or "epoll_wait failed (errno " .. tostring(errno) .. ")") + end + + local count = 0 + for fd, _ in pairs(events) do + count = count + 1 + self.active_events[fd] = nil + end + + if count == self.maxevents then + self.maxevents = self.maxevents * 2 + end + + return events, nil +end + +function Epoll:del(fd) + local ok, err, errno = epoll_ctl_del(self.epfd, fd) + if not ok then + -- ENOENT/EBADF: fd already closed or never registered; just clear. + if errno == ENOENT or errno == EBADF then + self.active_events[fd] = nil + return + end + error(err or "epoll_ctl(DEL) failed (errno " .. tostring(errno) .. ")") + end + self.active_events[fd] = nil +end + +function Epoll:close() + epoll_close(self.epfd) + self.epfd = nil +end + +---------------------------------------------------------------------- +-- Poller TaskSource built on Epoll +---------------------------------------------------------------------- + +---@class Poller : TaskSource +---@field ep Epoll +---@field rd Waitset +---@field wr Waitset +local Poller = {} +Poller.__index = Poller + +local function new_poller() + return setmetatable({ + ep = new_epoll(), + rd = wait.new_waitset(), + wr = wait.new_waitset(), + }, Poller) +end + +local function recompute_mask(self, fd) + local need_rd = not self.rd:is_empty(fd) + local need_wr = not self.wr:is_empty(fd) + local mask = 0 + if need_rd then mask = bit.bor(mask, RD) end + if need_wr then mask = bit.bor(mask, WR) end + + if mask ~= 0 then + self.ep:add(fd, mask) + else + self.ep:del(fd) + end +end + +--- Register a task as waiting on an fd for read or write readiness. +---@param fd integer +---@param dir '"rd"'|'"wr"' +---@param task Task +---@return WaitToken +function Poller:wait(fd, dir, task) + assert(type(fd) == 'number', "fd must be number") + assert(dir == "rd" or dir == "wr", "dir must be 'rd' or 'wr'") + + local ws = (dir == "rd") and self.rd or self.wr + local token = ws:add(fd, task) + + recompute_mask(self, fd) + + local original_unlink = token.unlink + local owner = self + + function token.unlink(tok) + local emptied = original_unlink(tok) + if emptied then + recompute_mask(owner, fd) + end + return emptied + end + + return token +end + +--- TaskSource hook: poll epoll and schedule ready tasks. +---@param sched Scheduler +---@param _ number|nil +---@param timeout number|nil -- seconds +function Poller:schedule_tasks(sched, _, timeout) + if timeout == nil then timeout = 0 end + if timeout >= 0 then timeout = timeout * 1e3 end + + local events = self.ep:poll(timeout) + for fd, ev in pairs(events) do + if bit.band(ev, RD + ERR) ~= 0 then + self.rd:notify_all(fd, sched) + end + if bit.band(ev, WR + ERR) ~= 0 then + self.wr:notify_all(fd, sched) + end + recompute_mask(self, fd) + end +end + +Poller.wait_for_events = Poller.schedule_tasks + +function Poller:close() + self.ep:close() + self.ep = nil +end + +---------------------------------------------------------------------- +-- Singleton and capability probe +---------------------------------------------------------------------- + +local singleton + +local function get() + if singleton then return singleton end + singleton = new_poller() + local sched = runtime.current_scheduler + assert(sched.add_task_source, "scheduler must implement add_task_source") + sched:add_task_source(singleton) + return singleton +end + +local function is_supported() + local ok = safe.pcall(function() + local e = new_epoll() + e:close() + end) + return ok +end + +return { + get = get, + Poller = Poller, + is_supported = is_supported, +} diff --git a/src/fibers/io/poller/select.lua b/src/fibers/io/poller/select.lua new file mode 100644 index 0000000..74ccb07 --- /dev/null +++ b/src/fibers/io/poller/select.lua @@ -0,0 +1,159 @@ +-- fibers/io/poller/select.lua +-- +-- posix.poll()-based poller backend (no epoll required). +-- Intended to be selected via fibers.io.poller. + +---@module 'fibers.io.poller.select' + +local runtime = require 'fibers.runtime' +local wait = require 'fibers.wait' + +-- Try to load luaposix poll support. +local ok, poll_mod = pcall(require, 'posix.poll') +if not ok or type(poll_mod) ~= "table" or type(poll_mod.poll) ~= "function" then + -- Backend is present but unusable on this platform. + return { + is_supported = function() return false end, + } +end + +local poll_fn = poll_mod.poll + +---@class Waitset +---@field buckets table + +---@class SelectPoller : TaskSource +---@field rd Waitset # fd -> tasks waiting for read +---@field wr Waitset # fd -> tasks waiting for write +local SelectPoller = {} +SelectPoller.__index = SelectPoller + +local function new_poller() + return setmetatable({ + rd = wait.new_waitset(), + wr = wait.new_waitset(), + }, SelectPoller) +end + +--- Register a task as waiting on an fd for read or write readiness. +---@param fd integer +---@param dir '"rd"'|'"wr"' +---@param task Task +---@return WaitToken +function SelectPoller:wait(fd, dir, task) + assert(type(fd) == 'number', "fd must be number") + assert(dir == "rd" or dir == "wr", "dir must be 'rd' or 'wr'") + + local ws = (dir == "rd") and self.rd or self.wr + return ws:add(fd, task) +end + +--- Build the fds table in the shape expected by posix.poll.poll: +--- fds[fd] = { events = { IN = true, OUT = true } } +---@param self SelectPoller +---@return table +local function build_fds(self) + local fds = {} + + -- Any fd with one or more read waiters gets IN. + for fd, list in pairs(self.rd.buckets) do + if list and #list > 0 then + local e = fds[fd] + if not e then + e = { events = {} } + fds[fd] = e + end + e.events.IN = true + end + end + + -- Any fd with one or more write waiters gets OUT. + for fd, list in pairs(self.wr.buckets) do + if list and #list > 0 then + local e = fds[fd] + if not e then + e = { events = {} } + fds[fd] = e + end + e.events.OUT = true + end + end + + return fds +end + +--- TaskSource hook: poll and schedule any ready tasks. +---@param sched Scheduler +---@param _ number|nil -- current monotonic time (unused) +---@param timeout number|nil -- seconds +function SelectPoller:schedule_tasks(sched, _, timeout) + -- Convert timeout from seconds to milliseconds for posix.poll. + local timeout_ms + if timeout == nil then + timeout_ms = 0 + elseif timeout < 0 then + timeout_ms = -1 + else + timeout_ms = math.floor(timeout * 1e3 + 0.5) + end + + local fds = build_fds(self) + + -- poll() with nfds == 0 is defined and just sleeps for timeout. + local nready, err, errno = poll_fn(fds, timeout_ms) + if nready == nil then + -- Treat as a hard failure: surfaced as a normal Lua error, which + -- will be caught by the scope/fibre machinery. + error(("%s (errno %s)"):format(tostring(err), tostring(errno))) + end + + if nready == 0 then + return + end + + -- Wake tasks for any fd that reported events. + -- + -- luaposix reports readiness in fds[fd].revents with flags + -- such as IN, OUT, ERR, HUP, NVAL. + for fd, info in pairs(fds) do + local re = info.revents + if re then + if re.IN or re.HUP or re.ERR or re.NVAL then + self.rd:notify_all(fd, sched) + end + if re.OUT or re.ERR or re.NVAL then + self.wr:notify_all(fd, sched) + end + end + end +end + +-- Used as the scheduler's event_waiter. +SelectPoller.wait_for_events = SelectPoller.schedule_tasks + +function SelectPoller:close() + self.rd:clear_all() + self.wr:clear_all() +end + +-- Singleton wiring (mirrors the epoll poller pattern). +local singleton + +local function get() + if singleton then return singleton end + singleton = new_poller() + local sched = runtime.current_scheduler + assert(sched.add_task_source, "scheduler must implement add_task_source") + sched:add_task_source(singleton) + return singleton +end + +local function is_supported() + return true +end + +return { + get = get, + Poller = SelectPoller, + is_supported = is_supported, +} From a717664e0b7e3b3b1fba0f3c183afb52d52543ef Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sun, 30 Nov 2025 15:52:16 +0000 Subject: [PATCH 063/138] trims big fat syscall --- src/fibers/utils/syscall.lua | 315 +---------------------------------- 1 file changed, 8 insertions(+), 307 deletions(-) diff --git a/src/fibers/utils/syscall.lua b/src/fibers/utils/syscall.lua index 9ecf60d..d91555c 100644 --- a/src/fibers/utils/syscall.lua +++ b/src/fibers/utils/syscall.lua @@ -246,157 +246,6 @@ local function wrap_error(retval) end end ------------------------------------- --- epoll - -if ARCH == "x64" or ARCH == "x86" then - ffi.cdef [[ - typedef struct epoll_event { - uint8_t raw[12]; // 4 bytes for events + 8 bytes for data - } epoll_event; - ]] -elseif ARCH == "mips" or ARCH == "mipsel" or ARCH == "arm64" then - ffi.cdef [[ - typedef struct epoll_event { - uint32_t events; - uint64_t data; - } epoll_event; - ]] -else - error(ARCH .. " architecture not specified") -end - -ffi.cdef [[ - int epoll_create(int size); - int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); - int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); - - int fcntl(int fd, int cmd, ...); - int close(int fd); - char *strerror(int errnum); - - int prctl(int option, unsigned long arg2, unsigned long arg3, unsigned long arg4, unsigned long arg5); -]] - -M.EPOLLIN = 0x00000001 -M.EPOLLPRI = 0x00000002 -M.EPOLLOUT = 0x00000004 -M.EPOLLERR = 0x00000008 -M.EPOLLHUP = 0x00000010 -M.EPOLLNVAL = 0x00000020 -M.EPOLLRDNORM = 0x00000040 -M.EPOLLRDBAND = 0x00000080 -M.EPOLLWRNORM = 0x00000100 -M.EPOLLWRBAND = 0x00000200 -M.EPOLLMSG = 0x00000400 -M.EPOLLRDHUP = 0x00002000 - -M.EPOLLEXCLUSIVE = bit.lshift(1, 28) -M.EPOLLWAKEUP = bit.lshift(1, 29) -M.EPOLLONESHOT = bit.lshift(1, 30) -M.EPOLLET = bit.lshift(1, 31) - -local EPOLL_CTL_ADD = 1 -local EPOLL_CTL_DEL = 2 -local EPOLL_CTL_MOD = 3 - - --- Adjust helper functions based on the architecture: -local get_event -local set_event -local get_data -local set_data - -if ARCH == 'x64' or ARCH == 'x86' then - get_event = function(ev) - return ffi.cast("uint32_t*", ev.raw)[0] - end - set_event = function(ev, value) - ffi.cast("uint32_t*", ev.raw)[0] = value - end - get_data = function(ev) - return ffi.cast("uint64_t*", ev.raw + 4)[0] - end - set_data = function(ev, value) - ffi.cast("uint64_t*", ev.raw + 4)[0] = value - end -elseif ARCH == 'mips' or ARCH == 'arm64' or ARCH == 'mipsel' then - get_event = function(ev) - return ev.events - end - set_event = function(ev, value) - ev.events = value - end - get_data = function(ev) - return ev.data - end - set_data = function(ev, value) - ev.data = value - end -else - error(ARCH .. " architecture not specified") -end - --- Returns an epoll file descriptor. -function M.epoll_create() - return wrap_error(ffi.C.epoll_create(1)) -end - --- Register eventmask of a file descriptor onto epoll file descriptor. -function M.epoll_register(epfd, fd, eventmask) - local event = ffi.new("struct epoll_event") - set_event(event, eventmask) - set_data(event, fd) - return wrap_error(ffi.C.epoll_ctl(epfd, EPOLL_CTL_ADD, fd, event)) -end - --- Modify eventmask of a file descriptor. -function M.epoll_modify(epfd, fd, eventmask) - local event = ffi.new("struct epoll_event") - set_event(event, eventmask) - set_data(event, fd) - return wrap_error(ffi.C.epoll_ctl(epfd, EPOLL_CTL_MOD, fd, event)) -end - --- Remove a registered file descriptor from the epoll file descriptor. -function M.epoll_unregister(epfd, fd) - return wrap_error(ffi.C.epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nil)) -end - --- Wait for events. -function M.epoll_wait(epfd, timeout, max_events) - local events = ffi.new("struct epoll_event[?]", max_events) - local num_events = ffi.C.epoll_wait(epfd, events, max_events, timeout) - if num_events == -1 then - return nil, ffi.string(ffi.C.strerror(ffi.errno())) - end - - -- Create a table to hold the resulting events - local res = {} - - -- Loop over the events, inserting them into the table with their fd as the key - for i = 0, num_events - 1 do - local fd = assert(ffi.tonumber(get_data(events[i]))) - local event = assert(ffi.tonumber(get_event(events[i]))) - res[fd] = event - end - - return res, num_events -end - --- Close epoll file descriptor. -function M.epoll_close(epfd) - return wrap_error(ffi.C.close(epfd)) -end - -function M.prctl(option, arg2, arg3, arg4, arg5) - arg2 = arg2 or 0 - arg3 = arg3 or 0 - arg4 = arg4 or 0 - arg5 = arg5 or 0 - - return wrap_error(ffi.C.prctl(option, arg2, arg3, arg4, arg5)) -end ------------------------------------------------------------------------------- -- FFI C structure functions (for efficiency) @@ -405,100 +254,23 @@ M.ffi.typeof = ffi.typeof M.ffi.sizeof = ffi.sizeof ffi.cdef [[ - ssize_t write(int fildes, const void *buf, size_t nbytes); - ssize_t read(int fildes, void *buf, size_t nbytes); + //ssize_t write(int fildes, const void *buf, size_t nbytes); + //ssize_t read(int fildes, void *buf, size_t nbytes); int memcmp(const void *s1, const void *s2, size_t n); ]] -function M.ffi.write(fildes, buf, nbytes) - return wrap_error(ffi.tonumber(ffi.C.write(fildes, buf, nbytes))) -end +-- function M.ffi.write(fildes, buf, nbytes) +-- return wrap_error(ffi.tonumber(ffi.C.write(fildes, buf, nbytes))) +-- end -function M.ffi.read(fildes, buf, nbytes) - return wrap_error(ffi.tonumber(ffi.C.read(fildes, buf, nbytes))) -end +-- function M.ffi.read(fildes, buf, nbytes) +-- return wrap_error(ffi.tonumber(ffi.C.read(fildes, buf, nbytes))) +-- end function M.ffi.memcmp(obj1, obj2, nbytes) return ffi.tonumber(ffi.C.memcmp(obj1, obj2, nbytes)) end --- Explicitly load the pthread library - -local pthread_names = { - "pthread", - "libpthread.so.0" -} - -local libpthread = nil - -for _, v in ipairs(pthread_names) do - local success - success, libpthread = pcall(ffi.load, v) - if success then break end -end - -if not libpthread then error("libpthread not found") end - -ffi.cdef [[ -typedef struct { - uint32_t ssi_signo; /* Signal number */ - int32_t ssi_errno; /* Error number (unused) */ - int32_t ssi_code; /* Signal code */ - uint32_t ssi_pid; /* PID of sender */ - uint32_t ssi_uid; /* Real UID of sender */ - int32_t ssi_fd; /* File descriptor (SIGIO) */ - uint32_t ssi_tid; /* Kernel timer ID (POSIX timers) */ - uint32_t ssi_band; /* Band event (SIGIO) */ - uint32_t ssi_overrun; /* POSIX timer overrun count */ - uint32_t ssi_trapno; /* Trap number that caused signal */ - int32_t ssi_status; /* Exit status or signal (SIGCHLD) */ - int32_t ssi_int; /* Integer sent by sigqueue(3) */ - uint64_t ssi_ptr; /* Pointer sent by sigqueue(3) */ - uint64_t ssi_utime; /* User CPU time consumed (SIGCHLD) */ - uint64_t ssi_stime; /* System CPU time consumed (SIGCHLD) */ - uint64_t ssi_addr; /* Address that generated signal (for hardware-generated signals) */ - uint16_t ssi_addr_lsb; /* Least significant bit of address (SIGBUS; since Linux 2.6.37) */ - uint16_t __pad2; - int32_t ssi_syscall; - uint64_t ssi_call_addr; - uint32_t ssi_arch; - uint8_t pad[28]; /* Pad size to 128 bytes */ -} signalfd_siginfo; - -typedef struct { - unsigned long int __val[1024 / (8 * sizeof (unsigned long int))]; -} __sigset_t; - -typedef __sigset_t sigset_t; - -int pthread_sigmask(int how, const sigset_t *set, sigset_t *oldset); -int sigemptyset(sigset_t *set); -int sigaddset(sigset_t *set, int signum); -int signalfd(int fd, const sigset_t *mask, int flags); -]] - -if ARCH == "mips" or ARCH == "mipsel" then - M.SIG_BLOCK = 1 - M.SIG_UNBLOCK = 2 - M.SIG_SETMASK = 3 -elseif ARCH == "x64" or ARCH == "arm64" or ARCH == "x86" then - M.SIG_BLOCK = 0 - M.SIG_UNBLOCK = 1 - M.SIG_SETMASK = 2 -end - -function M.sigemptyset(set) return wrap_error(ffi.C.sigemptyset(set)) end - -function M.sigaddset(set, signum) return wrap_error(ffi.C.sigaddset(set, signum)) end - -function M.signalfd(fd, mask, flags) return wrap_error(ffi.C.signalfd(fd, mask, flags)) end - -function M.pthread_sigmask(how, set, oldset) return wrap_error(libpthread.pthread_sigmask(how, set, oldset)) end - -function M.new_sigset() return ffi.new("sigset_t") end - -function M.new_fdsi() return ffi.new("signalfd_siginfo"), ffi.sizeof("signalfd_siginfo") end - -- Define syscall and pid_t ffi.cdef [[ long syscall(long number, ...); @@ -518,75 +290,4 @@ function M.pidfd_open(pid, flags) return wrap_error(ffi.tonumber(ffi.C.syscall(SYS_pidfd_open, pid, flags))) end --- Termios constants and baudrate support -M.TCSANOW = 0 -- Make changes now without waiting for data to complete -M.TCSADRAIN = 1 -- Wait until all output written to fildes is transmitted -M.TCSAFLUSH = 2 -- Flush input/output buffers and make the change - --- Baudrate constants -M.BAUDRATES = { - [1200] = 9, - [2400] = 11, - [4800] = 12, - [9600] = 13, - [19200] = 14, - [38400] = 15, - [57600] = 4097, - [115200] = 4098, - [230400] = 4099, - [460800] = 4100, - [500000] = 4101, - [576000] = 4102, - [921600] = 4103, - [1000000] = 4104, - [1152000] = 4105, - [1500000] = 4106, - [2000000] = 4107, - [2500000] = 4108, - [3000000] = 4109, - [3500000] = 4110, - [4000000] = 4111 -} - --- Define termios structs/functions for direct baudrate control -ffi.cdef [[ -typedef unsigned int speed_t; -typedef unsigned char cc_t; -typedef unsigned int tcflag_t; - -struct termios { - tcflag_t c_iflag; - tcflag_t c_oflag; - tcflag_t c_cflag; - tcflag_t c_lflag; - cc_t c_line; - cc_t c_cc[32]; - speed_t c_ispeed; - speed_t c_ospeed; -}; - -int tcgetattr(int fd, struct termios *termios_p); -int tcsetattr(int fd, int optional_actions, const struct termios *termios_p); -speed_t cfgetospeed(const struct termios *termios_p); -speed_t cfgetispeed(const struct termios *termios_p); -int cfsetospeed(struct termios *termios_p, speed_t speed); -int cfsetispeed(struct termios *termios_p, speed_t speed); -]] - -function M.new_termios() return ffi.new("struct termios") end - -function M.tcgetattr(fd, termios_p) return wrap_error(ffi.C.tcgetattr(fd, termios_p)) end - -function M.tcsetattr(fd, optional_actions, termios_p) - return wrap_error(ffi.C.tcsetattr(fd, optional_actions, termios_p)) -end - -function M.cfgetospeed(termios_p) return ffi.tonumber(ffi.C.cfgetospeed(termios_p)) end - -function M.cfgetispeed(termios_p) return ffi.tonumber(ffi.C.cfgetispeed(termios_p)) end - -function M.cfsetospeed(termios_p, speed) return wrap_error(ffi.C.cfsetospeed(termios_p, speed)) end - -function M.cfsetispeed(termios_p, speed) return wrap_error(ffi.C.cfsetispeed(termios_p, speed)) end - return M From 712678d3611ac28810bd3c9090c05824f4ba6d09 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sun, 30 Nov 2025 15:52:53 +0000 Subject: [PATCH 064/138] deletes uneeded epoll file --- src/fibers/io/epoll.lua | 125 ---------------------------------------- 1 file changed, 125 deletions(-) delete mode 100644 src/fibers/io/epoll.lua diff --git a/src/fibers/io/epoll.lua b/src/fibers/io/epoll.lua deleted file mode 100644 index 14275ca..0000000 --- a/src/fibers/io/epoll.lua +++ /dev/null @@ -1,125 +0,0 @@ --- (c) Snabb project --- (c) Jangala - --- Use of this source code is governed by the XXXXXXXXX license; see COPYING. - --- Epoll. ----@module 'fibers.io.epoll' - -local sc = require 'fibers.utils.syscall' -local bit = rawget(_G, "bit") or require 'bit32' - ---- Epoll handle and state. ----@class Epoll ----@field epfd integer # epoll file descriptor ----@field active_events table # fd -> current event mask ----@field maxevents integer # current maximum events per epoll_wait -local Epoll = {} - ----@type integer -local INITIAL_MAXEVENTS = 8 - ---- Create a new Epoll instance. ----@return Epoll -local function new() - local ret = { - epfd = assert(sc.epoll_create()), - active_events = {}, -- fd -> mask - maxevents = INITIAL_MAXEVENTS, - } - return setmetatable(ret, { __index = Epoll }) -end - ----@type integer -local RD = sc.EPOLLIN + sc.EPOLLRDHUP ----@type integer -local WR = sc.EPOLLOUT ----@type integer -local RDWR = RD + WR ----@type integer -local ERR = sc.EPOLLERR + sc.EPOLLHUP - ---- Add or modify interest for a file descriptor. ---- ---- The descriptor is registered with EPOLLONESHOT; after an event ---- fires it becomes inactive until re-armed via this method. ----@param s integer # file descriptor ----@param events integer # epoll event mask -function Epoll:add(s, events) - -- local fd = type(s) == 'number' and s or sc.fileno(s) - local fd = s - local active = self.active_events[fd] or 0 - local eventmask = bit.bor(events, active, sc.EPOLLONESHOT) - local ok, _ = sc.epoll_modify(self.epfd, fd, eventmask) - if not ok then - assert(sc.epoll_register(self.epfd, fd, eventmask)) - end - self.active_events[fd] = eventmask -end - ---- Wait for events. ---- ---- Returns a map of fd -> event mask. EINTR is treated as benign and ---- yields an empty table. ----@param timeout? integer # timeout in milliseconds (default 0) ----@return table events, string|nil err -function Epoll:poll(timeout) - -- Returns a table, an iterator would be more efficient. - local events, err, errno = sc.epoll_wait(self.epfd, timeout or 0, self.maxevents) - if not events then - -- Treat EINTR as a benign interruption and report no events. - if errno == sc.EINTR then - return {}, nil - end - -- Other errors are considered fatal at this level. - error(err) - end - local count = 0 - -- Since we add fd's with EPOLL_ONESHOT, now that the event has - -- fired, the fd is now deactivated. Record that fact. - for fd, _ in pairs(events) do - count = count + 1 - self.active_events[fd] = nil - end - if count == self.maxevents then - -- If we received `maxevents' events, it means that probably there - -- are more active fd's in the queue that we were unable to - -- receive. Expand our event buffer in that case. - self.maxevents = self.maxevents * 2 - end - return events, err -end - ---- Remove interest in a file descriptor. ---- ---- ENOENT/EBADF are treated as benign and only clear bookkeeping. ----@param fd integer -function Epoll:del(fd) - local ok, err, errno = sc.epoll_unregister(self.epfd, fd) - if not ok then - -- It is possible to see ENOENT/EBADF here if the fd was - -- already closed or never registered. Treat those as benign - -- and just clear our bookkeeping. - if errno == sc.ENOENT or errno == sc.EBADF then - self.active_events[fd] = nil - return - end - error(err) - end - self.active_events[fd] = nil -end - ---- Close the epoll instance. -function Epoll:close() - sc.epoll_close(self.epfd) - self.epfd = nil -end - -return { - new = new, - - RD = RD, - WR = WR, - RDWR = RDWR, - ERR = ERR, -} From 6e1db3d3025499191cac785939bb9cb075653888 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sun, 30 Nov 2025 15:55:44 +0000 Subject: [PATCH 065/138] corrects and cleans up tests for previous cases --- tests/test.lua | 2 +- tests/test_epoll.lua | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test.lua b/tests/test.lua index 5413f7a..f58a16e 100644 --- a/tests/test.lua +++ b/tests/test.lua @@ -19,7 +19,7 @@ local modules = { { 'queue' }, { 'cond' }, { 'sleep' }, - { 'epoll' }, + -- { 'epoll' }, { 'waitgroup' }, -- { 'alarm' }, { 'scope' }, diff --git a/tests/test_epoll.lua b/tests/test_epoll.lua index 22aeab3..9db90d7 100644 --- a/tests/test_epoll.lua +++ b/tests/test_epoll.lua @@ -4,7 +4,7 @@ print('testing: fibers.epoll') -- look one level up package.path = "../src/?.lua;" .. package.path -local epoll = require 'fibers.epoll' +local epoll = require 'fibers.io.epoll' local sc = require 'fibers.utils.syscall' local bit = rawget(_G, "bit") or require 'bit32' From 77a117ac9a2121460b53ec34096ab9120de4d2bd Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sun, 30 Nov 2025 16:02:35 +0000 Subject: [PATCH 066/138] corrects type annotations --- src/fibers/io/socket.lua | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/fibers/io/socket.lua b/src/fibers/io/socket.lua index a095e93..6e4bd5d 100644 --- a/src/fibers/io/socket.lua +++ b/src/fibers/io/socket.lua @@ -26,14 +26,11 @@ local perform = require 'fibers.performer'.perform ---@class Socket ---@field fd integer|nil ----@field listen_unix fun(self: Socket, path: string): (boolean|nil, string|nil) ----@field accept_op fun(self: Socket): Op ----@field accept fun(self: Socket): (Stream|nil, string|nil) ----@field connect_op fun(self: Socket, sa: any): Op ----@field connect fun(self: Socket, sa: any): (Stream|nil, string|nil) ----@field connect_unix_op fun(self: Socket, path: string): Op ----@field connect_unix fun(self: Socket, path: string): (Stream|nil, string|nil) ----@field close fun(self: Socket): (boolean, string|nil) +---@field accept fun(self: Socket): (boolean|nil), any +---@field listen_unix fun(self: Socket, path: string): (boolean|nil), any +---@field connect_unix fun(self: Socket, path: string): (Stream|nil), any +---@field close fun(self: Socket): boolean, any + local Socket = {} Socket.__index = Socket From 446e5d05c417a84ac96d0bef307b536a0e71a496 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Thu, 4 Dec 2025 21:47:28 +0000 Subject: [PATCH 067/138] moves fd and poller to new shapes --- src/fibers/io/fd_backend.lua | 17 +- src/fibers/io/fd_backend/core.lua | 189 ++++++++++++++++++++++ src/fibers/io/fd_backend/ffi.lua | 244 +++++++++-------------------- src/fibers/io/fd_backend/posix.lua | 204 ++++++++---------------- src/fibers/io/poller/core.lua | 182 +++++++++++++++++++++ src/fibers/io/poller/epoll.lua | 146 ++++++----------- src/fibers/io/poller/select.lua | 133 +++++----------- 7 files changed, 599 insertions(+), 516 deletions(-) create mode 100644 src/fibers/io/fd_backend/core.lua create mode 100644 src/fibers/io/poller/core.lua diff --git a/src/fibers/io/fd_backend.lua b/src/fibers/io/fd_backend.lua index 91a4b1e..ab3b46c 100644 --- a/src/fibers/io/fd_backend.lua +++ b/src/fibers/io/fd_backend.lua @@ -5,24 +5,11 @@ -- 1. FFI-based (no luaposix dependency), -- 2. luaposix-based (no FFI dependency). -- --- Backend contract towards fibers.io.stream: --- * kind() -> "fd" --- * fileno() -> fd --- * read_string(max) -> str|nil, err|nil --- - str == nil : would block --- - str == "" : EOF --- * write_string(str) -> n|nil, err|nil --- - n == nil : would block --- * on_readable(task) -> token{ unlink = fn } --- * on_writable(task) -> token{ unlink = fn } --- * close() -> ok, err|nil --- * seek(whence, off) -> pos|nil, err|nil --- - whence: "set" | "cur" | "end" ---@module 'fibers.io.fd_backend' local candidates = { - 'fibers.io.fd_backend.ffi', -- FFI / libc - -- 'fibers.io.fd_backend.posix', -- luaposix + 'fibers.io.fd_backend.ffi', -- FFI / libc + 'fibers.io.fd_backend.posix', -- luaposix } for _, name in ipairs(candidates) do diff --git a/src/fibers/io/fd_backend/core.lua b/src/fibers/io/fd_backend/core.lua new file mode 100644 index 0000000..64ae2e8 --- /dev/null +++ b/src/fibers/io/fd_backend/core.lua @@ -0,0 +1,189 @@ +-- fibers/io/fd_backend/core.lua +-- +-- Core glue for fd-backed StreamBackend implementations. +-- +-- This module owns the public FdBackend shape and semantics. +-- Platform backends provide only low-level primitives; build_backend +-- wires those into a concrete { new, is_supported } module. +-- +---@module 'fibers.io.fd_backend.core' + +local poller = require 'fibers.io.poller' + +---@class FdBackend +---@field filename string|nil -- optional filename for diagnostics +---@field _fd integer|nil -- underlying OS file descriptor (or nil if closed) +---@field _ops table -- low-level operations table (see build_backend) + +local FdBackend = {} +FdBackend.__index = FdBackend + +---------------------------------------------------------------------- +-- Public methods (contract lives here) +---------------------------------------------------------------------- + +--- Backend kind identifier. +---@return '"fd"' +function FdBackend:kind() + return "fd" +end + +--- Underlying file descriptor number, or nil if closed. +---@return integer|nil +function FdBackend:fileno() + return self._fd +end + +--- Read up to max bytes as a Lua string. +--- +--- Semantics: +--- * s == nil, err == nil : would block +--- * s == nil, err ~= nil : hard error +--- * s == "" : EOF +--- * s ~= "" : data +--- +---@param max? integer +---@return string|nil s, string|nil err +function FdBackend:read_string(max) + if not self._fd then + return nil, "closed" + end + + max = max or 4096 + if max <= 0 then + -- Matches existing posix/ffi backends. + return "", nil + end + + return self._ops.read(self._fd, max) +end + +--- Write a Lua string. +--- +--- Semantics: +--- * n == nil, err == nil : would block +--- * n == nil, err ~= nil : hard error +--- * n >= 0 : bytes written +--- +---@param str string +---@return integer|nil n, string|nil err +function FdBackend:write_string(str) + if not self._fd then + return nil, "closed" + end + + local len = #str + if len == 0 then + -- Existing behaviour: zero-length write is a cheap no-op. + return 0, nil + end + + return self._ops.write(self._fd, str, len) +end + +--- Seek within the file descriptor. +--- +--- whence: "set" | "cur" | "end" +---@param whence '"set"'|'"cur"'|'"end"' +---@param off integer +---@return integer|nil pos, string|nil err +function FdBackend:seek(whence, off) + if not self._fd then + return nil, "closed" + end + return self._ops.seek(self._fd, whence, off) +end + +--- Register for readability events on this fd. +---@param task Task +---@return WaitToken +function FdBackend:on_readable(task) + return poller.get():wait(assert(self._fd, "closed fd"), "rd", task) +end + +--- Register for writability events on this fd. +---@param task Task +---@return WaitToken +function FdBackend:on_writable(task) + return poller.get():wait(assert(self._fd, "closed fd"), "wr", task) +end + +--- Close the backend and underlying fd. +--- +--- Returns: +--- ok : true on success, false on failure +--- err : error string or nil +--- +---@return boolean ok, string|nil err +function FdBackend:close() + if self._fd == nil then + return true, nil + end + + local fd = self._fd + self._fd = nil + + -- Matches old behaviour: fd is considered closed from the Lua side + -- even if close() reports an error. + return self._ops.close(fd) +end + +---------------------------------------------------------------------- +-- Backend builder +---------------------------------------------------------------------- + +--- Build a concrete fd backend module from low-level ops. +--- +--- ops must provide: +--- set_nonblock(fd) -> ok, err|nil, errno|nil +--- read(fd, max) -> s|nil, err|nil +--- write(fd, str, len) -> n|nil, err|nil +--- seek(fd, whence, off) -> pos|nil, err|nil +--- close(fd) -> ok, err|nil +--- +--- ops.is_supported() -> boolean (optional) +--- +---@param ops table +---@return table backend_module -- { new = fn, is_supported = fn } +local function build_backend(ops) + local required = { "set_nonblock", "read", "write", "seek", "close" } + for _, k in ipairs(required) do + assert(type(ops[k]) == "function", + "fd_backend ops." .. k .. " must be a function") + end + + local function new(fd, opts) + opts = opts or {} + + if fd ~= nil then + local ok, err = ops.set_nonblock(fd) + if not ok then + error("fd_backend: set_nonblock(" .. tostring(fd) .. ") failed: " + .. tostring(err)) + end + end + + local self = { + _fd = fd, + _ops = ops, + filename = opts.filename, + } + return setmetatable(self, FdBackend) + end + + local function is_supported() + if type(ops.is_supported) == "function" then + return not not ops.is_supported() + end + return true + end + + return { + new = new, + is_supported = is_supported, + } +end + +return { + build_backend = build_backend, +} diff --git a/src/fibers/io/fd_backend/ffi.lua b/src/fibers/io/fd_backend/ffi.lua index 598e34f..3b7bcfd 100644 --- a/src/fibers/io/fd_backend/ffi.lua +++ b/src/fibers/io/fd_backend/ffi.lua @@ -2,19 +2,19 @@ -- -- FFI-based FD backend (no luaposix / syscall dependency). -- Intended to be selected via fibers.io.fd_backend. - +-- ---@module 'fibers.io.fd_backend.ffi' -local poller = require 'fibers.io.poller' -local ffi_c = require 'fibers.utils.ffi_compat' +local core = require 'fibers.io.fd_backend.core' +local ffi_c = require 'fibers.utils.ffi_compat' if not ffi_c.is_supported() then return { is_supported = function() return false end } end -local ffi = ffi_c.ffi -local C = ffi_c.C -local toint = ffi_c.tonumber +local ffi = ffi_c.ffi +local C = ffi_c.C +local toint = ffi_c.tonumber local get_errno = ffi_c.errno local ok_bit, bit_mod = pcall(function() @@ -37,11 +37,11 @@ ffi.cdef[[ char *strerror(int errnum); ]] --- POSIX fcntl command numbers are stable (3/4) on Linux. +-- POSIX fcntl command numbers on Linux. local F_GETFL = 3 local F_SETFL = 4 --- Default Linux O_NONBLOCK +-- Linux O_NONBLOCK (matches luaposix O_NONBLOCK on this platform). local O_NONBLOCK = 0x00000800 -- Errno values: EAGAIN/EWOULDBLOCK are both 11 on Linux. @@ -50,7 +50,9 @@ local EWOULDBLOCK = 11 local function strerror(e) local s = C.strerror(e) - if s == nil then return "errno " .. tostring(e) end + if s == nil then + return "errno " .. tostring(e) + end return ffi.string(s) end @@ -58,37 +60,34 @@ end -- fcntl helpers (casted to avoid varargs issues) ---------------------------------------------------------------------- -local getfl_fp = ffi.cast("int (*)(int, int)", C.fcntl) -local setfl_fp = ffi.cast("int (*)(int, int, int)", C.fcntl) +local getfl_fp = ffi.cast("int (*)(int, int)", C.fcntl) +local setfl_fp = ffi.cast("int (*)(int, int, int)", C.fcntl) local function set_nonblock(fd) - local before = getfl_fp(fd, F_GETFL) - local before_l = toint(before) - if before_l < 0 then + local before = toint(getfl_fp(fd, F_GETFL)) + if before < 0 then local e = get_errno() return false, ("F_GETFL failed: %s"):format(strerror(e)), e end - local new_flags = bit.bor(before_l, O_NONBLOCK) - local rc = setfl_fp(fd, F_SETFL, new_flags) - local rc_l = toint(rc) - if rc_l < 0 then + local new_flags = bit.bor(before, O_NONBLOCK) + local rc = toint(setfl_fp(fd, F_SETFL, new_flags)) + if rc < 0 then local e = get_errno() return false, ("F_SETFL failed: %s"):format(strerror(e)), e end - -- Sanity check: did the kernel actually set O_NONBLOCK? - local after = getfl_fp(fd, F_GETFL) - local after_l = toint(after) - if after_l < 0 then + -- Sanity check. + local after = toint(getfl_fp(fd, F_GETFL)) + if after < 0 then local e = get_errno() return false, ("F_GETFL (post) failed: %s"):format(strerror(e)), e end - if bit.band(after_l, O_NONBLOCK) == 0 then + if bit.band(after, O_NONBLOCK) == 0 then return false, ("set_nonblock: O_NONBLOCK not set after F_SETFL; before=0x%x after=0x%x") - :format(before_l, after_l), + :format(before, after), nil end @@ -96,174 +95,81 @@ local function set_nonblock(fd) end ---------------------------------------------------------------------- --- FdBackend implementation +-- Low-level ops implementing the core contract ---------------------------------------------------------------------- ----@class FdBackend : StreamBackend ----@field filename string|nil +local SEEK = { set = 0, cur = 1, ["end"] = 2 } ----@param fd integer|nil ----@param opts? { filename?: string } ----@return FdBackend -local function new(fd, opts) - opts = opts or {} +local function read_fd(fd, max) + -- max > 0 and fd non-nil guaranteed by core. + local buf = ffi.new("char[?]", max) + local n = toint(C.read(fd, buf, max)) - if fd ~= nil then - local ok, err = set_nonblock(fd) - if not ok then - error("fd_backend.ffi: set_nonblock(" .. tostring(fd) .. ") failed: " - .. tostring(err)) + if n < 0 then + local e = get_errno() + if e == EAGAIN or e == EWOULDBLOCK then + return nil, nil -- would block end + return nil, strerror(e) end - ---@class FdBackend - local B = { - filename = opts.filename, - } - - function B:kind() - return "fd" + if n == 0 then + return "", nil -- EOF end - function B:fileno() - return fd + if n > max then + return nil, "read returned " .. tostring(n) .. " bytes (max " .. tostring(max) .. ")" end - -------------------------------------------------------------------- - -- String-oriented I/O - -------------------------------------------------------------------- - - ---@param max? integer - ---@return string|nil s, string|nil err - function B:read_string(max) - if not fd then - return nil, "closed" - end - - max = max or 4096 - if max <= 0 then - return "", nil - end - - local buf = ffi.new("char[?]", max) - local n = C.read(fd, buf, max) - local n_l = toint(n) - - if n_l < 0 then - local e = get_errno() - if e == EAGAIN or e == EWOULDBLOCK then - return nil, nil -- would block - end - return nil, strerror(e) - end - - if n_l == 0 then - return "", nil -- EOF - end - - if n_l > max then - return nil, "read returned " .. tostring(n_l) .. " bytes (max " .. tostring(max) .. ")" - end - - return ffi.string(buf, n_l), nil - end - - ---@param str string - ---@return integer|nil n, string|nil err - function B:write_string(str) - if not fd then - return nil, "closed" - end - - local len = #str - if len == 0 then - return 0, nil - end - - local buf = ffi.new("char[?]", len) - ffi.copy(buf, str, len) - - local n = C.write(fd, buf, len) - local n_l = toint(n) - - if n_l < 0 then - local e = get_errno() - if e == EAGAIN or e == EWOULDBLOCK then - return nil, nil -- would block - end - return nil, strerror(e) - end - - return n_l, nil - end - - -------------------------------------------------------------------- - -- Seek - -------------------------------------------------------------------- - - local SEEK = { set = 0, cur = 1, ["end"] = 2 } - - ---@param whence '"set"'|'"cur"'|'"end"' - ---@param off integer - ---@return integer|nil pos, string|nil err - function B:seek(whence, off) - if not fd then - return nil, "closed" - end + return ffi.string(buf, n), nil +end - local w = SEEK[whence] - if not w then - return nil, "bad whence: " .. tostring(whence) - end +local function write_fd(fd, str, len) + -- len > 0 and fd non-nil guaranteed by core. + local buf = ffi.new("char[?]", len) + ffi.copy(buf, str, len) - local res = C.lseek(fd, off, w) - local res_l = toint(res) - if res_l < 0 then - return nil, strerror(get_errno()) + local n = toint(C.write(fd, buf, len)) + if n < 0 then + local e = get_errno() + if e == EAGAIN or e == EWOULDBLOCK then + return nil, nil -- would block end - - return res_l, nil + return nil, strerror(e) end - -------------------------------------------------------------------- - -- Readiness registration - -------------------------------------------------------------------- + return n, nil +end - function B:on_readable(task) - return poller.get():wait(assert(fd, "closed fd"), "rd", task) +local function seek_fd(fd, whence, off) + local w = SEEK[whence] + if not w then + return nil, "bad whence: " .. tostring(whence) end - function B:on_writable(task) - return poller.get():wait(assert(fd, "closed fd"), "wr", task) + local res = toint(C.lseek(fd, off, w)) + if res < 0 then + return nil, strerror(get_errno()) end - -------------------------------------------------------------------- - -- Lifecycle - -------------------------------------------------------------------- - - ---@return boolean ok, string|nil err - function B:close() - if fd == nil then - return true, nil - end - - local rc = C.close(fd) - fd = nil - if toint(rc) ~= 0 then - return false, strerror(get_errno()) - end - return true, nil - end - - return B + return res, nil end -local function is_supported() - -- We already gated on ffi_compat + bit; this backend is intended for Linux. - return true +local function close_fd(fd) + local rc = toint(C.close(fd)) + if rc ~= 0 then + return false, strerror(get_errno()) + end + return true, nil end -return { - new = new, - is_supported = is_supported, +local ops = { + set_nonblock = set_nonblock, + read = read_fd, + write = write_fd, + seek = seek_fd, + close = close_fd, + is_supported = function() return true end, } + +return core.build_backend(ops) diff --git a/src/fibers/io/fd_backend/posix.lua b/src/fibers/io/fd_backend/posix.lua index 3253567..16d7153 100644 --- a/src/fibers/io/fd_backend/posix.lua +++ b/src/fibers/io/fd_backend/posix.lua @@ -1,20 +1,11 @@ -- fibers/io/fd_backend/posix.lua -- -- luaposix-based FD backend for files/sockets. +-- Intended as a fallback where FFI is unavailable or undesired. -- --- Backend contract towards fibers.io.stream: --- * kind() -> "fd" --- * fileno() -> fd --- * read_string(max) -> str|nil, err|nil --- * write_string(str) -> n|nil, err|nil --- * on_readable(task) -> token{ unlink = fn } --- * on_writable(task) -> token{ unlink = fn } --- * close() -> ok, err|nil --- * seek(whence, off) -> pos|nil, err|nil - ---@module 'fibers.io.fd_backend.posix' -local poller = require 'fibers.io.poller' +local core = require 'fibers.io.fd_backend.core' local ok_unistd, unistd = pcall(require, 'posix.unistd') local ok_fcntl, fcntl = pcall(require, 'posix.fcntl') @@ -28,12 +19,16 @@ end local bit = rawget(_G, "bit") or require 'bit32' -local EAGAIN = errno.EAGAIN +local EAGAIN = errno.EAGAIN local EWOULDBLOCK = errno.EWOULDBLOCK or errno.EAGAIN -local SEEK_SET = fcntl.SEEK_SET or 0 -local SEEK_CUR = fcntl.SEEK_CUR or 1 -local SEEK_END = fcntl.SEEK_END or 2 +local SEEK_SET = fcntl.SEEK_SET or 0 +local SEEK_CUR = fcntl.SEEK_CUR or 1 +local SEEK_END = fcntl.SEEK_END or 2 + +---------------------------------------------------------------------- +-- Non-blocking helper +---------------------------------------------------------------------- local function set_nonblock(fd) local flags, err, en = fcntl.fcntl(fd, fcntl.F_GETFL) @@ -41,154 +36,85 @@ local function set_nonblock(fd) return nil, err or ("fcntl(F_GETFL) errno " .. tostring(en)), en end - local newflags = bit.bor(flags, fcntl.O_NONBLOCK) - local ok, err2, en2 = fcntl.fcntl(fd, fcntl.F_SETFL, newflags) - if ok == nil then + local newflags = bit.bor(flags, fcntl.O_NONBLOCK) + local ok2, err2, en2 = fcntl.fcntl(fd, fcntl.F_SETFL, newflags) + if ok2 == nil then return nil, err2 or ("fcntl(F_SETFL) errno " .. tostring(en2)), en2 end return true, nil, nil end ---- FD-backed stream backend for use with fibers.io.stream. ----@class FdBackend : StreamBackend ----@field filename string|nil - ---- Create a new FD backend instance. ----@param fd integer|nil ----@param opts? { filename?: string } ----@return FdBackend -local function new(fd, opts) - opts = opts or {} - - if fd ~= nil then - local ok, err = set_nonblock(fd) - if not ok then - error("set_nonblock failed: " .. tostring(err)) - end - end - - ---@class FdBackend - local B = { - filename = opts.filename, - } - - function B:kind() - return "fd" - end - - function B:fileno() - return fd - end +---------------------------------------------------------------------- +-- Low-level ops implementing the core contract +---------------------------------------------------------------------- - -------------------------------------------------------------------- - -- String-oriented I/O - -------------------------------------------------------------------- - - function B:read_string(max) - if not fd then - return nil, "closed" - end - - max = max or 4096 - if max <= 0 then - return "", nil - end +local SEEK = { + set = SEEK_SET, + cur = SEEK_CUR, + ["end"] = SEEK_END, +} - local s, err, en = unistd.read(fd, max) - if s == nil then - if en == EAGAIN or en == EWOULDBLOCK then - -- Would block. - return nil, nil - end - return nil, err or ("errno " .. tostring(en)) +local function read_fd(fd, max) + -- max > 0 and fd non-nil guaranteed by core. + local s, err, en = unistd.read(fd, max) + if s == nil then + if en == EAGAIN or en == EWOULDBLOCK then + return nil, nil -- would block end - - -- s may be "" at EOF; that is fine. - return s, nil + return nil, err or ("errno " .. tostring(en)) end - function B:write_string(str) - if not fd then - return nil, "closed" - end + -- s may be "" at EOF. + return s, nil +end - local n, err, en = unistd.write(fd, str) - if n == nil then - if en == EAGAIN or en == EWOULDBLOCK then - -- Would block. - return nil, nil - end - return nil, err or ("errno " .. tostring(en)) +local function write_fd(fd, str, _len) + local n, err, en = unistd.write(fd, str) + if n == nil then + if en == EAGAIN or en == EWOULDBLOCK then + return nil, nil -- would block end - - return n, nil + return nil, err or ("errno " .. tostring(en)) end - -------------------------------------------------------------------- - -- Seek (used by Stream:seek) - -------------------------------------------------------------------- - - local SEEK = { - set = SEEK_SET, - cur = SEEK_CUR, - ["end"] = SEEK_END, - } - - function B:seek(whence, off) - if not fd then - return nil, "closed" - end - - local w = SEEK[whence] - if not w then - return nil, "bad whence: " .. tostring(whence) - end + return n, nil +end - local pos, err, en = unistd.lseek(fd, off, w) - if pos == nil then - return nil, err or ("errno " .. tostring(en)) - end - return pos, nil +local function seek_fd(fd, whence, off) + local w = SEEK[whence] + if not w then + return nil, "bad whence: " .. tostring(whence) end - -------------------------------------------------------------------- - -- Readiness registration (for waitable/poller) - -------------------------------------------------------------------- - - function B:on_readable(task) - return poller.get():wait(assert(fd, "closed fd"), "rd", task) + local pos, err, en = unistd.lseek(fd, off, w) + if pos == nil then + return nil, err or ("errno " .. tostring(en)) end + return pos, nil +end - function B:on_writable(task) - return poller.get():wait(assert(fd, "closed fd"), "wr", task) +local function close_fd(fd) + local ok, err, en = unistd.close(fd) + if ok == nil or ok == false then + return false, err or ("errno " .. tostring(en)) end + return true, nil +end - -------------------------------------------------------------------- - -- Lifecycle - -------------------------------------------------------------------- - - function B:close() - if fd == nil then - return true, nil - end - - local ok, err, en = unistd.close(fd) - fd = nil - if ok == nil or ok == false then - return false, err or ("errno " .. tostring(en)) - end - return true, nil - end +local ops = { + set_nonblock = set_nonblock, + read = read_fd, + write = write_fd, + seek = seek_fd, + close = close_fd, +} - return B -end +local backend = core.build_backend(ops) -local function is_supported() +-- Preserve explicit is_supported for symmetry with ffi backend. +backend.is_supported = function() return true end -return { - new = new, - is_supported = is_supported, -} +return backend diff --git a/src/fibers/io/poller/core.lua b/src/fibers/io/poller/core.lua new file mode 100644 index 0000000..f6b55f2 --- /dev/null +++ b/src/fibers/io/poller/core.lua @@ -0,0 +1,182 @@ +-- fibers/io/poller/core.lua +-- +-- Core glue for poller backends. +-- +-- This module owns the public Poller shape and semantics. +-- Platform backends provide only low-level primitives; build_poller +-- wires those into a concrete { get, Poller, is_supported } module. +-- +-- Backend ops contract: +-- ops.new_backend() -> backend_state +-- ops.poll(backend_state, timeout_ms, rd_waitset, wr_waitset) -> events +-- events is a table: events[fd] = { rd = bool, wr = bool, err = bool } +-- ops.on_wait_change(backend_state, fd, want_rd, want_wr) -- optional +-- ops.close_backend(backend_state) -- optional +-- ops.is_supported() -> boolean -- optional +-- +---@module 'fibers.io.poller.core' + +local runtime = require 'fibers.runtime' +local wait = require 'fibers.wait' + +---@class Poller : TaskSource +---@field backend_state any +---@field rd Waitset +---@field wr Waitset +---@field _ops table +local Poller = {} +Poller.__index = Poller + +---------------------------------------------------------------------- +-- Internal helpers +---------------------------------------------------------------------- + +local function recompute_mask(self, fd) + local ops = self._ops + if not ops.on_wait_change then + return + end + local want_rd = not self.rd:is_empty(fd) + local want_wr = not self.wr:is_empty(fd) + ops.on_wait_change(self.backend_state, fd, want_rd, want_wr) +end + +local function seconds_to_ms(timeout) + if timeout == nil then + -- Non-blocking poll. + return 0 + elseif timeout < 0 then + -- “Infinite” block. + return -1 + else + return math.floor(timeout * 1e3 + 0.5) + end +end + +---------------------------------------------------------------------- +-- Public methods +---------------------------------------------------------------------- + +--- Register a task as waiting on an fd for read or write readiness. +---@param fd integer +---@param dir '"rd"'|'"wr"' +---@param task Task +---@return WaitToken +function Poller:wait(fd, dir, task) + assert(type(fd) == "number", "fd must be number") + assert(dir == "rd" or dir == "wr", "dir must be 'rd' or 'wr'") + + local ws = (dir == "rd") and self.rd or self.wr + local token = ws:add(fd, task) + + -- Update backend subscription / mask. + recompute_mask(self, fd) + + -- Wrap unlink to keep backend state in sync with waitsets. + local original_unlink = token.unlink + local owner = self + + function token.unlink(tok) + local emptied = original_unlink(tok) + if emptied then + recompute_mask(owner, fd) + end + return emptied + end + + return token +end + +--- TaskSource hook: wait for events and schedule ready tasks. +---@param sched Scheduler +---@param _ number|nil -- current monotonic time (unused) +---@param timeout number|nil -- seconds +function Poller:schedule_tasks(sched, _, timeout) + local ops = self._ops + + local timeout_ms = seconds_to_ms(timeout) + local events = ops.poll(self.backend_state, timeout_ms, self.rd, self.wr) + if not events then + -- Backend chose to do nothing (e.g. no fds registered). + return + end + + for fd, flags in pairs(events) do + if flags.rd or flags.err then + self.rd:notify_all(fd, sched) + end + if flags.wr or flags.err then + self.wr:notify_all(fd, sched) + end + + -- Re-arm / update backend subscription after delivering events. + recompute_mask(self, fd) + end +end + +-- Used by the scheduler. +Poller.wait_for_events = Poller.schedule_tasks + +function Poller:close() + if self.backend_state and self._ops.close_backend then + self._ops.close_backend(self.backend_state) + end + self.backend_state = nil +end + +---------------------------------------------------------------------- +-- Builder +---------------------------------------------------------------------- + +--- Build a concrete poller module from low-level ops. +--- +--- See top-of-file comment for ops contract. +--- +---@param ops table +---@return table poller_module -- { get = fn, Poller = Poller, is_supported = fn } +local function build_poller(ops) + assert(type(ops) == "table", "poller ops must be a table") + assert(type(ops.new_backend) == "function", "ops.new_backend must be a function") + assert(type(ops.poll) == "function", "ops.poll must be a function") + + local function new_poller() + local backend_state = ops.new_backend() + local self = { + backend_state = backend_state, + rd = wait.new_waitset(), + wr = wait.new_waitset(), + _ops = ops, + } + return setmetatable(self, Poller) + end + + local singleton + + local function get() + if singleton then + return singleton + end + singleton = new_poller() + local sched = runtime.current_scheduler + assert(sched.add_task_source, "scheduler must implement add_task_source") + sched:add_task_source(singleton) + return singleton + end + + local function is_supported() + if type(ops.is_supported) == "function" then + return not not ops.is_supported() + end + return true + end + + return { + get = get, + Poller = Poller, + is_supported = is_supported, + } +end + +return { + build_poller = build_poller, +} diff --git a/src/fibers/io/poller/epoll.lua b/src/fibers/io/poller/epoll.lua index d548c3a..101a691 100644 --- a/src/fibers/io/poller/epoll.lua +++ b/src/fibers/io/poller/epoll.lua @@ -1,15 +1,15 @@ -- fibers/io/poller/epoll.lua -- -- Linux epoll-based poller backend. --- Supports LuaJIT (ffi) and PUC Lua with cffi. +-- Supports LuaJIT (ffi) and PUC Lua with cffi via ffi_compat. -- Intended to be selected via fibers.io.poller. +-- +---@module 'fibers.io.poller.epoll' -local runtime = require 'fibers.runtime' -local wait = require 'fibers.wait' -local safe = require 'coxpcall' - -local bit = rawget(_G, "bit") or require 'bit32' -local ffi_c = require 'fibers.utils.ffi_compat' +local core = require 'fibers.io.poller.core' +local safe = require 'coxpcall' +local bit = rawget(_G, "bit") or require 'bit32' +local ffi_c = require 'fibers.utils.ffi_compat' ---------------------------------------------------------------------- -- FFI / CFFI setup via ffi_compat @@ -31,7 +31,7 @@ local EBADF = 9 local ARCH = ffi.arch or ((jit and jit.arch) or "x64") ---------------------------------------------------------------------- --- Low-level epoll bindings (local to this file) +-- Low-level epoll bindings ---------------------------------------------------------------------- ffi.cdef[[ @@ -145,7 +145,7 @@ end local function epoll_wait(epfd, timeout_ms, max_events) local events = ffi.new("struct epoll_event[?]", max_events) - local n = C.epoll_wait(epfd, events, max_events, timeout_ms) + local n = C.epoll_wait(epfd, events, max_events, timeout_ms or 0) if n == -1 then local errno = get_errno() if errno == EINTR then @@ -174,7 +174,7 @@ end -- Epoll wrapper object ---------------------------------------------------------------------- ----@class Epoll +---@class EpollState ---@field epfd integer ---@field active_events table ---@field maxevents integer @@ -209,7 +209,7 @@ end function Epoll:poll(timeout_ms) local events, err, errno = epoll_wait(self.epfd, timeout_ms or 0, self.maxevents) if not events then - error(err or "epoll_wait failed (errno " .. tostring(errno) .. ")") + error(err or ("epoll_wait failed (errno " .. tostring(errno) .. ")")) end local count = 0 @@ -222,7 +222,7 @@ function Epoll:poll(timeout_ms) self.maxevents = self.maxevents * 2 end - return events, nil + return events end function Epoll:del(fd) @@ -233,7 +233,7 @@ function Epoll:del(fd) self.active_events[fd] = nil return end - error(err or "epoll_ctl(DEL) failed (errno " .. tostring(errno) .. ")") + error(err or ("epoll_ctl(DEL) failed (errno " .. tostring(errno) .. ")")) end self.active_events[fd] = nil end @@ -244,106 +244,44 @@ function Epoll:close() end ---------------------------------------------------------------------- --- Poller TaskSource built on Epoll +-- Backend ops for poller.core ---------------------------------------------------------------------- ----@class Poller : TaskSource ----@field ep Epoll ----@field rd Waitset ----@field wr Waitset -local Poller = {} -Poller.__index = Poller - -local function new_poller() - return setmetatable({ - ep = new_epoll(), - rd = wait.new_waitset(), - wr = wait.new_waitset(), - }, Poller) +local function new_backend() + return new_epoll() end -local function recompute_mask(self, fd) - local need_rd = not self.rd:is_empty(fd) - local need_wr = not self.wr:is_empty(fd) +local function on_wait_change(ep, fd, want_rd, want_wr) local mask = 0 - if need_rd then mask = bit.bor(mask, RD) end - if need_wr then mask = bit.bor(mask, WR) end + if want_rd then mask = bit.bor(mask, RD) end + if want_wr then mask = bit.bor(mask, WR) end if mask ~= 0 then - self.ep:add(fd, mask) + ep:add(fd, mask) else - self.ep:del(fd) - end -end - ---- Register a task as waiting on an fd for read or write readiness. ----@param fd integer ----@param dir '"rd"'|'"wr"' ----@param task Task ----@return WaitToken -function Poller:wait(fd, dir, task) - assert(type(fd) == 'number', "fd must be number") - assert(dir == "rd" or dir == "wr", "dir must be 'rd' or 'wr'") - - local ws = (dir == "rd") and self.rd or self.wr - local token = ws:add(fd, task) - - recompute_mask(self, fd) - - local original_unlink = token.unlink - local owner = self - - function token.unlink(tok) - local emptied = original_unlink(tok) - if emptied then - recompute_mask(owner, fd) - end - return emptied + ep:del(fd) end - - return token end ---- TaskSource hook: poll epoll and schedule ready tasks. ----@param sched Scheduler ----@param _ number|nil ----@param timeout number|nil -- seconds -function Poller:schedule_tasks(sched, _, timeout) - if timeout == nil then timeout = 0 end - if timeout >= 0 then timeout = timeout * 1e3 end - - local events = self.ep:poll(timeout) - for fd, ev in pairs(events) do - if bit.band(ev, RD + ERR) ~= 0 then - self.rd:notify_all(fd, sched) - end - if bit.band(ev, WR + ERR) ~= 0 then - self.wr:notify_all(fd, sched) - end - recompute_mask(self, fd) +local function poll_backend(ep, timeout_ms, _rd_ws, _wr_ws) + -- ep:poll already returns fd -> epoll event bits. + local evmap = ep:poll(timeout_ms) + local events = {} + + for fd, ev in pairs(evmap) do + local flags = { + rd = bit.band(ev, RD + ERR) ~= 0, + wr = bit.band(ev, WR + ERR) ~= 0, + err = bit.band(ev, ERR) ~= 0, + } + events[fd] = flags end -end - -Poller.wait_for_events = Poller.schedule_tasks -function Poller:close() - self.ep:close() - self.ep = nil + return events end ----------------------------------------------------------------------- --- Singleton and capability probe ----------------------------------------------------------------------- - -local singleton - -local function get() - if singleton then return singleton end - singleton = new_poller() - local sched = runtime.current_scheduler - assert(sched.add_task_source, "scheduler must implement add_task_source") - sched:add_task_source(singleton) - return singleton +local function close_backend(ep) + ep:close() end local function is_supported() @@ -354,8 +292,12 @@ local function is_supported() return ok end -return { - get = get, - Poller = Poller, - is_supported = is_supported, +local ops = { + new_backend = new_backend, + on_wait_change = on_wait_change, + poll = poll_backend, + close_backend = close_backend, + is_supported = is_supported, } + +return core.build_poller(ops) diff --git a/src/fibers/io/poller/select.lua b/src/fibers/io/poller/select.lua index 74ccb07..00b8692 100644 --- a/src/fibers/io/poller/select.lua +++ b/src/fibers/io/poller/select.lua @@ -2,61 +2,39 @@ -- -- posix.poll()-based poller backend (no epoll required). -- Intended to be selected via fibers.io.poller. - +-- ---@module 'fibers.io.poller.select' -local runtime = require 'fibers.runtime' -local wait = require 'fibers.wait' +local core = require 'fibers.io.poller.core' -- Try to load luaposix poll support. local ok, poll_mod = pcall(require, 'posix.poll') if not ok or type(poll_mod) ~= "table" or type(poll_mod.poll) ~= "function" then - -- Backend is present but unusable on this platform. return { is_supported = function() return false end, } end +local errno_mod = require 'posix.errno' local poll_fn = poll_mod.poll ----@class Waitset ----@field buckets table - ----@class SelectPoller : TaskSource ----@field rd Waitset # fd -> tasks waiting for read ----@field wr Waitset # fd -> tasks waiting for write -local SelectPoller = {} -SelectPoller.__index = SelectPoller - -local function new_poller() - return setmetatable({ - rd = wait.new_waitset(), - wr = wait.new_waitset(), - }, SelectPoller) -end +---------------------------------------------------------------------- +-- Backend ops for poller.core +---------------------------------------------------------------------- ---- Register a task as waiting on an fd for read or write readiness. ----@param fd integer ----@param dir '"rd"'|'"wr"' ----@param task Task ----@return WaitToken -function SelectPoller:wait(fd, dir, task) - assert(type(fd) == 'number', "fd must be number") - assert(dir == "rd" or dir == "wr", "dir must be 'rd' or 'wr'") - - local ws = (dir == "rd") and self.rd or self.wr - return ws:add(fd, task) +local function new_backend() + -- No persistent kernel state required for poll(); everything is + -- derived from the current waitsets on each poll call. + return {} end --- Build the fds table in the shape expected by posix.poll.poll: --- fds[fd] = { events = { IN = true, OUT = true } } ----@param self SelectPoller ----@return table -local function build_fds(self) +local function build_fds(rd_waitset, wr_waitset) local fds = {} -- Any fd with one or more read waiters gets IN. - for fd, list in pairs(self.rd.buckets) do + for fd, list in pairs(rd_waitset.buckets) do if list and #list > 0 then local e = fds[fd] if not e then @@ -68,7 +46,7 @@ local function build_fds(self) end -- Any fd with one or more write waiters gets OUT. - for fd, list in pairs(self.wr.buckets) do + for fd, list in pairs(wr_waitset.buckets) do if list and #list > 0 then local e = fds[fd] if not e then @@ -82,78 +60,51 @@ local function build_fds(self) return fds end ---- TaskSource hook: poll and schedule any ready tasks. ----@param sched Scheduler ----@param _ number|nil -- current monotonic time (unused) ----@param timeout number|nil -- seconds -function SelectPoller:schedule_tasks(sched, _, timeout) - -- Convert timeout from seconds to milliseconds for posix.poll. - local timeout_ms - if timeout == nil then - timeout_ms = 0 - elseif timeout < 0 then - timeout_ms = -1 - else - timeout_ms = math.floor(timeout * 1e3 + 0.5) - end - - local fds = build_fds(self) +local function poll_backend(_, timeout_ms, rd_waitset, wr_waitset) + local fds = build_fds(rd_waitset, wr_waitset) -- poll() with nfds == 0 is defined and just sleeps for timeout. - local nready, err, errno = poll_fn(fds, timeout_ms) + local nready, err, eno = poll_fn(fds, timeout_ms) if nready == nil then - -- Treat as a hard failure: surfaced as a normal Lua error, which - -- will be caught by the scope/fibre machinery. - error(("%s (errno %s)"):format(tostring(err), tostring(errno))) + -- Treat EINTR as a benign interruption (e.g. SIGCHLD), same as epoll backend. + if eno == errno_mod.EINTR then + return {} + end + error(("%s (errno %s)"):format(tostring(err), tostring(eno))) end - if nready == 0 then - return + return {} end - -- Wake tasks for any fd that reported events. - -- + local events = {} + -- luaposix reports readiness in fds[fd].revents with flags -- such as IN, OUT, ERR, HUP, NVAL. for fd, info in pairs(fds) do local re = info.revents if re then - if re.IN or re.HUP or re.ERR or re.NVAL then - self.rd:notify_all(fd, sched) - end - if re.OUT or re.ERR or re.NVAL then - self.wr:notify_all(fd, sched) + local rd_flag = re.IN or re.HUP or re.ERR or re.NVAL + local wr_flag = re.OUT or re.ERR or re.NVAL + local err_flag = re.ERR or re.NVAL + + if rd_flag or wr_flag or err_flag then + events[fd] = { + rd = not not rd_flag, + wr = not not wr_flag, + err = not not err_flag, + } end end end -end - --- Used as the scheduler's event_waiter. -SelectPoller.wait_for_events = SelectPoller.schedule_tasks -function SelectPoller:close() - self.rd:clear_all() - self.wr:clear_all() + return events end --- Singleton wiring (mirrors the epoll poller pattern). -local singleton - -local function get() - if singleton then return singleton end - singleton = new_poller() - local sched = runtime.current_scheduler - assert(sched.add_task_source, "scheduler must implement add_task_source") - sched:add_task_source(singleton) - return singleton -end - -local function is_supported() - return true -end - -return { - get = get, - Poller = SelectPoller, - is_supported = is_supported, +local ops = { + new_backend = new_backend, + poll = poll_backend, + -- on_wait_change: not needed for poll(); state is rebuilt each time. + is_supported = function() return true end, } + +return core.build_poller(ops) From 331fc5e4d2a04c3fb5a1baa8da4ccdfc6167b0fa Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Thu, 4 Dec 2025 22:11:57 +0000 Subject: [PATCH 068/138] replaces process with exec --- src/fibers/epoll.lua | 74 --- src/fibers/io/exec.lua | 569 ++++++++++++++++++ src/fibers/io/exec_backend.lua | 41 ++ src/fibers/io/exec_backend/core.lua | 175 ++++++ src/fibers/io/exec_backend/pidfd.lua | 567 +++++++++++++++++ src/fibers/io/exec_backend/sigchld.lua | 483 +++++++++++++++ src/fibers/io/exec_backend/stdio.lua | 227 +++++++ src/fibers/io/proc_backend.lua | 224 ------- src/fibers/io/process.lua | 408 ------------- src/fibers/pollio.lua | 202 ------- src/fibers/utils/syscall.lua | 44 +- tests/test.lua | 4 +- tests/test_io-exec.lua | 255 ++++++++ ...c_backend.lua => test_io-exec_backend.lua} | 45 +- tests/test_io-process.lua | 231 ------- 15 files changed, 2361 insertions(+), 1188 deletions(-) delete mode 100644 src/fibers/epoll.lua create mode 100644 src/fibers/io/exec.lua create mode 100644 src/fibers/io/exec_backend.lua create mode 100644 src/fibers/io/exec_backend/core.lua create mode 100644 src/fibers/io/exec_backend/pidfd.lua create mode 100644 src/fibers/io/exec_backend/sigchld.lua create mode 100644 src/fibers/io/exec_backend/stdio.lua delete mode 100644 src/fibers/io/proc_backend.lua delete mode 100644 src/fibers/io/process.lua delete mode 100644 src/fibers/pollio.lua create mode 100644 tests/test_io-exec.lua rename tests/{test_io-proc_backend.lua => test_io-exec_backend.lua} (72%) delete mode 100644 tests/test_io-process.lua diff --git a/src/fibers/epoll.lua b/src/fibers/epoll.lua deleted file mode 100644 index bc26fa5..0000000 --- a/src/fibers/epoll.lua +++ /dev/null @@ -1,74 +0,0 @@ --- (c) Snabb project --- (c) Jangala - --- Use of this source code is governed by the XXXXXXXXX license; see COPYING. - --- Epoll. - -local sc = require 'fibers.utils.syscall' -local bit = rawget(_G, "bit") or require 'bit32' - -local Epoll = {} - -local INITIAL_MAXEVENTS = 8 - -local function new() - local ret = { - epfd = assert(sc.epoll_create()), - active_events = {}, - maxevents = INITIAL_MAXEVENTS, - } - return setmetatable(ret, { __index = Epoll }) -end - -local RD = sc.EPOLLIN + sc.EPOLLRDHUP -local WR = sc.EPOLLOUT -local RDWR = RD + WR -local ERR = sc.EPOLLERR + sc.EPOLLHUP - -function Epoll:add(s, events) - -- local fd = type(s) == 'number' and s or sc.fileno(s) - local fd = s - local active = self.active_events[fd] or 0 - local eventmask = bit.bor(events, active, sc.EPOLLONESHOT) - local ok, _ = sc.epoll_modify(self.epfd, fd, eventmask) - if not ok then assert(sc.epoll_register(self.epfd, fd, eventmask)) end - self.active_events[fd] = eventmask -end - -function Epoll:poll(timeout) - -- Returns a table, an iterator would be more efficient. - -- print("self.epfd", self.epfd) - -- print("self.maxevents", self.maxevents) - local events, err = sc.epoll_wait(self.epfd, timeout or 0, self.maxevents) - if not events then - error(err) - end - local count = 0 - -- Since we add fd's with EPOLL_ONESHOT, now that the event has - -- fired, the fd is now deactivated. Record that fact. - for fd, _ in pairs(events) do - count = count + 1 - self.active_events[fd] = nil - end - if count == self.maxevents then - -- If we received `maxevents' events, it means that probably there - -- are more active fd's in the queue that we were unable to - -- receive. Expand our event buffer in that case. - self.maxevents = self.maxevents * 2 - end - return events, err -end - -function Epoll:close() - sc.epoll_close(self.epfd) - self.epfd = nil -end - -return { - new = new, - RD = RD, - WR = WR, - RDWR = RDWR, - ERR = ERR, -} diff --git a/src/fibers/io/exec.lua b/src/fibers/io/exec.lua new file mode 100644 index 0000000..8bd42e5 --- /dev/null +++ b/src/fibers/io/exec.lua @@ -0,0 +1,569 @@ +-- fibers/io/exec.lua - Structured process execution bound to scopes +---@module 'fibers.exec' + +local Runtime = require 'fibers.runtime' +local Scope = require 'fibers.scope' +local op = require 'fibers.op' +local sleep = require 'fibers.sleep' +local proc_mod = require 'fibers.io.exec_backend' +local stream_mod = require 'fibers.io.stream' + +local DEFAULT_SHUTDOWN_GRACE = 1.0 + +---@alias ExecStdin "inherit"|"null"|"pipe"|Stream +---@alias ExecStdout "inherit"|"null"|"pipe"|Stream +---@alias ExecStderr "inherit"|"null"|"pipe"|"stdout"|Stream + +--- ExecSpec: argv[1] is the program to exec; argv[2..n] are its arguments. +---@class ExecSpec +---@field [integer] string # argv elements (1..n) +---@field cwd string|nil +---@field env table|nil +---@field flags table|nil +---@field stdin ExecStdin|nil +---@field stdout ExecStdout|nil +---@field stderr ExecStderr|nil +---@field shutdown_grace number|nil + +--- Normalised stream configuration passed through to the backend. +---@class ExecStreamConfig +---@field mode "inherit"|"null"|"pipe"|"stdout"|"stream" +---@field stream Stream|nil +---@field owned boolean # whether the Command owns and will close the stream + +---@alias CommandStatus "pending"|"running"|"exited"|"signalled"|"failed" + +---@class Command +---@field _scope Scope +---@field _argv string[] +---@field _cwd string|nil +---@field _env table|nil +---@field _flags table +---@field _stdin ExecStreamConfig +---@field _stdout ExecStreamConfig +---@field _stderr ExecStreamConfig +---@field _shutdown_grace number +---@field _started boolean +---@field _done boolean +---@field _proc ProcHandle|nil +---@field _pid integer|nil +---@field _status CommandStatus +---@field _code integer|nil +---@field _signal integer|nil +---@field _err string|nil +local Command = {} +Command.__index = Command + +-- Normalise a user-facing stdio configuration into an ExecStreamConfig. +local function norm_stream(value, is_stderr) + if value == nil then + return { mode = "inherit", stream = nil, owned = true } + end + + local t = type(value) + if t == "string" then + if value == "inherit" or value == "null" or value == "pipe" then + return { mode = value, stream = nil, owned = true } + end + if is_stderr and value == "stdout" then + return { mode = "stdout", stream = nil, owned = true } + end + error("invalid stdio mode: " .. tostring(value)) + end + + if stream_mod.is_stream(value) then + -- A user-supplied stream is not owned by the Command. + return { mode = "stream", stream = value, owned = false } + end + + error("invalid stdio configuration: " .. tostring(value)) +end + +local function assert_not_started(self) + if self._started then + error("command already started") + end +end + +-- Perform an op using the current scope when it is still running, +-- otherwise fall back to a raw perform. This lets normal calls to +-- exec ops honour scope cancellation, while scope defers can still +-- run cleanup after the scope has reached a terminal state. +local function perform_with_scope_or_raw(ev) + local s = Scope.current() + if s and s.perform then + local status = s:status() + if status == "running" then + return s:perform(ev) + end + end + return op.perform_raw(ev) +end + +---------------------------------------------------------------------- +-- Internal: process lifecycle bookkeeping +---------------------------------------------------------------------- + +function Command:_record_exit(code, signal, err) + if self._done then return end + self._done = true + + if err then + self._status, self._err = "failed", err + elseif signal ~= nil then + self._status, self._signal = "signalled", signal + elseif code ~= nil then + self._status, self._code = "exited", code + else + self._status, self._err = "failed", "unknown process status" + end + + self._code, self._signal = code, signal +end + +--- Ensure the process has been started and a ProcHandle exists. +---@return boolean ok +---@return ProcHandle|nil proc +---@return string|nil err +function Command:_ensure_started() + if self._started then + if self._proc then + return true, self._proc, nil + end + if self._status == "failed" then + return false, nil, self._err + end + return false, nil, "exec: command started without backend" + end + self._started = true + + -- High-level process spec passed to the backend. + local spec = { + argv = self._argv, + cwd = self._cwd, + env = self._env, + flags = self._flags, + stdin = self._stdin, + stdout = self._stdout, + stderr = self._stderr, + } + + -- Backend returns a ProcHandle: + -- { backend = ExecBackend, stdin = Stream|nil, stdout = Stream|nil, stderr = Stream|nil } + local proc_handle, start_err = proc_mod.start(spec) + if not proc_handle then + self._status = "failed" + self._done = true + self._err = start_err + return false, nil, start_err + end + + self._proc = proc_handle + self._pid = proc_handle.backend and proc_handle.backend.pid or nil + self._status = "running" + + -- If the backend created pipe streams for us, record them and mark them as owned + -- so they are cleaned up in _on_scope_exit. + if proc_handle.stdin then + self._stdin.stream = proc_handle.stdin + self._stdin.owned = true + end + if proc_handle.stdout then + self._stdout.stream = proc_handle.stdout + self._stdout.owned = true + end + if proc_handle.stderr then + self._stderr.stream = proc_handle.stderr + self._stderr.owned = true + end + + return true, proc_handle, nil +end + +---------------------------------------------------------------------- +-- Configuration setters +---------------------------------------------------------------------- + +function Command:set_stdin(v) + assert_not_started(self) + self._stdin = norm_stream(v, false) + return self +end + +function Command:set_stdout(v) + assert_not_started(self) + self._stdout = norm_stream(v, false) + return self +end + +function Command:set_stderr(v) + assert_not_started(self) + self._stderr = norm_stream(v, true) + return self +end + +function Command:set_cwd(v) + assert_not_started(self) + self._cwd = v + return self +end + +function Command:set_env(v) + assert_not_started(self) + self._env = v + return self +end + +function Command:set_flags(v) + assert_not_started(self) + self._flags = v + return self +end + +function Command:set_shutdown_grace(v) + assert_not_started(self) + self._shutdown_grace = v + return self +end + +---------------------------------------------------------------------- +-- Introspection +---------------------------------------------------------------------- + +--- Return this command's status, exit code or signal (if any), and primary error. +---@return CommandStatus status +---@return integer|nil code_or_signal +---@return string|nil err +function Command:status() + local st = self._status + + if st == "exited" then + return st, self._code, self._err + elseif st == "signalled" then + return st, self._signal, self._err + elseif st == "failed" then + return st, nil, self._err + elseif st == "pending" or st == "running" then + return st, nil, nil + end + + -- Fallback for any unexpected status value. + return st, nil, self._err +end + +function Command:pid() + return self._pid +end + +function Command:argv() + local out = {} + for i, v in ipairs(self._argv) do + out[i] = v + end + return out +end + +---------------------------------------------------------------------- +-- Signalling +---------------------------------------------------------------------- + +function Command:kill(sig) + if self._done then + return true, nil + end + if not self._started then + return false, "command not started" + end + if self._status == "failed" then + return false, self._err or "command failed to start" + end + + local backend = self._proc and self._proc.backend or nil + if not backend then + return false, "no backend available" + end + + -- If the caller supplied an explicit signal/token and the backend exposes send_signal, pass it through. + if sig ~= nil and backend.send_signal then + return backend:send_signal(sig) + end + + -- Otherwise, prefer a forceful kill if implemented, fall back to terminate or a default send_signal. + if backend.kill then + return backend:kill() + elseif backend.terminate then + return backend:terminate() + elseif backend.send_signal then + return backend:send_signal() + end + + return false, "backend does not support signalling" +end + +---------------------------------------------------------------------- +-- Stream accessors +---------------------------------------------------------------------- + +function Command:stdin_stream() + local cfg = self._stdin + if cfg.mode == "inherit" or cfg.mode == "null" then + return nil + end + if cfg.mode == "stream" then + return cfg.stream + end + if cfg.mode == "pipe" then + local ok, _, err = self:_ensure_started() + return ok and self._stdin.stream or nil, err + end + return nil +end + +function Command:stdout_stream() + local cfg = self._stdout + if cfg.mode == "inherit" or cfg.mode == "null" then + return nil + end + if cfg.mode == "stream" then + return cfg.stream + end + if cfg.mode == "pipe" then + local ok, _, err = self:_ensure_started() + return ok and self._stdout.stream or nil, err + end + return nil +end + +function Command:stderr_stream() + local cfg = self._stderr + if cfg.mode == "inherit" or cfg.mode == "null" then + return nil + end + if cfg.mode == "stream" then + return cfg.stream + end + if cfg.mode == "pipe" then + local ok, _, err = self:_ensure_started() + return ok and self._stderr.stream or nil, err + end + if cfg.mode == "stdout" then + return self:stdout_stream() + end + return nil +end + +---------------------------------------------------------------------- +-- Ops: wait/run/shutdown/output +---------------------------------------------------------------------- + +function Command:run_op() + return op.guard(function() + local ok, proc, err = self:_ensure_started() + if not ok or not proc then + return op.always("failed", nil, nil, err) + end + if self._done then + return op.always(self._status, self._code, self._signal, self._err) + end + + return proc.backend:wait_op():wrap(function(...) + self:_record_exit(...) + return self._status, self._code, self._signal, self._err + end) + end) +end + +function Command:shutdown_op(grace) + return op.guard(function() + local ok, proc, err = self:_ensure_started() + if not (ok and proc) then + return op.always("failed", nil, nil, err) + end + if self._done then + return op.always(self._status, self._code, self._signal, self._err) + end + + local g = grace or self._shutdown_grace or DEFAULT_SHUTDOWN_GRACE + + -- Polite termination: delegate behaviour to backend. + if proc.backend and proc.backend.terminate then + proc.backend:terminate() + elseif proc.backend and proc.backend.send_signal then + proc.backend:send_signal() + end + + local choice_ev = op.boolean_choice( + self:run_op():wrap(function(status, code, signal, e) + return true, status, code, signal, e + end), + sleep.sleep_op(g):wrap(function() + return false + end) + ) + + return choice_ev:wrap(function(is_exit, status, code, signal, e) + if is_exit then + return status, code, signal, e + end + + -- Grace period elapsed. Try a forceful kill. + local kill_err + if proc.backend then + if proc.backend.kill then + local ok2, err2 = proc.backend:kill() + if not ok2 and err2 then + kill_err = err2 + end + elseif proc.backend.send_signal then + local ok2, err2 = proc.backend:send_signal() + if not ok2 and err2 then + kill_err = err2 + end + end + end + + -- Wait for the process to complete. + -- Use the current scope if it is still running so that + -- callers see cancellation; fall back to a raw perform + -- during scope cleanup (where the owning scope is already + -- in a terminal state). + local code2, signal2, err2 = perform_with_scope_or_raw(proc.backend:wait_op()) + self:_record_exit(code2, signal2, err2) + local err_final = kill_err or err2 + return self._status, self._code, self._signal, err_final + end) + end) +end + +function Command:output_op() + return op.guard(function() + -- If stdout is currently inherited, default to piping for this helper. + if not self._started and (self._stdout.mode == "inherit" or self._stdout.mode == nil) then + self._stdout = norm_stream("pipe", false) + end + + local ok, _, err = self:_ensure_started() + if not ok then + return op.always("", "failed", nil, nil, err) + end + + local stream, serr = self:stdout_stream() + if not stream then + return op.always("", "failed", nil, nil, serr or "no stdout stream available") + end + + return stream:read_all_op():wrap(function(out, io_err) + local status, code, signal, perr = perform_with_scope_or_raw(self:run_op()) + local err_final = io_err or perr + return out or "", status, code, signal, err_final + end) + end) +end + +function Command:combined_output_op() + if self._stderr.mode == "pipe" or self._stderr.mode == "stream" then + error("combined_output_op: stderr must not already be a pipe or stream") + end + if not self._started and (self._stderr.mode == "inherit" or self._stderr.mode == nil) then + self._stderr = norm_stream("stdout", true) + end + return self:output_op() +end + +---------------------------------------------------------------------- +-- Scope cleanup +---------------------------------------------------------------------- + +function Command:_on_scope_exit() + if self._started and not self._done then + -- Best-effort shutdown. Any error here will be caught by the + -- scope's defer machinery and recorded as a scope failure. + op.perform_raw(self:shutdown_op(self._shutdown_grace)) + end + + for _, name in ipairs { "stdin", "stdout", "stderr" } do + local cfg = self["_" .. name] + if cfg.stream and cfg.owned then + local ok, err = cfg.stream:close() + if not ok then + error(err or ("failed to close " .. name .. " stream")) + end + cfg.stream = nil + end + end + + if self._proc and self._proc.backend then + local ok, err = self._proc.backend:close() + if not ok then + error(err or "failed to close process backend") + end + self._proc.backend = nil + end +end + +---------------------------------------------------------------------- +-- Command construction +---------------------------------------------------------------------- + +local function command_from_spec(spec) + assert(Runtime.current_fiber(), "exec.command must be called from inside a fiber") + local scope = Scope.current() + + local argv = {} + local i = 1 + while spec[i] ~= nil do + argv[i] = assert(spec[i], "argv must not contain nil") + i = i + 1 + end + assert(argv[1], "exec.command: argv[1] must be non-nil") + + local cmd = setmetatable({ + _scope = scope, + _argv = argv, + _cwd = spec.cwd, + _env = spec.env, + _flags = spec.flags or {}, + _stdin = norm_stream(spec.stdin, false), + _stdout = norm_stream(spec.stdout, false), + _stderr = norm_stream(spec.stderr, true), + _shutdown_grace = spec.shutdown_grace or DEFAULT_SHUTDOWN_GRACE, + _started = false, + _done = false, + _proc = nil, + _pid = nil, + _status = "pending", + _code = nil, + _signal = nil, + _err = nil, + }, Command) + + scope:defer(function() + cmd:_on_scope_exit() + end) + + return cmd +end + +---------------------------------------------------------------------- +-- Public API +---------------------------------------------------------------------- + +local exec = {} + +function exec.command(...) + local n = select("#", ...) + if n == 1 and type((...)) == "table" then + return command_from_spec((...)) + end + + assert(n > 0, "exec.command: at least one argv element required") + local spec = {} + for i = 1, n do + spec[i] = assert(select(i, ...), "argv must not contain nil") + end + return command_from_spec(spec) +end + +exec.Command = Command + +return exec diff --git a/src/fibers/io/exec_backend.lua b/src/fibers/io/exec_backend.lua new file mode 100644 index 0000000..5d4210b --- /dev/null +++ b/src/fibers/io/exec_backend.lua @@ -0,0 +1,41 @@ +-- fibers/io/exec_backend.lua +-- +-- Backend selector for process management. +-- Prefers pidfd backend where available, falls back to SIGCHLD/self-pipe. +-- + +---@class ExecProcSpec +---@field argv string[] +---@field env table|nil +---@field cwd string|nil +---@field flags table|nil +---@field stdin ExecStreamConfig +---@field stdout ExecStreamConfig +---@field stderr ExecStreamConfig + +---@class ProcHandle +---@field backend ExecBackend +---@field stdin Stream|nil +---@field stdout Stream|nil +---@field stderr Stream|nil + +local candidates = { + 'fibers.io.exec_backend.pidfd', -- Linux pidfd backend + 'fibers.io.exec_backend.sigchld', -- Portable SIGCHLD + self-pipe backend (luaposix) +} + +local chosen + +for _, name in ipairs(candidates) do + local ok, mod = pcall(require, name) + if ok and type(mod) == "table" and mod.is_supported and mod.is_supported() then + chosen = mod + break + end +end + +if not chosen then + error("fibers.io.exec_backend: no suitable process backend available on this platform") +end + +return chosen diff --git a/src/fibers/io/exec_backend/core.lua b/src/fibers/io/exec_backend/core.lua new file mode 100644 index 0000000..8659d40 --- /dev/null +++ b/src/fibers/io/exec_backend/core.lua @@ -0,0 +1,175 @@ +-- fibers/io/exec_backend/core.lua +-- +-- Core glue for exec backends. +-- +-- This owns the public ExecBackend shape and semantics. +-- Backend modules provide only low-level primitives; build_backend +-- wires those into a concrete { start, ExecBackend, is_supported } module. +-- +---@module 'fibers.io.exec_backend.core' + +local waitmod = require 'fibers.wait' + +---@class ExecBackend +---@field pid integer|nil +---@field exited boolean +---@field status integer|nil +---@field code integer|nil +---@field signal integer|nil +---@field err string|nil +---@field _state any +---@field _ops table +local ExecBackend = {} +ExecBackend.__index = ExecBackend + +--- Wait for the process to complete, returning (code, signal, err). +---@return Op +function ExecBackend:wait_op() + local ops = self._ops + local state = self._state + + local function step() + if self.exited then + return true, self.code, self.signal, self.err + end + + local done, code, signal, err = ops.poll(state) + if not done then + return false + end + + self.exited = true + self.code = code + self.signal = signal + self.err = err + return true, self.code, self.signal, self.err + end + + ---@param task Task + ---@param suspension Suspension + ---@param leaf_wrap WrapFn + local function register(task, suspension, leaf_wrap) + return ops.register_wait(state, task, suspension, leaf_wrap) + end + + local function wrap(code, signal, err) + return code, signal, err + end + + return waitmod.waitable(register, step, wrap) +end + +function ExecBackend:send_signal(sig) + local ops = self._ops + if ops.send_signal then + return ops.send_signal(self._state, sig) + end + return false, "backend does not support send_signal" +end + +function ExecBackend:terminate() + local ops = self._ops + if ops.terminate then + return ops.terminate(self._state) + elseif ops.send_signal then + return ops.send_signal(self._state, nil) + end + return false, "backend does not support terminate" +end + +function ExecBackend:kill() + local ops = self._ops + if ops.kill then + return ops.kill(self._state) + elseif ops.send_signal then + return ops.send_signal(self._state, nil) + end + return false, "backend does not support kill" +end + +function ExecBackend:close() + local ops = self._ops + if ops.close then + return ops.close(self._state) + end + return true, nil +end + +---------------------------------------------------------------------- +-- Backend builder +---------------------------------------------------------------------- + +--- Build a concrete exec backend module from low-level ops. +--- +--- Required ops: +--- spawn(spec) -> state, streams, err|nil +--- state : backend-private state (must at least contain state.pid) +--- streams : { stdin = Stream|nil, stdout = Stream|nil, stderr = Stream|nil } +--- +--- poll(state) -> done:boolean, code|nil, signal|nil, err|nil +--- Non-blocking; done=false means “still running”. +--- +--- register_wait(state, task, suspension, leaf_wrap) -> WaitToken +--- Register a Task to be run when progress may have been made. +--- +--- Optional ops: +--- send_signal(state, sig) -> ok:boolean, err|nil +--- terminate(state) -> ok:boolean, err|nil +--- kill(state) -> ok:boolean, err|nil +--- close(state) -> ok:boolean, err|nil +--- is_supported() -> boolean +--- +---@param ops table +---@return table backend_module -- { start = fn, ExecBackend = ExecBackend, is_supported = fn } +local function build_backend(ops) + assert(type(ops) == "table", "exec_backend ops must be a table") + assert(type(ops.spawn) == "function", "ops.spawn must be a function") + assert(type(ops.poll) == "function", "ops.poll must be a function") + assert(type(ops.register_wait) == "function", "ops.register_wait must be a function") + + local function start(spec) + local state, streams, err = ops.spawn(spec) + if not state then + return nil, err + end + + local backend = setmetatable({ + _ops = ops, + _state = state, + + pid = state.pid, -- for introspection + exited = false, + status = nil, + code = nil, + signal = nil, + err = nil, + }, ExecBackend) + + local handle = { + backend = backend, + stdin = streams and streams.stdin or nil, + stdout = streams and streams.stdout or nil, + stderr = streams and streams.stderr or nil, + } + + return handle, nil + end + + local function is_supported() + if type(ops.is_supported) == "function" then + return not not ops.is_supported() + end + return true + end + + return { + ExecBackend = ExecBackend, + start = start, + is_supported = is_supported, + } +end + +return { + ExecBackend = ExecBackend, + build_backend = build_backend, +} diff --git a/src/fibers/io/exec_backend/pidfd.lua b/src/fibers/io/exec_backend/pidfd.lua new file mode 100644 index 0000000..421a021 --- /dev/null +++ b/src/fibers/io/exec_backend/pidfd.lua @@ -0,0 +1,567 @@ +-- fibers/io/exec_backend/pidfd.lua +-- +-- Linux pidfd-based process backend. +-- Uses fork + execvp + raw pidfd_open syscall + non-blocking waitpid. +-- +---@module 'fibers.io.exec_backend.pidfd' + +local core = require 'fibers.io.exec_backend.core' +local poller = require 'fibers.io.poller' +local runtime = require 'fibers.runtime' +local ffi_c = require 'fibers.utils.ffi_compat' +local file_io = require 'fibers.io.file' +local stdio = require 'fibers.io.exec_backend.stdio' + +local ffi = ffi_c.ffi +local C = ffi_c.C +local toint = ffi_c.tonumber +local get_errno = ffi_c.errno +local DEV_NULL = "/dev/null" + +local bit = rawget(_G, "bit") or require 'bit32' + +---------------------------------------------------------------------- +-- FFI / CFFI availability +---------------------------------------------------------------------- + +if not (ffi_c.is_supported and ffi_c.is_supported()) then + return { is_supported = function() return false end } +end + +---------------------------------------------------------------------- +-- FFI declarations and constants +---------------------------------------------------------------------- + +local ARCH = ffi.arch or ((jit and jit.arch) or "x64") + +ffi.cdef[[ + typedef int pid_t; + typedef unsigned int uint; + + long syscall(long number, ...); + + pid_t fork(void); + void _exit(int status); + int chdir(const char *path); + int setenv(const char *name, const char *value, int overwrite); + pid_t setsid(void); + int execvp(const char *file, char *const argv[]); + + int kill(pid_t pid, int sig); + int close(int fd); + + int fcntl(int fd, int cmd, ...); + + pid_t waitpid(pid_t pid, int *wstatus, int options); + pid_t getpid(void); + + int dup2(int oldfd, int newfd); + + int pipe(int pipefd[2]); + int open(const char *pathname, int flags, int mode); + + char *strerror(int errnum); +]] + +-- Raw syscall number for pidfd_open. +local SYS_pidfd_open = 434 -- Linux generic +if ARCH == "mips" or ARCH == "mipsel" then + -- See https://www.linux-mips.org/wiki/Syscall + SYS_pidfd_open = 4000 + 434 +end + +-- fcntl constants (Linux) +local F_GETFL = 3 +local F_SETFL = 4 +local F_GETFD = 1 +local F_SETFD = 2 +local O_NONBLOCK = 0x00000800 +local FD_CLOEXEC = 1 + +-- Minimal open() flags we need here. +local O_RDONLY = 0 +local O_WRONLY = 1 + +-- wait/errno constants (Linux) +local WNOHANG = 1 + +local EINTR = 4 +local ESRCH = 3 +local ECHILD = 10 +local ENOSYS = 38 + +-- Signals (Linux values). +local SIGTERM = 15 +local SIGKILL = 9 + +---------------------------------------------------------------------- +-- Small helpers +---------------------------------------------------------------------- + +local function strerror(e) + local s = C.strerror(e) + if s == nil then + return "errno " .. tostring(e) + end + return ffi.string(s) +end + +local function errno_msg(prefix, err, eno) + if err and err ~= "" then + return err + end + if eno then + return ("%s (errno %d)"):format(prefix, eno) + end + return prefix +end + +-- In the child we must not return to Lua on fatal errors. +local function must_child(ok) + if not ok then + C._exit(127) + end +end + +---------------------------------------------------------------------- +-- Raw pidfd_open syscall (musl/glibc-independent) +---------------------------------------------------------------------- + +local function pidfd_open_raw(pid, flags) + pid = ffi.new("pid_t", pid) + flags = ffi.new("uint", flags or 0) + + local fd = toint(C.syscall(SYS_pidfd_open, pid, flags)) + if fd == -1 then + local e = get_errno() + return nil, strerror(e), e + end + return fd, nil, nil +end + +---------------------------------------------------------------------- +-- fcntl helpers: set_nonblock / set_cloexec +---------------------------------------------------------------------- + +local getfl_fp = ffi.cast("int (*)(int, int)", C.fcntl) +local setfl_fp = ffi.cast("int (*)(int, int, int)", C.fcntl) + +local function set_nonblock(fd) + local before = assert(toint(getfl_fp(fd, F_GETFL))) + if before < 0 then + local e = get_errno() + return false, ("F_GETFL failed: %s"):format(strerror(e)), e + end + + local new_flags = bit.bor(before, O_NONBLOCK) + local rc = toint(setfl_fp(fd, F_SETFL, new_flags)) + if rc < 0 then + local e = get_errno() + return false, ("F_SETFL failed: %s"):format(strerror(e)), e + end + + -- Optional sanity check. + local after = assert(toint(getfl_fp(fd, F_GETFL))) + if after < 0 then + local e = get_errno() + return false, ("F_GETFL (post) failed: %s"):format(strerror(e)), e + end + + if bit.band(after, O_NONBLOCK) == 0 then + return false, + ("set_nonblock: O_NONBLOCK not set after F_SETFL; before=0x%x after=0x%x") + :format(before, after), + nil + end + + return true, nil, nil +end + +local getfd_fp = ffi.cast("int (*)(int, int)", C.fcntl) +local setfd_fp = ffi.cast("int (*)(int, int, int)", C.fcntl) + +local function set_cloexec(fd) + local before = assert(toint(getfd_fp(fd, F_GETFD))) + if before < 0 then + local e = get_errno() + return false, ("F_GETFD failed: %s"):format(strerror(e)), e + end + + local new_flags = bit.bor(before, FD_CLOEXEC) + local rc = toint(setfd_fp(fd, F_SETFD, new_flags)) + if rc < 0 then + local e = get_errno() + return false, ("F_SETFD failed: %s"):format(strerror(e)), e + end + + return true, nil, nil +end + +---------------------------------------------------------------------- +-- waitpid helpers (status inspection) +---------------------------------------------------------------------- + +local function WIFEXITED(status) + return bit.band(status, 0x7f) == 0 +end + +local function WEXITSTATUS(status) + return bit.rshift(status, 8) +end + +local function WIFSIGNALED(status) + local term = bit.band(status, 0x7f) + return term ~= 0 and term ~= 0x7f +end + +local function WTERMSIG(status) + return bit.band(status, 0x7f) +end + +---------------------------------------------------------------------- +-- Child-side helpers: argv/env/fd setup +---------------------------------------------------------------------- + +local function build_argv_c(argv) + local n = #argv + local cargv = ffi.new("char *[?]", n + 1) + + for i = 1, n do + local s = assert(argv[i], "argv must not contain nil") + local cs = ffi.new("char[?]", #s + 1) + ffi.copy(cs, s) + cargv[i - 1] = cs + end + cargv[n] = nil + + return cargv +end + +local function setup_child_fd(src_fd, dest_fd) + if not src_fd or src_fd == dest_fd then + return + end + local rc = toint(C.dup2(src_fd, dest_fd)) + if rc < 0 then + must_child(false) + end +end + +local function apply_child_env(env) + for name, value in pairs(env) do + local v = value and tostring(value) or nil + local rc = C.setenv(name, v, 1) -- value == nil clears the variable + if rc ~= 0 then + must_child(false) + end + end +end + +---@param spec table -- child-facing spec with *fd fields +local function child_exec(spec) + if spec.cwd then + local rc = C.chdir(spec.cwd) + must_child(rc == 0) + end + + if spec.flags and spec.flags.setsid then + local rc = toint(C.setsid()) + must_child(rc ~= -1) + end + + if spec.env then + apply_child_env(spec.env) + end + + setup_child_fd(spec.stdin_fd, 0) + setup_child_fd(spec.stdout_fd, 1) + setup_child_fd(spec.stderr_fd, 2) + + do + local seen = {} + for _, fd in ipairs { spec.stdin_fd, spec.stdout_fd, spec.stderr_fd } do + if fd and fd > 2 and not seen[fd] then + seen[fd] = true + C.close(fd) + end + end + end + + local argv = spec.argv + local cargv = build_argv_c(argv) + + C.execvp(argv[1], cargv) + + -- If we reach here, execvp failed. + C._exit(127) +end + +---------------------------------------------------------------------- +-- Backend state helpers +---------------------------------------------------------------------- + +---@class PidfdState +---@field pid integer +---@field pidfd integer|nil +---@field exited boolean +---@field status integer|nil +---@field code integer|nil +---@field signal integer|nil +---@field err string|nil + +local function finalise_state(st, status, code, signal, err) + if st.exited then + return + end + st.exited = true + st.status = status + st.code = code + st.signal = signal + st.err = err +end + +--- Blocking wait used only in the error path after a failed pidfd_open. +local function wait_blocking(pid) + local status_buf = ffi.new("int[1]") + while true do + local rpid = toint(C.waitpid(pid, status_buf, 0)) + if rpid == -1 then + local e = get_errno() + if e ~= EINTR then + return + end + else + -- Child reaped. + return + end + end +end + +--- Non-blocking wait on a single child. +---@param st PidfdState +---@return boolean done, integer|nil code, integer|nil signal, string|nil err +local function poll_state(st) + if st.exited then + return true, st.code, st.signal, st.err + end + + local status_buf = ffi.new("int[1]") + local rpid = toint(C.waitpid(st.pid, status_buf, WNOHANG)) + + if rpid == 0 then + -- Still running. + return false, nil, nil, nil + end + + if rpid == -1 then + local e = get_errno() + if e == ECHILD or e == ESRCH then + -- Child already gone or reaped elsewhere. + finalise_state(st, nil, nil, nil, nil) + return true, st.code, st.signal, st.err + end + finalise_state(st, nil, nil, nil, errno_msg("waitpid failed", nil, e)) + return true, st.code, st.signal, st.err + end + + local status = status_buf[0] + + if WIFEXITED(status) then + local code = WEXITSTATUS(status) + finalise_state(st, status, code, nil, nil) + elseif WIFSIGNALED(status) then + local sig = WTERMSIG(status) + finalise_state(st, status, nil, sig, nil) + else + -- Stopped/continued or other odd state; treat as “completed, detail unknown”. + finalise_state(st, status, nil, nil, nil) + end + + return true, st.code, st.signal, st.err +end + +---------------------------------------------------------------------- +-- Stream / stdio integration via exec_stdio +---------------------------------------------------------------------- + +local function open_dev_null(is_output) + local flags = is_output and O_WRONLY or O_RDONLY + local fd = toint(C.open(DEV_NULL, flags, 0)) + if fd < 0 then + local e = get_errno() + return nil, errno_msg("failed to open " .. DEV_NULL, nil, e) + end + return fd, nil +end + +local function make_pipe() + local pipefd = ffi.new("int[2]") + local rc = toint(C.pipe(pipefd)) + if rc ~= 0 then + local e = get_errno() + return nil, nil, errno_msg("pipe() failed", nil, e) + end + return toint(pipefd[0]), toint(pipefd[1]), nil +end + +local function close_fd(fd) + C.close(fd) +end + +local function open_stream(role, fd) + if role == "stdin" then + return file_io.fdopen(fd, O_WRONLY) + else + -- stdout / stderr + return file_io.fdopen(fd, O_RDONLY) + end +end + +---------------------------------------------------------------------- +-- Backend ops for exec_backend.core +---------------------------------------------------------------------- + +--- spawn(spec) -> state, streams, err +---@param spec ExecProcSpec +---@return PidfdState|nil state, {stdin:Stream|nil, stdout:Stream|nil, stderr:Stream|nil}|nil streams, string|nil err +local function spawn(spec) + assert(type(spec) == "table", "ExecBackend.spawn: spec must be a table") + assert(type(spec.argv) == "table" and spec.argv[1], + "ExecBackend.spawn: spec.argv must be a non-empty array") + + -- Common stdio wiring. + local child_spec, child_only, parent_fds, cfg_err = + stdio.build_child_stdio(spec, open_dev_null, make_pipe, set_cloexec, close_fd) + if not child_spec then + return nil, nil, cfg_err + end + + -- Fork. + local pid = toint(C.fork()) + if pid < 0 then + local e = get_errno() + stdio.close_child_only(child_only, close_fd) + stdio.close_parent_fds(parent_fds, close_fd) + return nil, nil, errno_msg("fork failed", nil, e) + end + + if pid == 0 then + child_exec(child_spec) -- never returns + end + + -- Parent: child-only fds no longer needed. + stdio.close_child_only(child_only, close_fd) + + -- Open pidfd. + local pidfd, perr, perrno = pidfd_open_raw(pid, 0) + if not pidfd then + C.kill(pid, SIGKILL) + wait_blocking(pid) + stdio.close_parent_fds(parent_fds, close_fd) + return nil, nil, errno_msg("pidfd_open failed", perr, perrno) + end + + local ok, e1 = set_nonblock(pidfd) + assert(ok, "set_nonblock(pidfd) failed: " .. tostring(e1)) + + ok, e1 = set_cloexec(pidfd) + assert(ok, "set_cloexec(pidfd) failed: " .. tostring(e1)) + + local state = { + pid = pid, + pidfd = pidfd, + exited = false, + status = nil, + code = nil, + signal = nil, + err = nil, + } + + -- Common mapping from parent_fds -> Streams. + local streams = stdio.build_parent_streams(parent_fds, open_stream) + + return state, streams, nil +end + +--- poll(state) -> done, code, signal, err +local function poll(state) + return poll_state(state) +end + +--- register_wait(state, task, suspension, leaf_wrap) -> WaitToken +local function register_wait(state, task, _, _) + if not state.pidfd then + -- No pidfd: best-effort reschedule. + runtime.current_scheduler:schedule(task) + return { unlink = function() return false end } + end + return poller.get():wait(state.pidfd, "rd", task) +end + +local function send_signal(state, sig) + sig = sig or SIGTERM + + local rc = toint(C.kill(state.pid, sig)) + if rc == 0 then + return true, nil + end + + local e = get_errno() + if e == ESRCH then + return true, nil + end + + return false, errno_msg("kill failed", nil, e) +end + +local function terminate(state) + return send_signal(state, SIGTERM) +end + +local function kill_proc(state) + return send_signal(state, SIGKILL) +end + +local function close_state(state) + if state.pidfd then + local rc = toint(C.close(state.pidfd)) + state.pidfd = nil + if rc ~= 0 then + local e = get_errno() + return false, strerror(e) + end + end + return true, nil +end + +---------------------------------------------------------------------- +-- Capability probe +---------------------------------------------------------------------- + +local function is_supported() + local pid = C.getpid() + local fd, _, eno = pidfd_open_raw(pid, 0) + if fd then + C.close(fd) + return true + end + + if eno == ENOSYS then + return false + end + + return true +end + +local ops = { + spawn = spawn, + poll = poll, + register_wait = register_wait, + send_signal = send_signal, + terminate = terminate, + kill = kill_proc, + close = close_state, + is_supported = is_supported, +} + +return core.build_backend(ops) diff --git a/src/fibers/io/exec_backend/sigchld.lua b/src/fibers/io/exec_backend/sigchld.lua new file mode 100644 index 0000000..d8dfda7 --- /dev/null +++ b/src/fibers/io/exec_backend/sigchld.lua @@ -0,0 +1,483 @@ +-- fibers/io/exec_backend/sigchld.lua +-- +-- SIGCHLD + self-pipe process backend (luaposix only). +-- +-- Portable POSIX backend for systems without pidfd_open. +-- Uses: +-- - SIGCHLD handler that writes to a non-blocking self-pipe +-- - poller watching the pipe +-- - wait(WNOHANG) per tracked child PID +-- - a Waitset to wake fibers blocked in wait_op(). +-- +---@module 'fibers.io.exec_backend.sigchld' + +local core = require 'fibers.io.exec_backend.core' +local unistd = require 'posix.unistd' +local syswait = require 'posix.sys.wait' +local psignal = require 'posix.signal' +local fcntl = require 'posix.fcntl' +local errno = require 'posix.errno' +local stdlib = require 'posix.stdlib' + +local poller = require 'fibers.io.poller' +local waitmod = require 'fibers.wait' +local runtime = require 'fibers.runtime' +local file_io = require 'fibers.io.file' +local stdio = require 'fibers.io.exec_backend.stdio' + +local bit = rawget(_G, "bit") or require 'bit32' + +local DEV_NULL = "/dev/null" + +---------------------------------------------------------------------- +-- Global state for SIGCHLD handling +---------------------------------------------------------------------- + +--- All children we are responsible for: pid -> SigchldState +local children = {} + +--- Waiters keyed by pid: Waitset from fibers.wait. +local waiters = waitmod.new_waitset() + +--- Scheduler used to reschedule waiters; populated when the reaper starts. +---@type Scheduler|nil +local child_sched + +--- Self-pipe file descriptors. +---@type integer|nil +local sig_r +---@type integer|nil +local sig_w + +--- Reaper task flag. +local reaper_started = false + +local function errno_msg(prefix, err, eno) + if err and err ~= "" then + return err + end + if eno then + return ("%s (errno %d)"):format(prefix, eno) + end + return prefix +end + +---------------------------------------------------------------------- +-- Small helpers +---------------------------------------------------------------------- + +local function set_nonblock(fd) + local flags, err, eno = fcntl.fcntl(fd, fcntl.F_GETFL) + if flags == nil then + return nil, errno_msg("fcntl(F_GETFL)", err, eno) + end + local newflags = bit.bor(flags, fcntl.O_NONBLOCK) + local ok, err2, eno2 = fcntl.fcntl(fd, fcntl.F_SETFL, newflags) + if ok == nil then + return nil, errno_msg("fcntl(F_SETFL)", err2, eno2) + end + return true +end + +local function set_cloexec(fd) + local flags, err, eno = fcntl.fcntl(fd, fcntl.F_GETFD) + if flags == nil then + return nil, errno_msg("fcntl(F_GETFD)", err, eno) + end + local newflags = bit.bor(flags, fcntl.FD_CLOEXEC or 0) + local ok, err2, eno2 = fcntl.fcntl(fd, fcntl.F_SETFD, newflags) + if ok == nil then + return nil, errno_msg("fcntl(F_SETFD)", err2, eno2) + end + return true +end + +local function must_child(ok, _, _) + if not ok or ok == 0 then + unistd._exit(127) + end +end + +local function build_argt(argv) + local cmd = assert(argv[1], "ProcSpec.argv[1] must be executable") + local argt = {} + argt[0] = cmd + for i = 2, #argv do + argt[i - 1] = argv[i] + end + return cmd, argt +end + +local function setup_child_fd(src_fd, dest_fd) + if src_fd == nil or src_fd == dest_fd then + return + end + local newfd, err, eno = unistd.dup2(src_fd, dest_fd) + if not newfd then + must_child(false, err, eno) + end +end + +local function apply_child_env(env) + for name, value in pairs(env) do + local ok, err, eno = stdlib.setenv(name, value and tostring(value) or nil) + if ok == nil then + must_child(false, err, eno) + end + end +end + +---------------------------------------------------------------------- +-- SIGCHLD self-pipe and reaper +---------------------------------------------------------------------- + +local function install_self_pipe_and_handler() + if sig_r ~= nil then + return + end + + local r, w, err, eno = unistd.pipe() + if not r then + error("exec_backend.sigchld: pipe() failed: " .. errno_msg("pipe", err, eno)) + end + + local ok1, e1 = set_nonblock(r) + local ok2, e2 = set_nonblock(w) + local ok3, e3 = set_cloexec(r) + local ok4, e4 = set_cloexec(w) + if not (ok1 and ok2 and ok3 and ok4) then + if r then unistd.close(r) end + if w then unistd.close(w) end + error("exec_backend.sigchld: failed to configure self-pipe: " + .. tostring(e1 or e2 or e3 or e4)) + end + + sig_r, sig_w = r, w + + local function handler() + unistd.write(sig_w, "x") + end + + if jit and jit.off then + jit.off(handler, true) + end + + local flags = psignal.SA_RESTART + local old, serr, seno + if flags ~= nil then + old, serr, seno = psignal.signal(psignal.SIGCHLD, handler, flags) + else + old, serr, seno = psignal.signal(psignal.SIGCHLD, handler) + end + if not old and serr then + error("exec_backend.sigchld: signal(SIGCHLD) failed: " + .. errno_msg("signal", serr, seno)) + end +end + +---------------------------------------------------------------------- +-- Backend state helpers +---------------------------------------------------------------------- + +---@class SigchldState +---@field pid integer +---@field exited boolean +---@field status integer|nil +---@field code integer|nil +---@field signal integer|nil +---@field err string|nil + +local function finalise_state(st, status, code, signal, err) + if st.exited then + return + end + st.exited = true + st.status = status + st.code = code + st.signal = signal + st.err = err +end + +local function poll_state(st) + if st.exited then + return true, st.code, st.signal, st.err + end + return false, nil, nil, nil +end + +local function drain_self_pipe() + if not sig_r then return end + + while true do + local s, _, eno = unistd.read(sig_r, 4096) + if s == nil then + if eno == errno.EAGAIN or eno == errno.EWOULDBLOCK then + break + end + break + end + if #s < 4096 then + break + end + end +end + +local function reap_known_children() + local to_remove = {} + + for pid, st in pairs(children) do + if st and not st.exited then + local rpid, how, v3, err, eno = syswait.wait(pid, syswait.WNOHANG) + if rpid == nil then + if eno == errno.ECHILD then + finalise_state(st, nil, nil, nil, nil) + to_remove[#to_remove + 1] = pid + elseif eno ~= errno.EINTR then + finalise_state(st, nil, nil, nil, errno_msg("wait failed", err, eno)) + to_remove[#to_remove + 1] = pid + end + elseif how ~= "running" then + if how == "exited" then + local code = v3 + finalise_state(st, v3, code, nil, nil) + else + local sig = v3 + finalise_state(st, v3, nil, sig, nil) + end + to_remove[#to_remove + 1] = pid + end + end + end + + if child_sched then + for i = 1, #to_remove do + local pid = to_remove[i] + children[pid] = nil + waiters:notify_all(pid, child_sched) + end + else + for i = 1, #to_remove do + children[to_remove[i]] = nil + end + end +end + +---@class ReaperTask : Task +local ReaperTask = {} +ReaperTask.__index = ReaperTask + +function ReaperTask:run() + if not sig_r or not child_sched then + return + end + + self.armed = false + drain_self_pipe() + reap_known_children() + + if sig_r then + self.armed = true + poller.get():wait(sig_r, "rd", self) + end +end + +local function start_reaper() + if reaper_started then + return + end + + install_self_pipe_and_handler() + + reaper_started = true + child_sched = runtime.current_scheduler + + local task = setmetatable({}, ReaperTask) + if not task.armed then + task.armed = true + poller.get():wait(sig_r, "rd", task) + end +end + +---------------------------------------------------------------------- +-- Child side exec +---------------------------------------------------------------------- + +---@param spec table -- child-facing spec with *fd fields +local function child_exec(spec) + if spec.cwd then + local ok, err, eno = unistd.chdir(spec.cwd) + if not ok then + must_child(false, err, eno) + end + end + + if spec.flags and spec.flags.setsid then + local res, err, eno + if unistd.setsid then + res, err, eno = unistd.setsid() + elseif unistd.setpid then + res, err, eno = unistd.setpid("s", 0) + end + if res == nil then + must_child(false, err, eno) + end + end + + if spec.env then + apply_child_env(spec.env) + end + + setup_child_fd(spec.stdin_fd, 0) + setup_child_fd(spec.stdout_fd, 1) + setup_child_fd(spec.stderr_fd, 2) + + do + local seen = {} + for _, fd in ipairs{ spec.stdin_fd, spec.stdout_fd, spec.stderr_fd } do + if fd and fd > 2 and not seen[fd] then + seen[fd] = true + unistd.close(fd) + end + end + end + + local cmd, argt = build_argt(spec.argv) + unistd.execp(cmd, argt) + unistd._exit(127) +end + +---------------------------------------------------------------------- +-- Stream / stdio integration via exec_stdio +---------------------------------------------------------------------- + +local function open_dev_null(is_output) + local flags = is_output and fcntl.O_WRONLY or fcntl.O_RDONLY + local fd, err, eno = fcntl.open(DEV_NULL, flags, 0) + if not fd then + return nil, errno_msg("failed to open " .. DEV_NULL, err, eno) + end + return fd, nil +end + +local function make_pipe() + local rd, wr, err, eno = unistd.pipe() + if not rd then + return nil, nil, errno_msg("pipe() failed", err, eno) + end + return rd, wr, nil +end + +local function close_fd(fd) + unistd.close(fd) +end + +local function open_stream(role, fd) + if role == "stdin" then + return file_io.fdopen(fd, fcntl.O_WRONLY) + else + return file_io.fdopen(fd, fcntl.O_RDONLY) + end +end + +---------------------------------------------------------------------- +-- Backend ops for exec_backend.core +---------------------------------------------------------------------- + +--- spawn(spec) -> state, streams, err +---@param spec ExecProcSpec +---@return SigchldState|nil state, {stdin:Stream|nil, stdout:Stream|nil, stderr:Stream|nil}|nil streams, string|nil err +local function spawn(spec) + assert(type(spec) == "table", "ExecBackend.spawn: spec must be a table") + assert(type(spec.argv) == "table" and spec.argv[1], + "ExecBackend.spawn: spec.argv must be a non-empty array") + + install_self_pipe_and_handler() + start_reaper() + + -- Common stdio wiring. + local child_spec, child_only, parent_fds, cfg_err = + stdio.build_child_stdio(spec, open_dev_null, make_pipe, set_cloexec, close_fd) + if not child_spec then + return nil, nil, cfg_err + end + + local pid, err, eno = unistd.fork() + if not pid then + stdio.close_child_only(child_only, close_fd) + stdio.close_parent_fds(parent_fds, close_fd) + return nil, nil, errno_msg("fork failed", err, eno) + end + + if pid == 0 then + child_exec(child_spec) + end + + -- Parent: child-only fds no longer needed. + stdio.close_child_only(child_only, close_fd) + + local state = { + pid = pid, + exited = false, + status = nil, + code = nil, + signal = nil, + err = nil, + } + + children[pid] = state + + local streams = stdio.build_parent_streams(parent_fds, open_stream) + + return state, streams, nil +end + +local function poll(state) + return poll_state(state) +end + +local function register_wait(state, task, _, _) + start_reaper() + return waiters:add(state.pid, task) +end + +local function send_signal(state, sig) + sig = sig or psignal.SIGTERM + local rc, err, eno = psignal.kill(state.pid, sig) + if rc == 0 then + return true, nil + end + if rc == nil and eno == errno.ESRCH then + return true, nil + end + return false, errno_msg("kill failed", err, eno) +end + +local function terminate(state) + return send_signal(state, psignal.SIGTERM) +end + +local function kill_proc(state) + return send_signal(state, psignal.SIGKILL) +end + +local function close_state(state) + waiters:clear_key(state.pid) + return true, nil +end + +local function is_supported() + return psignal.SIGCHLD ~= nil +end + +local ops = { + spawn = spawn, + poll = poll, + register_wait = register_wait, + send_signal = send_signal, + terminate = terminate, + kill = kill_proc, + close = close_state, + is_supported = is_supported, +} + +return core.build_backend(ops) diff --git a/src/fibers/io/exec_backend/stdio.lua b/src/fibers/io/exec_backend/stdio.lua new file mode 100644 index 0000000..35c436d --- /dev/null +++ b/src/fibers/io/exec_backend/stdio.lua @@ -0,0 +1,227 @@ +-- fibers/io/exec_backend/stdio.lua +-- +-- Shared stdio wiring for exec backends. +-- +-- Takes ExecStreamConfig values (as constructed by fibers.exec) +-- and turns them into: +-- * child_spec.{stdin_fd, stdout_fd, stderr_fd} +-- * child_only : set of fds only used in the child +-- * parent_fds : { stdin = fd?, stdout = fd?, stderr = fd? } for pipes +-- +---@module 'fibers.io.exec_backend.stdio' + +local M = {} + +---@param s any +---@return integer|nil fd, string|nil err +local function stream_fileno(s) + if type(s) ~= "table" then + return nil, "stream is not a table" + end + local io_backend = s.io + if type(io_backend) ~= "table" or type(io_backend.fileno) ~= "function" then + return nil, "stream backend does not support fileno()" + end + return io_backend:fileno() +end + +--- Build child stdio fd mapping and parent pipe ends from a high-level spec. +--- +--- Callbacks: +--- open_dev_null(is_output:boolean) -> fd|nil, err|nil +--- make_pipe() -> rd_fd|nil, wr_fd|nil, err|nil +--- set_cloexec(fd) -> ok:boolean, err|nil +--- close_fd(fd) -> () +--- +---@param spec ExecProcSpec +---@param open_dev_null fun(is_output: boolean): integer|nil, string|nil +---@param make_pipe fun(): integer|nil, integer|nil, string|nil +---@param set_cloexec fun(fd: integer): boolean, string|nil +---@param close_fd fun(fd: integer) +---@return table|nil child_spec, table|nil child_only, table|nil parent_fds, string|nil err +function M.build_child_stdio(spec, open_dev_null, make_pipe, set_cloexec, close_fd) + assert(type(spec) == "table", "build_child_stdio: spec must be a table") + assert(type(open_dev_null) == "function", "build_child_stdio: open_dev_null must be a function") + assert(type(make_pipe) == "function", "build_child_stdio: make_pipe must be a function") + assert(type(set_cloexec) == "function", "build_child_stdio: set_cloexec must be a function") + assert(type(close_fd) == "function", "build_child_stdio: close_fd must be a function") + + local child_only = {} -- [fd] = true (used only in child) + local parent_fds = {} -- stdin/stdout/stderr -> parent end for pipes + + local child_spec = { + argv = spec.argv, + cwd = spec.cwd, + env = spec.env, + flags = spec.flags, + stdin_fd = nil, + stdout_fd = nil, + stderr_fd = nil, + } + + local function fail(msg) + for fd in pairs(child_only) do + close_fd(fd) + end + for _, fd in pairs(parent_fds) do + if fd then + close_fd(fd) + end + end + return nil, nil, nil, msg + end + + --- Configure a single stdio stream. + --- kind: "stdin" | "stdout" | "stderr" + --- cfg : ExecStreamConfig + local function configure_stream(kind, cfg) + local is_output = (kind ~= "stdin") + local field = kind .. "_fd" + local mode = cfg.mode or "inherit" + + -- inherit + if mode == "inherit" then + child_spec[field] = nil + return true + + -- /dev/null + elseif mode == "null" then + local fd, err = open_dev_null(is_output) + if not fd then + return false, err + end + child_only[fd] = true + child_spec[field] = fd + return true + + -- pipe (child ↔ parent) + elseif mode == "pipe" then + local rd, wr, err = make_pipe() + if not (rd and wr) then + return false, err + end + + local child_fd, parent_fd + if is_output then + -- child writes, parent reads + child_fd, parent_fd = wr, rd + else + -- child reads, parent writes + child_fd, parent_fd = rd, wr + end + + child_only[child_fd] = true + child_spec[field] = child_fd + parent_fds[kind] = parent_fd + + local ok, cerr = set_cloexec(parent_fd) + if not ok then + return false, cerr or ("set_cloexec(" .. kind .. " parent fd) failed") + end + return true + + -- user-supplied stream: just borrow the underlying fd + elseif mode == "stream" then + local fd, err = stream_fileno(cfg.stream) + if not fd then + return false, err + end + child_spec[field] = fd + return true + + -- stderr = "stdout" + elseif kind == "stderr" and mode == "stdout" then + local out_cfg = spec.stdout + local out_mode = out_cfg and (out_cfg.mode or "inherit") or "inherit" + if out_mode == "inherit" and child_spec.stdout_fd == nil then + -- Share the inherited stdout (fd 1). + child_spec.stderr_fd = 1 + elseif child_spec.stdout_fd ~= nil then + -- Share whatever stdout was configured to use. + child_spec.stderr_fd = child_spec.stdout_fd + else + return false, "stderr='stdout' but stdout not configured" + end + return true + + else + return false, "invalid " .. kind .. " mode: " .. tostring(mode) + end + end + + do + local ok, err = configure_stream("stdin", spec.stdin) + if not ok then + return fail(err) + end + end + + do + local ok, err = configure_stream("stdout", spec.stdout) + if not ok then + return fail(err) + end + end + + do + local ok, err = configure_stream("stderr", spec.stderr) + if not ok then + return fail(err) + end + end + + return child_spec, child_only, parent_fds, nil +end + +--- Best-effort close of fds that are only needed in the child. +---@param child_only table|nil +---@param close_fd fun(fd: integer) +function M.close_child_only(child_only, close_fd) + if not child_only then return end + for fd in pairs(child_only) do + close_fd(fd) + end +end + +--- Best-effort close of parent pipe ends (used on error paths). +---@param parent_fds table|nil +---@param close_fd fun(fd: integer) +function M.close_parent_fds(parent_fds, close_fd) + if not parent_fds then return end + for _, fd in pairs(parent_fds) do + if fd then + close_fd(fd) + end + end +end + +--- Map parent pipe fds back into Streams, given an open_stream callback. +--- +--- open_stream(role, fd) -> Stream +--- +---@param parent_fds table|nil +---@param open_stream fun(role: '"stdin"'|'"stdout"'|'"stderr"', fd: integer): Stream +---@return { stdin: Stream|nil, stdout: Stream|nil, stderr: Stream|nil } +function M.build_parent_streams(parent_fds, open_stream) + parent_fds = parent_fds or {} + + local stdin, stdout, stderr + + if parent_fds.stdin then + stdin = open_stream("stdin", parent_fds.stdin) + end + if parent_fds.stdout then + stdout = open_stream("stdout", parent_fds.stdout) + end + if parent_fds.stderr then + stderr = open_stream("stderr", parent_fds.stderr) + end + + return { + stdin = stdin, + stdout = stdout, + stderr = stderr, + } +end + +return M diff --git a/src/fibers/io/proc_backend.lua b/src/fibers/io/proc_backend.lua deleted file mode 100644 index 7c00d7c..0000000 --- a/src/fibers/io/proc_backend.lua +++ /dev/null @@ -1,224 +0,0 @@ --- fibers/io/proc_backend.lua --- --- Low-level OS process backend for fibers.process. --- --- Linux-specific: uses fork + execp + pidfd_open + wait(WNOHANG). --- ----@module 'fibers.io.proc_backend' - -local sc = require 'fibers.utils.syscall' - ----@class ProcSpec ----@field argv string[] -- argv[1] is executable ----@field env table|nil -- env overrides/unsets; nil: inherit ----@field cwd string|nil -- working directory for child ----@field stdin_fd integer|nil -- child stdin fd (nil = inherit) ----@field stdout_fd integer|nil -- child stdout fd (nil = inherit) ----@field stderr_fd integer|nil -- child stderr fd (nil = inherit) ----@field flags table|nil -- optional flags, e.g. { setsid = true } - ----@class ProcBackend ----@field pid integer ----@field pidfd integer ----@field exited boolean ----@field status integer|nil -- raw status / code / signal ----@field code integer|nil -- exit code if exited normally ----@field signal integer|nil -- signal number if killed/stopped ----@field err string|nil -local ProcBackend = {} -ProcBackend.__index = ProcBackend - ----------------------------------------------------------------------- --- Internal helpers ----------------------------------------------------------------------- - -local function errno_msg(prefix, err, errno) - return err or (prefix .. " (errno " .. tostring(errno) .. ")") -end - -local function must_ok(ok) - if not ok then - sc._exit(127) - end -end - -local function build_argt(argv) - local cmd = assert(argv[1], "ProcSpec.argv[1] must be executable") - local argt = {} - argt[0] = cmd - for i = 2, #argv do - argt[i - 1] = argv[i] - end - return cmd, argt -end - ---- Duplicate src_fd onto dest_fd in the child. -local function setup_child_fd(src_fd, dest_fd) - if src_fd == nil or src_fd == dest_fd then - return - end - must_ok(sc.dup2(src_fd, dest_fd)) -end - ---- Apply environment overrides in the child. ----@param env table -local function apply_child_env(env) - for name, value in pairs(env) do - must_ok(sc.setenv(name, value and tostring(value) or nil)) - end -end - ----@param spec ProcSpec -local function child_exec(spec) - if spec.cwd then - must_ok(sc.chdir(spec.cwd)) - end - - if spec.flags and spec.flags.setsid then - must_ok(sc.setpid("s", 0)) - end - - if spec.env then - apply_child_env(spec.env) - end - - setup_child_fd(spec.stdin_fd, 0) - setup_child_fd(spec.stdout_fd, 1) - setup_child_fd(spec.stderr_fd, 2) - - -- Close child-only descriptors used for stdio, where safe. - do - local seen = {} - for _, fd in ipairs{ spec.stdin_fd, spec.stdout_fd, spec.stderr_fd } do - if fd and fd > 2 and not seen[fd] then - seen[fd] = true - sc.close(fd) -- ignore errors - end - end - end - - local cmd, argt = build_argt(spec.argv) - sc.execp(cmd, argt) - - sc._exit(127) -end - ----------------------------------------------------------------------- --- Public spawn ----------------------------------------------------------------------- - ----@param spec ProcSpec ----@return ProcBackend|nil backend, string|nil err -local function spawn(spec) - assert(type(spec) == "table", "ProcBackend.spawn: spec must be a table") - assert(type(spec.argv) == "table" and spec.argv[1], - "ProcBackend.spawn: spec.argv must be a non-empty array") - - local pid, err, errno = sc.fork() - if not pid then - return nil, errno_msg("fork failed", err, errno) - end - - if pid == 0 then - child_exec(spec) - end - - local pidfd, perr, perrno = sc.pidfd_open(pid, 0) - if not pidfd then - sc.kill(pid, sc.SIGKILL) - sc.wait(pid, 0) - return nil, errno_msg("pidfd_open failed", perr, perrno) - end - - local ok, e1 = sc.set_nonblock(pidfd) - assert(ok, "set_nonblock pidfd failed: " .. tostring(e1)) - - ok, e1 = sc.set_cloexec(pidfd) - assert(ok, "set_cloexec pidfd failed: " .. tostring(e1)) - - local retval = setmetatable({ - pid = pid, - pidfd = pidfd, - exited = false, - status = nil, - code = nil, - signal = nil, - err = nil, - }, ProcBackend) - - return retval -end - ----------------------------------------------------------------------- --- Non-blocking wait ----------------------------------------------------------------------- - -function ProcBackend:_finalise(status, code, signal, err) - if not self.exited then - self.exited = true - self.status = status - self.code = code - self.signal = signal - self.err = err - self:close() - end - return true, self.status, self.code, self.signal, self.err -end - -function ProcBackend:nonblock_wait() - if self.exited then - return true, self.status, self.code, self.signal, self.err - end - - local pid, how, v3 = sc.wait(self.pid, sc.WNOHANG) - if not pid then - local err, errno = how, v3 - if errno == sc.ECHILD or errno == sc.ESRCH then - return self:_finalise(nil, nil, nil, nil) - end - return self:_finalise(nil, nil, nil, errno_msg("wait failed", err, errno)) - end - - if how == "running" then - return false, nil, nil, nil, nil - end - - local status, code, signal - if how == "exited" then - status, code, signal = v3, v3, nil - else - status, code, signal = v3, nil, v3 - end - - return self:_finalise(status, code, signal, nil) -end - ----------------------------------------------------------------------- --- Signalling and close ----------------------------------------------------------------------- - -function ProcBackend:send_signal(sig) - sig = sig or sc.SIGTERM - - local ok, err, errno = sc.kill(self.pid, sig) - if not ok then - if errno == sc.ESRCH then return true, nil end - return false, errno_msg("kill failed", err, errno) - end - - return true, nil -end - -function ProcBackend:close() - if self.pidfd then - local ok, err = sc.close(self.pidfd) - self.pidfd = nil - if not ok then return false, err end - end - return true, nil -end - -return { - ProcBackend = ProcBackend, - spawn = spawn, -} diff --git a/src/fibers/io/process.lua b/src/fibers/io/process.lua deleted file mode 100644 index 14a8e79..0000000 --- a/src/fibers/io/process.lua +++ /dev/null @@ -1,408 +0,0 @@ --- fibers/io/process.lua --- --- Process abstraction built on top of proc_backend, streams and CML Ops. --- ----@module 'fibers.process' - -local sc = require 'fibers.utils.syscall' -local op = require 'fibers.op' -local perform = require 'fibers.performer'.perform -local wait = require 'fibers.wait' -local file_io = require 'fibers.io.file' -local proc_mod = require 'fibers.io.proc_backend' -local poller = require 'fibers.io.poller' -local sleep = require 'fibers.sleep' - -local safe = require 'coxpcall' - ----@class SpawnOptions ----@field argv string[] # required, argv[1] is executable ----@field cwd string|nil # working directory for child ----@field env table|nil # environment overrides/unsets ----@field flags table|nil # passed through to ProcSpec.flags ----@field stdin '"inherit"'|'"null"'|'"pipe"'|nil ----@field stdout '"inherit"'|'"null"'|'"pipe"'|nil ----@field stderr '"inherit"'|'"null"'|'"pipe"'|'"stdout"'|nil - ----@class Process ----@field backend ProcBackend|nil ----@field stdin Stream|nil ----@field stdout Stream|nil ----@field stderr Stream|nil ----@field _done boolean ----@field _status integer|nil ----@field _code integer|nil ----@field _signal integer|nil ----@field _err string|nil -local Process = {} -Process.__index = Process - -local DEV_NULL = "/dev/null" - ---- Build a ProcSpec and derived stdio information. ---- ---- On success returns: ---- spec, child_only_set, parent_stdin_fd, parent_stdout_fd, parent_stderr_fd, nil ---- ---- On failure due to OS/environment: ---- nil, nil, nil, nil, nil, err ---- ---- On programmer error (bad options), raises. ----@param opts SpawnOptions ----@return table|nil spec ----@return table|nil child_only ----@return integer|nil parent_stdin_fd ----@return integer|nil parent_stdout_fd ----@return integer|nil parent_stderr_fd ----@return string|nil err -local function build_proc_spec(opts) - local argv = assert(opts.argv, "spawn: opts.argv is required") - assert(type(argv) == "table" and argv[1], "spawn: opts.argv must be a non-empty array") - - local spec = { - argv = argv, - cwd = opts.cwd, - env = opts.env, - flags = opts.flags, - stdin_fd = nil, - stdout_fd = nil, - stderr_fd = nil, - } - - local opened = {} -- all fds opened in this function - local child_only = {} -- subset used only in the child - local parent_fds = {} -- parent-side pipe ends by name - - local function remember(fd) - if fd and fd >= 0 then - opened[fd] = true - end - return fd - end - - local function mark_child_only(fd) - if fd and fd >= 0 then - child_only[fd] = true - end - return fd - end - - local function cleanup_all() - for fd in pairs(opened) do - sc.close(fd) - end - end - - local function fail_env(msg) - cleanup_all() - return nil, nil, nil, nil, nil, msg - end - - local function prog_error(msg) - -- Programmer error: clean up fds for politeness, then raise. - cleanup_all() - error(msg, 3) -- level 3: attribute to caller of build_proc_spec - end - - local function open_dev_null(flags) - local fd, err = sc.open(DEV_NULL, flags, 0) - if not fd then - return nil, err or ("failed to open " .. DEV_NULL) - end - return remember(fd), nil - end - - local function make_pipe() - local rd, wr, perr = sc.pipe() - if not rd then - return nil, nil, perr or "pipe() failed" - end - remember(rd) - remember(wr) - return rd, wr, nil - end - - local function handle_stream(stream_type, is_output) - local opt = opts[stream_type] or "inherit" - if opt == "inherit" then - return nil -- no error - end - - if opt == "null" then - local flags = is_output and sc.O_WRONLY or sc.O_RDONLY - local fd, err = open_dev_null(flags) - if not fd then return err end - spec[stream_type .. "_fd"] = mark_child_only(fd) - return nil - - elseif opt == "pipe" then - local rd, wr, err = make_pipe() - if not rd then return err or "pipe() failed" end - - if is_output then - -- child writes, parent reads - spec[stream_type .. "_fd"] = mark_child_only(wr) - parent_fds[stream_type] = rd - else - -- child reads, parent writes - spec[stream_type .. "_fd"] = mark_child_only(rd) - parent_fds[stream_type] = wr - end - - local ok, cerr = sc.set_cloexec(parent_fds[stream_type]) - if not ok then - return cerr or ("set_cloexec(" .. stream_type .. " parent end) failed") - end - - return nil - - elseif stream_type == "stderr" and opt == "stdout" then - -- stderr shares stdout in the child. - if opts.stdout == "inherit" or opts.stdout == nil then - spec.stderr_fd = 1 - else - -- stdout must already have been configured. - if not spec.stdout_fd then - prog_error("spawn: stderr='stdout' but stdout not configured") - end - spec.stderr_fd = spec.stdout_fd - end - return nil - - else - prog_error("spawn: invalid " .. stream_type .. " option " .. tostring(opt)) - end - end - - local err = handle_stream("stdin", false) - if err then return fail_env(err) end - - err = handle_stream("stdout", true) - if err then return fail_env(err) end - - err = handle_stream("stderr", true) - if err then return fail_env(err) end - - return spec, child_only, parent_fds.stdin, parent_fds.stdout, parent_fds.stderr, nil -end - ----------------------------------------------------------------------- --- Process spawning ----------------------------------------------------------------------- - ---- Spawn a new Process according to SpawnOptions. ----@param opts SpawnOptions ----@return Process|nil proc, string|nil err -local function spawn(opts) - assert(type(opts) == "table", "spawn: opts must be a table") - - local spec, child_only, stdin_fd, stdout_fd, stderr_fd, cfg_err = build_proc_spec(opts) - if not spec then return nil, cfg_err end - - local backend, spawn_err = proc_mod.spawn(spec) - if not backend then - for fd in pairs(child_only or {}) do sc.close(fd) end - for _, fd in ipairs{ stdin_fd, stdout_fd, stderr_fd } do - if fd then sc.close(fd) end - end - return nil, spawn_err - end - - -- Child-only ends are not needed in the parent. - if child_only then - for fd in pairs(child_only) do sc.close(fd) end - end - - local function open_stream(fd, mode) - if not fd then return nil end - return file_io.fdopen(fd, mode) - end - - local proc = setmetatable({ - backend = backend, - stdin = open_stream(stdin_fd, sc.O_WRONLY), - stdout = open_stream(stdout_fd, sc.O_RDONLY), - stderr = open_stream(stderr_fd, sc.O_RDONLY), - - _done = false, - _status = nil, - _code = nil, - _signal = nil, - _err = nil, - }, Process) - - return proc, nil -end - ---- Spawn wrapped as an Op. ----@param opts SpawnOptions ----@return Op -- when performed: proc:Process -local function spawn_op(opts) - return op.guard(function() - local proc, err = spawn(opts) - if not proc then error(err or "failed to spawn process") end - return op.always(proc) - end) -end - ----------------------------------------------------------------------- --- Waiting for exit ----------------------------------------------------------------------- - ---- Op that completes when the process exits. ---- Returns (status, code, signal, err). ----@return Op -function Process:wait_op() - local backend = assert(self.backend, "process backend is closed") - - local function step() - if self._done then - return true, self._status, self._code, self._signal, self._err - end - - local exited, status, code, sig, err = backend:nonblock_wait() - if not exited then - return false - end - - self._done = true - self._status = status - self._code = code - self._signal = sig - self._err = err - - return true, status, code, sig, err - end - - local function register(task) - local pidfd = assert(backend.pidfd, "pidfd closed") - return poller.get():wait(pidfd, "rd", task) - end - - local function wrap(status, code, sig, err) - return status, code, sig, err - end - - return wait.waitable(register, step, wrap) -end - -function Process:wait() - return perform(self:wait_op()) -end - -function Process:wait_raw() - return op.perform_raw(self:wait_op()) -end - ----------------------------------------------------------------------- --- Signalling and status ----------------------------------------------------------------------- - -function Process:kill(sig) - if not self.backend then - return false, "process backend closed" - end - return self.backend:send_signal(sig or sc.SIGKILL) -end - -function Process:terminate() - return self:kill(sc.SIGTERM) -end - -function Process:status() - if not self._done then return "running", nil end - - if self._signal then return "signalled", self._signal end - - return "exited", self._code -end - ----------------------------------------------------------------------- --- Closing and shutdown ----------------------------------------------------------------------- - -function Process:close() - for _, name in ipairs{ "stdin", "stdout", "stderr" } do - local s = self[name] - if s then - s:close() - self[name] = nil - end - end - - if self.backend then - self.backend:close() - self.backend = nil - end -end - ---- Best-effort shutdown: TERM, grace period, then KILL; wait and close. ----@param grace number|nil -- seconds before SIGKILL; default 1.0 -function Process:shutdown(grace) - grace = grace or 1.0 - - if self.backend and not self._done then - self:terminate() - - local ev = op.boolean_choice( - self:wait_op():wrap(function(status, code, sig, err) - return true, status, code, sig, err - end), - sleep.sleep_op(grace):wrap(function() - return false - end) - ) - - local ok, is_exit = safe.pcall(op.perform_raw, ev) - if not ok or not is_exit then - self:kill() - safe.pcall(function() - self:wait_raw() - end) - end - end - - self:close() -end - ----------------------------------------------------------------------- --- Bracket helper ----------------------------------------------------------------------- - ----@param spec SpawnOptions ----@param build_op fun(proc: Process): Op -local function with_process(spec, build_op) - assert(type(build_op) == "function", "with_process: build_op must be a function") - - return op.bracket( - function() - local proc, err = spawn(spec) - if not proc then - error(err or "failed to spawn process") - end - return proc - end, - function(proc, aborted) - if aborted then - proc:shutdown(1.0) - else - safe.pcall(function() - proc:wait_raw() - end) - proc:close() - end - end, - build_op - ) -end - ----------------------------------------------------------------------- --- Public API ----------------------------------------------------------------------- - -return { - Process = Process, - spawn = spawn, - spawn_op = spawn_op, - with_process = with_process, -} diff --git a/src/fibers/pollio.lua b/src/fibers/pollio.lua deleted file mode 100644 index 37cd2cc..0000000 --- a/src/fibers/pollio.lua +++ /dev/null @@ -1,202 +0,0 @@ --- Use of this source code is governed by the Apache 2.0 license; see COPYING. - --- File events. - -local op = require 'fibers.op' -local runtime = require 'fibers.runtime' -local epoll = require 'fibers.epoll' -local sc = require 'fibers.utils.syscall' -local bit = rawget(_G, "bit") or require 'bit32' - -local perform = require 'fibers.performer'.perform - -local PollIOHandler = {} -PollIOHandler.__index = PollIOHandler - -local function new_poll_io_handler() - return setmetatable( - { - epoll = epoll.new(), - waiting_for_readable = {}, -- sock descriptor => array of task - waiting_for_writable = {} - }, -- sock descriptor => array of task - PollIOHandler) -end - -function PollIOHandler:init_nonblocking(fd) - sc.set_nonblock(fd) -end - -local function add_waiter(fd, waiters, task) - local tasks = waiters[fd] - if tasks == nil then - tasks = {}; waiters[fd] = tasks - end - table.insert(tasks, task) -end - -local function make_block_fn(fd, waiting, poll, events) - return function(suspension, wrap_fn) - local task = suspension:complete_task(wrap_fn) - -- local fd = sc.fileno(fd) - add_waiter(fd, waiting, task) - poll:add(fd, events) - end -end - -function PollIOHandler:task_on_readable(fd, task) - add_waiter(fd, self.waiting_for_readable, task) - self.epoll:add(fd, epoll.RD) -end - -function PollIOHandler:task_on_writable(fd, task) - add_waiter(fd, self.waiting_for_writable, task) - self.epoll:add(fd, epoll.WR) -end - -function PollIOHandler:fd_readable_op(fd) - local function try() return false end - local block = make_block_fn( - fd, self.waiting_for_readable, self.epoll, epoll.RD) - return op.new_primitive(nil, try, block) -end - -function PollIOHandler:fd_writable_op(fd) - local function try() return false end - local block = make_block_fn( - fd, self.waiting_for_writable, self.epoll, epoll.WR) - return op.new_primitive(nil, try, block) -end - -function PollIOHandler:stream_readable_op(stream) - local fd = assert(stream.io.fd) - local function try() return not stream.rx:is_empty() end - local block = make_block_fn( - fd, self.waiting_for_readable, self.epoll, epoll.RD) - return op.new_primitive(nil, try, block) -end - --- A stream_writable_op is the same as fd_writable_op, as a stream's --- buffer is never left full -- any stream method that fills the buffer --- flushes it directly. Knowing something about the buffer state --- doesn't tell us anything useful. -function PollIOHandler:stream_writable_op(stream) - local fd = assert(stream.io.fd) - return self:fd_writable_op(fd) -end - -local function schedule_tasks(sched, tasks) - -- It's possible for tasks to be nil, as an IO error will notify for - -- both readable and writable, and maybe we only have tasks waiting - -- for one side. - if tasks == nil then return end - for i = 1, #tasks do - sched:schedule(tasks[i]) - tasks[i] = nil - end -end - --- These method is called by the fibers scheduler. -function PollIOHandler:schedule_tasks(sched, _, timeout) - if timeout == nil then timeout = 0 end - if timeout >= 0 then timeout = timeout * 1e3 end - for fd, event in pairs(self.epoll:poll(timeout)) do - if bit.band(event, epoll.RD + epoll.ERR) ~= 0 then - schedule_tasks(sched, self.waiting_for_readable[fd]) - self.waiting_for_readable[fd] = nil - end - if bit.band(event, epoll.WR + epoll.ERR) ~= 0 then - schedule_tasks(sched, self.waiting_for_writable[fd]) - self.waiting_for_writable[fd] = nil - end - local mask = 0 - if self.waiting_for_readable[fd] ~= nil then mask = bit.bor(mask, epoll.RD) end - if self.waiting_for_writable[fd] ~= nil then mask = bit.bor(mask, epoll.WR) end - if mask ~= 0 then self.epoll:add(fd, mask) end - end -end - -PollIOHandler.wait_for_events = PollIOHandler.schedule_tasks - -function PollIOHandler:cancel_tasks_for_fd(fd) - local function cancel_tasks(waiting) - local tasks = waiting[fd] - if tasks ~= nil then - for i = 1, #tasks do tasks[i]:cancel() end - waiting[fd] = nil - end - end - cancel_tasks(self.waiting_for_readable) - cancel_tasks(self.waiting_for_writable) -end - -function PollIOHandler:cancel_all_tasks() - for fd, _ in pairs(self.waiting_for_readable) do - self:cancel_tasks_for_fd(fd) - end - for fd, _ in pairs(self.waiting_for_writable) do - self:cancel_tasks_for_fd(fd) - end -end - -local installed = 0 -local installed_poll_handler -local function install_poll_io_handler() - installed = installed + 1 - if installed == 1 then - installed_poll_handler = new_poll_io_handler() - -- file.set_blocking_handler(installed_poll_handler) - runtime.current_scheduler:add_task_source(installed_poll_handler) - end - return installed_poll_handler -end - -local function uninstall_poll_io_handler() - installed = installed - 1 - if installed == 0 then - -- file.set_blocking_handler(nil) - -- FIXME: Remove task source. - for i, source in ipairs(runtime.current_scheduler.sources) do - if source == installed_poll_handler then - table.remove(runtime.current_scheduler.sources, i) - break - end - end - installed_poll_handler.epoll:close() - installed_poll_handler = nil - end -end - -local function init_nonblocking(fd) - return assert(installed_poll_handler):init_nonblocking(fd) -end -local function fd_readable_op(fd) - return assert(installed_poll_handler):fd_readable_op(fd) -end -local function fd_readable(fd) - return perform(fd_readable_op(fd)) -end -local function fd_writable_op(fd) - return assert(installed_poll_handler):fd_writable_op(fd) -end -local function fd_writable(fd) - return perform(fd_writable_op(fd)) -end -local function stream_readable_op(stream) - return assert(installed_poll_handler):stream_readable_op(stream) -end -local function stream_writable_op(stream) - return assert(installed_poll_handler):stream_writable_op(stream) -end - -return { - init_nonblocking = init_nonblocking, - fd_readable_op = fd_readable_op, - fd_readable = fd_readable, - fd_writable_op = fd_writable_op, - fd_writable = fd_writable, - stream_readable_op = stream_readable_op, - stream_writable_op = stream_writable_op, - install_poll_io_handler = install_poll_io_handler, - uninstall_poll_io_handler = uninstall_poll_io_handler -} diff --git a/src/fibers/utils/syscall.lua b/src/fibers/utils/syscall.lua index d91555c..d355c5f 100644 --- a/src/fibers/utils/syscall.lua +++ b/src/fibers/utils/syscall.lua @@ -22,8 +22,6 @@ local ffi = M.is_LuaJIT and require 'ffi' or require 'cffi' ffi.tonumber = ffi.tonumber or tonumber ffi.type = ffi.type or type -local ARCH = ffi.arch - ------------------------------------------------------------------------------- -- Compatibility functions table.pack = table.pack or function(...) -- luacheck: ignore -- Compatibility fallback @@ -237,16 +235,6 @@ function M.floatsleep(t) end end -local function wrap_error(retval) - if retval >= 0 then - return retval - else - local errno = ffi.errno() - return nil, M.strerror(errno), errno - end -end - - ------------------------------------------------------------------------------- -- FFI C structure functions (for efficiency) @@ -271,23 +259,23 @@ function M.ffi.memcmp(obj1, obj2, nbytes) return ffi.tonumber(ffi.C.memcmp(obj1, obj2, nbytes)) end --- Define syscall and pid_t -ffi.cdef [[ -long syscall(long number, ...); -typedef int pid_t; -typedef unsigned int uint; -]] +-- -- Define syscall and pid_t +-- ffi.cdef [[ +-- long syscall(long number, ...); +-- typedef int pid_t; +-- typedef unsigned int uint; +-- ]] -local SYS_pidfd_open = 434 -- Good for (almost) all our platforms -if ARCH == "mips" or ARCH == "mipsel" then - SYS_pidfd_open = 4000 + 434 -- See https://www.linux-mips.org/wiki/Syscall -end +-- local SYS_pidfd_open = 434 -- Good for (almost) all our platforms +-- if ARCH == "mips" or ARCH == "mipsel" then +-- SYS_pidfd_open = 4000 + 434 -- See https://www.linux-mips.org/wiki/Syscall +-- end --- Function to open a pidfd -function M.pidfd_open(pid, flags) - pid = ffi.new("pid_t", pid) -- Explicitly cast pid to pid_t - flags = ffi.new("uint", flags) -- Explicitly cast flgas to uint - return wrap_error(ffi.tonumber(ffi.C.syscall(SYS_pidfd_open, pid, flags))) -end +-- -- Function to open a pidfd +-- function M.pidfd_open(pid, flags) +-- pid = ffi.new("pid_t", pid) -- Explicitly cast pid to pid_t +-- flags = ffi.new("uint", flags) -- Explicitly cast flgas to uint +-- return wrap_error(ffi.tonumber(ffi.C.syscall(SYS_pidfd_open, pid, flags))) +-- end return M diff --git a/tests/test.lua b/tests/test.lua index f58a16e..c0d7696 100644 --- a/tests/test.lua +++ b/tests/test.lua @@ -10,8 +10,8 @@ local modules = { { 'io', 'mem' }, { 'io', 'stream' }, { 'io', 'socket' }, - { 'io', 'proc_backend' }, - { 'io', 'process' }, + { 'io', 'exec_backend' }, + { 'io', 'exec' }, { 'timer' }, { 'sched' }, { 'runtime' }, diff --git a/tests/test_io-exec.lua b/tests/test_io-exec.lua new file mode 100644 index 0000000..93d54c5 --- /dev/null +++ b/tests/test_io-exec.lua @@ -0,0 +1,255 @@ +-- tests/test_exec.lua +-- +-- Ad hoc tests and usage examples for fibers.io.exec. +-- +-- Run as: luajit test_exec.lua + +print("testing: fibers.io.exec") + +-- Look one level up for src modules. +package.path = "../src/?.lua;" .. package.path + +local fibers = require 'fibers' +local exec = require 'fibers.io.exec' +local op = require 'fibers.op' +local sleep = require 'fibers.sleep' + +---------------------------------------------------------------------- +-- Tests +---------------------------------------------------------------------- + +-- 1. Simple spawn: check we can see a non-zero exit code and no signal. +local function simple_exit_code() + print("running: simple_exit_code") + + local proc = exec.command{ + "sh", "-c", "exit 7", + stdin = "null", + stdout = "null", + stderr = "null", + } + assert(proc, "command creation failed") + + local status, code, sig, werr = fibers.perform(proc:run_op()) + assert(werr == nil, "wait error: " .. tostring(werr)) + assert(status == "exited", "expected status 'exited', got " .. tostring(status)) + assert(sig == nil, "expected no signal, got " .. tostring(sig)) + assert(code == 7, "expected exit code 7, got " .. tostring(code)) +end + +-- 2. stdin/stdout pipes: cat echoes what we send. +local function stdin_stdout_pipe_round_trip() + print("running: stdin_stdout_pipe_round_trip") + + local proc = exec.command{ + "sh", "-c", "cat", + stdin = "pipe", + stdout = "pipe", + stderr = "pipe", + } + assert(proc, "command creation failed") + + local stdin_stream, sin_err = proc:stdin_stream() + local stdout_stream, sout_err = proc:stdout_stream() + assert(stdin_stream and not sin_err, "expected stdin stream, got error: " .. tostring(sin_err)) + assert(stdout_stream and not sout_err, "expected stdout stream, got error: " .. tostring(sout_err)) + + local msg = "line1\nline2\n" + local n, werr = stdin_stream:write(msg) + assert(werr == nil, "write error: " .. tostring(werr)) + assert(n == #msg, "short write: " .. tostring(n)) + stdin_stream:close() -- send EOF + + local out, rerr = stdout_stream:read_all() + assert(rerr == nil, "read_all stdout error: " .. tostring(rerr)) + assert(out == msg, ("unexpected echo: %q"):format(out)) + + local status, code, sig, werr2 = fibers.perform(proc:run_op()) + assert(werr2 == nil, "wait error: " .. tostring(werr2)) + assert(status == "exited", "expected exited status") + assert(sig == nil, "expected no signal") + assert(code == 0, "expected exit 0") +end + +-- 3. stderr as separate pipe vs stderr redirected to stdout. +local function stderr_pipe_vs_stderr_is_stdout() + print("running: stderr_pipe_vs_stderr_is_stdout") + + -- Separate stderr. + local proc1 = exec.command{ + "sh", "-c", "echo out; echo err 1>&2", + stdin = "null", + stdout = "pipe", + stderr = "pipe", + } + assert(proc1, "spawn failed (separate stderr)") + + local out_stream1, oserr1 = proc1:stdout_stream() + local err_stream1, eserr1 = proc1:stderr_stream() + assert(out_stream1 and not oserr1, "stdout stream error: " .. tostring(oserr1)) + assert(err_stream1 and not eserr1, "stderr stream error: " .. tostring(eserr1)) + + local out1, oerr1 = out_stream1:read_all() + local errout1, eerr1 = err_stream1:read_all() + assert(oerr1 == nil, "stdout read error: " .. tostring(oerr1)) + assert(eerr1 == nil, "stderr read error: " .. tostring(eerr1)) + assert(out1 == "out\n", ("unexpected stdout: %q"):format(out1)) + assert(errout1 == "err\n", ("unexpected stderr: %q"):format(errout1)) + + fibers.perform(proc1:run_op()) + + -- stderr merged into stdout. + local proc2 = exec.command{ + "sh", "-c", "echo out; echo err 1>&2", + stdin = "null", + stdout = "pipe", + stderr = "stdout", + } + assert(proc2, "spawn failed (stderr=stdout)") + + local out_stream2, oserr2 = proc2:stdout_stream() + assert(out_stream2 and not oserr2, "stdout stream error: " .. tostring(oserr2)) + + local err_stream2, _ = proc2:stderr_stream() + -- When stderr is redirected to stdout, stderr_stream should return the same stream. + assert(err_stream2 == out_stream2, + "expected stderr_stream to return stdout stream when redirected") + + local merged, merr = out_stream2:read_all() + assert(merr == nil, "merged stdout read error: " .. tostring(merr)) + assert(merged:match("out"), ("merged output missing 'out': %q"):format(merged)) + assert(merged:match("err"), ("merged output missing 'err': %q"):format(merged)) + + fibers.perform(proc2:run_op()) +end + +-- 4. output_op: convenient capture of stdout plus status. +local function output_op_normal_completion() + print("running: output_op_normal_completion") + + local proc = exec.command{ + "sh", "-c", "echo bracket", + stdin = "null", + stdout = "pipe", + stderr = "pipe", + } + assert(proc, "command creation failed") + + local out, status, code, sig, err = fibers.perform(proc:output_op()) + assert(err == nil, "output_op error: " .. tostring(err)) + assert(status == "exited", "expected status 'exited'") + assert(code == 0, "expected exit code 0") + assert(sig == nil, "expected no signal") + + -- /bin/sh echo will append a newline. + assert(out == "bracket\n", ("unexpected output from output_op: %q"):format(out)) +end + +-- 5. wait_op via run_op and a simple timeout pattern using boolean_choice. +local function wait_op_with_timeout_pattern() + print("running: wait_op_with_timeout_pattern") + + local proc = exec.command{ + "/bin/sh", "-c", "sleep 0.5", + stdin = "null", + stdout = "null", + stderr = "null", + } + assert(proc, "command creation failed") + + -- Race process completion vs timeout. + local ev = op.boolean_choice( + proc:run_op(), + sleep.sleep_op(2.0) + ) + + local is_exit, status, code, sig, werr = fibers.perform(ev) + assert(is_exit == true, "process did not finish before timeout") + assert(werr == nil, "wait_op error: " .. tostring(werr)) + assert(status == "exited", "expected status 'exited'") + assert(code == 0, "expected exit code 0, got " .. tostring(code)) + -- sig may be nil; we do not insist on value. + + -- Second wait should be immediate and return the same result. + local status2, code2, sig2, werr2 = fibers.perform(proc:run_op()) + assert(status2 == status, "status changed between waits") + assert(code2 == code, "code changed between waits") + assert(sig2 == sig, "signal changed between waits") + assert(werr2 == nil, "unexpected error on second wait: " .. tostring(werr2)) +end + +-- 6. shutdown: terminate a long-running process (TERM then KILL if needed). +local function shutdown_long_running_process() + print("running: shutdown_long_running_process") + + local proc = exec.command{ + "sh", "-c", "while true; do sleep 1; done", + stdin = "null", + stdout = "null", + stderr = "null", + } + assert(proc, "command creation failed") + + local t0 = fibers.now() + local _, _, _, err = fibers.perform(proc:shutdown_op(0.2)) + local t1 = fibers.now() + + -- Ensure we did not stall. + assert((t1 - t0) < 5.0, ("shutdown took too long: %.3fs"):format(t1 - t0)) + + -- For this ad hoc test we only insist that the process reached + -- a terminal state. shutdown_op may surface backend details in `err`, + -- which we treat as diagnostic rather than fatal here. + local state, code_or_sig = proc:status() + assert( + state == "exited" or state == "signalled", + ("unexpected final state: %s (detail=%s, err=%s)") + :format(tostring(state), tostring(code_or_sig), tostring(err)) + ) +end + +-- 7. Spawning as an Op for CML-shaped code (basic usage). +local function spawn_op_basic_usage() + print("running: spawn_op_basic_usage") + + -- Build an Op that, when performed, creates a Command and returns it. + local spawn_ev = op.guard(function() + local proc = exec.command{ + "sh", "-c", "printf 'via_op'", + stdin = "null", + stdout = "pipe", + stderr = "pipe", + } + return op.always(proc) + end) + + local proc = fibers.perform(spawn_ev) + assert(proc, "spawn_ev did not return a process") + + local stdout_stream, serr = proc:stdout_stream() + assert(stdout_stream and not serr, "stdout stream error: " .. tostring(serr)) + + local out, rerr = stdout_stream:read_all() + assert(rerr == nil, "read_all error: " .. tostring(rerr)) + assert(out == "via_op", ("unexpected stdout from spawn_ev: %q"):format(out)) + + local status, code, sig, werr = fibers.perform(proc:run_op()) + assert(werr == nil, "wait error: " .. tostring(werr)) + assert(status == "exited", "expected status 'exited'") + assert(sig == nil, "expected no signal") + assert(code == 0, "expected exit 0 from spawned process") +end + +local function main() + simple_exit_code() + stdin_stdout_pipe_round_trip() + stderr_pipe_vs_stderr_is_stdout() + output_op_normal_completion() + wait_op_with_timeout_pattern() + shutdown_long_running_process() + spawn_op_basic_usage() +end + +fibers.run(main) + +print("test_exec.lua: all assertions passed") diff --git a/tests/test_io-proc_backend.lua b/tests/test_io-exec_backend.lua similarity index 72% rename from tests/test_io-proc_backend.lua rename to tests/test_io-exec_backend.lua index d04ee05..a5439e6 100644 --- a/tests/test_io-proc_backend.lua +++ b/tests/test_io-exec_backend.lua @@ -1,12 +1,14 @@ --- tests/test_proc_backend.lua +-- tests/test_exec_backend.lua +-- +-- Backend-level tests for fibers.io.exec_backend. +-- Uses the real backend but does not involve fibers or the scheduler. --- Uses the real scheduler, ops and wait/waitset machinery. -print('testing: fibers.io.proc_backend') +print('testing: fibers.io.exec_backend') -- look one level up package.path = "../src/?.lua;" .. package.path -local proc_backend = require 'fibers.io.proc_backend' +local proc_backend = require 'fibers.io.exec_backend' local sc = require 'fibers.utils.syscall' local stdlib = require 'posix.stdlib' @@ -26,12 +28,14 @@ local function read_all(fd) return table.concat(chunks) end +-- Block in a simple loop on nonblock_wait until the child is finished. +-- New backend contract: nonblock_wait() → done:boolean, code, signal, err local function wait_blocking(backend) while true do - local exited, status, code, sig, err = backend:nonblock_wait() - assert(not err, "nonblock_wait error: " .. tostring(err)) + local exited, code, sig, err = backend:nonblock_wait() + assert(err == nil, "nonblock_wait error: " .. tostring(err)) if exited then - return status, code, sig + return code, sig end end end @@ -48,14 +52,15 @@ local function test_simple_exit() stdin_fd = nil, stdout_fd = nil, stderr_fd = nil, + flags = nil, } - local backend, err = proc_backend.spawn(spec) - assert(backend, "spawn failed: " .. tostring(err)) + local backend, err = proc_backend.start(spec) + assert(backend, "start failed: " .. tostring(err)) - local status, code, sig = wait_blocking(backend) + local code, sig = wait_blocking(backend) assert(code == 7, - ("expected exit code 7, got %s (status %s)"):format(tostring(code), tostring(status))) + ("expected exit code 7, got %s"):format(tostring(code))) assert(sig == nil, "expected no terminating signal") end @@ -77,10 +82,11 @@ local function test_env_inherit() stdin_fd = nil, stdout_fd = wr, -- child stdout -> pipe write end stderr_fd = wr, -- send stderr the same way for simplicity + flags = nil, } - local backend, err = proc_backend.spawn(spec) - assert(backend, "spawn failed: " .. tostring(err)) + local backend, err = proc_backend.start(spec) + assert(backend, "start failed: " .. tostring(err)) -- Parent no longer needs write end. assert(sc.close(wr)) @@ -88,9 +94,9 @@ local function test_env_inherit() local out = read_all(rd) assert(sc.close(rd)) - local _, code, sig = wait_blocking(backend) + local code, sig = wait_blocking(backend) assert(code == 0, "expected shell to exit 0") - assert(sig == nil) + assert(sig == nil, "expected no terminating signal") assert(out == "parent_inherit\n", ("expected 'parent_inherit', got %q"):format(out)) @@ -113,19 +119,20 @@ local function test_env_override() stdin_fd = nil, stdout_fd = wr, stderr_fd = wr, + flags = nil, } - local backend, err = proc_backend.spawn(spec) - assert(backend, "spawn failed: " .. tostring(err)) + local backend, err = proc_backend.start(spec) + assert(backend, "start failed: " .. tostring(err)) assert(sc.close(wr)) local out = read_all(rd) assert(sc.close(rd)) - local _, code, sig = wait_blocking(backend) + local code, sig = wait_blocking(backend) assert(code == 0, "expected shell to exit 0") - assert(sig == nil) + assert(sig == nil, "expected no terminating signal") assert(out == "child_value\n", ("expected 'child_value', got %q"):format(out)) diff --git a/tests/test_io-process.lua b/tests/test_io-process.lua deleted file mode 100644 index cc30704..0000000 --- a/tests/test_io-process.lua +++ /dev/null @@ -1,231 +0,0 @@ --- tests/test_process.lua --- --- Ad hoc tests and usage examples for fibers.process. --- --- Run as: luajit test_process.lua - -print("testing: fibers.io.process") - --- Look one level up for src modules. -package.path = "../src/?.lua;" .. package.path - -local fibers = require 'fibers' -local process = require 'fibers.io.process' -local op = require 'fibers.op' -local sleep = require 'fibers.sleep' - ----------------------------------------------------------------------- --- Simple test harness ----------------------------------------------------------------------- - -local function run_test(name, fn) - io.stderr:write("=== ", name, " ===\n") - fibers.run(function(scope) - fn(scope) - end) -end - ----------------------------------------------------------------------- --- Tests ----------------------------------------------------------------------- - --- 1. Simple spawn: check we can see a non-zero exit code and no signal. -run_test("simple exit code", function() - local proc, err = process.spawn{ - argv = { "sh", "-c", "exit 7" }, - stdin = "null", - stdout = "null", - stderr = "null", - } - assert(proc and not err, "spawn failed: " .. tostring(err)) - - local _, code, sig, werr = proc:wait() - assert(werr == nil, "wait error: " .. tostring(werr)) - assert(sig == nil, "expected no signal, got " .. tostring(sig)) - assert(code == 7, "expected exit code 7, got " .. tostring(code)) - - proc:close() -end) - --- 2. stdin/stdout pipes: cat echoes what we send. -run_test("stdin/stdout pipe round-trip", function() - local proc, err = process.spawn{ - argv = { "sh", "-c", "cat" }, - stdin = "pipe", - stdout = "pipe", - stderr = "pipe", - } - assert(proc and not err, "spawn failed: " .. tostring(err)) - assert(proc.stdin and proc.stdout, "expected stdin/stdout streams") - - local msg = "line1\nline2\n" - local n, werr = proc.stdin:write(msg) - assert(werr == nil, "write error: " .. tostring(werr)) - assert(n == #msg, "short write: " .. tostring(n)) - proc.stdin:close() -- send EOF - - local out, rerr = proc.stdout:read_all() - assert(rerr == nil, "read_all stdout error: " .. tostring(rerr)) - assert(out == msg, ("unexpected echo: %q"):format(out)) - - local _, code, sig, werr2 = proc:wait() - assert(werr2 == nil, "wait error: " .. tostring(werr2)) - assert(sig == nil, "expected no signal") - assert(code == 0, "expected exit 0") - - proc:close() -end) - --- 3. stderr as separate pipe vs stderr redirected to stdout. -run_test("stderr pipe vs stderr=stdout", function() - -- Separate stderr. - local proc1, err1 = process.spawn{ - argv = { "sh", "-c", "echo out; echo err 1>&2" }, - stdin = "null", - stdout = "pipe", - stderr = "pipe", - } - assert(proc1 and not err1, "spawn failed: " .. tostring(err1)) - - local out1, oerr1 = proc1.stdout:read_all() - local errout1, eerr1 = proc1.stderr:read_all() - assert(oerr1 == nil, "stdout read error: " .. tostring(oerr1)) - assert(eerr1 == nil, "stderr read error: " .. tostring(eerr1)) - assert(out1 == "out\n", ("unexpected stdout: %q"):format(out1)) - assert(errout1 == "err\n", ("unexpected stderr: %q"):format(errout1)) - proc1:wait() - proc1:close() - - -- stderr merged into stdout. - local proc2, err2 = process.spawn{ - argv = { "sh", "-c", "echo out; echo err 1>&2" }, - stdin = "null", - stdout = "pipe", - stderr = "stdout", - } - assert(proc2 and not err2, "spawn failed: " .. tostring(err2)) - assert(proc2.stderr == nil, "expected stderr to be nil when redirected to stdout") - - local merged, merr = proc2.stdout:read_all() - assert(merr == nil, "merged stdout read error: " .. tostring(merr)) - assert(merged:match("out"), ("merged output missing 'out': %q"):format(merged)) - assert(merged:match("err"), ("merged output missing 'err': %q"):format(merged)) - proc2:wait() - proc2:close() -end) - --- 4. wait_op and a simple timeout pattern using boolean_choice. -run_test("wait_op with timeout pattern", function() - local proc, err = process.spawn{ - argv = { "/bin/sh", "-c", "sleep 0.5" }, - stdin = "null", - stdout = "null", - stderr = "null", - } - assert(proc and not err, "spawn failed: " .. tostring(err)) - - -- Correct use of boolean_choice: it injects the leading boolean itself. - local ev = op.boolean_choice( - proc:wait_op(), - sleep.sleep_op(2.0) - ) - - local is_exit, status, code, _, werr = fibers.perform(ev) - assert(is_exit == true, "process did not finish before timeout") - assert(werr == nil, "wait_op error: " .. tostring(werr)) - assert(code == 0, "expected exit code 0, got " .. tostring(code)) - -- sig may be nil or 0 depending on your syscall wrapper; we do not insist. - - -- Second wait should be immediate and return the same result. - local status2, code2, sig2, werr2 = proc:wait() - assert(status2 == status, "status changed between waits") - assert(code2 == code, "code changed between waits") - assert(sig2 == sig2, "signal comparison is trivial here") - assert(werr2 == nil, "unexpected error on second wait") - - proc:close() -end) - --- 5. shutdown: terminate a long-running process (TERM then KILL if needed). -run_test("shutdown long-running process", function() - local proc, err = process.spawn{ - argv = { "sh", "-c", "while true; do sleep 1; done" }, - stdin = "null", - stdout = "null", - stderr = "null", - } - assert(proc and not err, "spawn failed: " .. tostring(err)) - - local t0 = fibers.now() - proc:shutdown(0.2) - local t1 = fibers.now() - - assert((t1 - t0) < 5.0, "shutdown took too long") - - local state, code_or_sig = proc:status() - assert(state == "exited" or state == "signalled", - ("unexpected final state: %s (%s)"):format(tostring(state), tostring(code_or_sig))) -end) - --- 6. with_process: normal completion (bracket acquire/use/release). -run_test("with_process normal completion", function() - local ev = process.with_process({ - argv = { "sh", "-c", "echo bracket" }, - stdin = "null", - stdout = "pipe", - stderr = "pipe", - }, function(proc) - return proc.stdout:read_line_op() - end) - - local line, err = fibers.perform(ev) - assert(err == nil, "with_process line error: " .. tostring(err)) - assert(line == "bracket", ("unexpected line: %q"):format(line)) -end) - --- 7. with_process aborted via choice: losing arm triggers shutdown(). -run_test("with_process aborted via choice", function() - local long_spec = { - argv = { "sh", "-c", "sleep 10" }, - stdin = "null", - stdout = "null", - stderr = "null", - } - - local ev = op.boolean_choice( - process.with_process(long_spec, function(proc) - return proc:wait_op() - end), - sleep.sleep_op(0.1) - ) - - local is_first = fibers.perform(ev) - -- We expect the timeout arm to win, so is_first should be false. - assert(is_first == false, "expected timeout arm to win in boolean_choice") -end) - --- 8. spawn_op: spawning as an Op for CML-shaped code. -run_test("spawn_op basic usage", function() - local spawn_ev = process.spawn_op{ - argv = { "sh", "-c", "printf 'via_op'" }, - stdin = "null", - stdout = "pipe", - stderr = "pipe", - } - - local proc = fibers.perform(spawn_ev) - assert(proc, "spawn_op did not return a process") - - local out, rerr = proc.stdout:read_all() - assert(rerr == nil, "read_all error: " .. tostring(rerr)) - assert(out == "via_op", ("unexpected stdout from spawn_op: %q"):format(out)) - - local _, code, sig, werr = proc:wait() - assert(werr == nil, "wait error: " .. tostring(werr)) - assert(sig == nil, "expected no signal") - assert(code == 0, "expected exit 0 from spawn_op process") - - proc:close() -end) - -io.stderr:write("All process tests completed.\n") From e56675ab6041e468decabe8820aa755ca382749d Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sat, 6 Dec 2025 01:11:03 +0000 Subject: [PATCH 069/138] makes top-level io os agnostic --- src/fibers/io/exec_backend/sigchld.lua | 7 + src/fibers/io/exec_backend/stdio.lua | 18 +- src/fibers/io/fd_backend/core.lua | 246 ++++++++++----- src/fibers/io/fd_backend/ffi.lua | 366 +++++++++++++++++++++- src/fibers/io/fd_backend/posix.lua | 410 +++++++++++++++++++++---- src/fibers/io/file.lua | 210 ++++++------- src/fibers/io/poller.lua | 2 +- src/fibers/io/poller/select.lua | 2 +- src/fibers/io/socket.lua | 202 ++++++------ src/fibers/io/stream.lua | 2 +- tests/test_io-exec_backend.lua | 139 +++++---- 11 files changed, 1165 insertions(+), 439 deletions(-) diff --git a/src/fibers/io/exec_backend/sigchld.lua b/src/fibers/io/exec_backend/sigchld.lua index d8dfda7..936c643 100644 --- a/src/fibers/io/exec_backend/sigchld.lua +++ b/src/fibers/io/exec_backend/sigchld.lua @@ -432,9 +432,16 @@ local function spawn(spec) end local function poll(state) + -- Fast path: if the child is already marked as exited, just report it. + if state.exited then + return true, state.code, state.signal, state.err + end + -- Ensure progress even when no scheduler is running + reap_known_children() return poll_state(state) end + local function register_wait(state, task, _, _) start_reaper() return waiters:add(state.pid, task) diff --git a/src/fibers/io/exec_backend/stdio.lua b/src/fibers/io/exec_backend/stdio.lua index 35c436d..4254430 100644 --- a/src/fibers/io/exec_backend/stdio.lua +++ b/src/fibers/io/exec_backend/stdio.lua @@ -73,8 +73,11 @@ function M.build_child_stdio(spec, open_dev_null, make_pipe, set_cloexec, close_ --- Configure a single stdio stream. --- kind: "stdin" | "stdout" | "stderr" - --- cfg : ExecStreamConfig + --- cfg : ExecStreamConfig|nil local function configure_stream(kind, cfg) + -- Allow callers to omit stdin/stdout/stderr in the spec; treat as inherit. + cfg = cfg or { mode = "inherit" } + local is_output = (kind ~= "stdin") local field = kind .. "_fd" local mode = cfg.mode or "inherit" @@ -90,8 +93,8 @@ function M.build_child_stdio(spec, open_dev_null, make_pipe, set_cloexec, close_ if not fd then return false, err end - child_only[fd] = true - child_spec[field] = fd + child_only[fd] = true + child_spec[field] = fd return true -- pipe (child ↔ parent) @@ -131,10 +134,12 @@ function M.build_child_stdio(spec, open_dev_null, make_pipe, set_cloexec, close_ -- stderr = "stdout" elseif kind == "stderr" and mode == "stdout" then - local out_cfg = spec.stdout - local out_mode = out_cfg and (out_cfg.mode or "inherit") or "inherit" + -- stdout config may itself be missing; default to inherit in that case. + local out_cfg = spec.stdout or { mode = "inherit" } + local out_mode = out_cfg.mode or "inherit" + if out_mode == "inherit" and child_spec.stdout_fd == nil then - -- Share the inherited stdout (fd 1). + -- Share inherited stdout (fd 1). child_spec.stderr_fd = 1 elseif child_spec.stdout_fd ~= nil then -- Share whatever stdout was configured to use. @@ -149,6 +154,7 @@ function M.build_child_stdio(spec, open_dev_null, make_pipe, set_cloexec, close_ end end + do local ok, err = configure_stream("stdin", spec.stdin) if not ok then diff --git a/src/fibers/io/fd_backend/core.lua b/src/fibers/io/fd_backend/core.lua index 64ae2e8..44c5a62 100644 --- a/src/fibers/io/fd_backend/core.lua +++ b/src/fibers/io/fd_backend/core.lua @@ -1,49 +1,22 @@ -- fibers/io/fd_backend/core.lua --- --- Core glue for fd-backed StreamBackend implementations. --- --- This module owns the public FdBackend shape and semantics. --- Platform backends provide only low-level primitives; build_backend --- wires those into a concrete { new, is_supported } module. --- ----@module 'fibers.io.fd_backend.core' local poller = require 'fibers.io.poller' ---@class FdBackend ----@field filename string|nil -- optional filename for diagnostics ----@field _fd integer|nil -- underlying OS file descriptor (or nil if closed) ----@field _ops table -- low-level operations table (see build_backend) - +---@field filename string|nil +---@field _fd integer|nil +---@field _ops table local FdBackend = {} FdBackend.__index = FdBackend ----------------------------------------------------------------------- --- Public methods (contract lives here) ----------------------------------------------------------------------- - ---- Backend kind identifier. ----@return '"fd"' function FdBackend:kind() return "fd" end ---- Underlying file descriptor number, or nil if closed. ----@return integer|nil function FdBackend:fileno() return self._fd end ---- Read up to max bytes as a Lua string. ---- ---- Semantics: ---- * s == nil, err == nil : would block ---- * s == nil, err ~= nil : hard error ---- * s == "" : EOF ---- * s ~= "" : data ---- ----@param max? integer ----@return string|nil s, string|nil err function FdBackend:read_string(max) if not self._fd then return nil, "closed" @@ -51,22 +24,12 @@ function FdBackend:read_string(max) max = max or 4096 if max <= 0 then - -- Matches existing posix/ffi backends. return "", nil end return self._ops.read(self._fd, max) end ---- Write a Lua string. ---- ---- Semantics: ---- * n == nil, err == nil : would block ---- * n == nil, err ~= nil : hard error ---- * n >= 0 : bytes written ---- ----@param str string ----@return integer|nil n, string|nil err function FdBackend:write_string(str) if not self._fd then return nil, "closed" @@ -74,19 +37,12 @@ function FdBackend:write_string(str) local len = #str if len == 0 then - -- Existing behaviour: zero-length write is a cheap no-op. return 0, nil end return self._ops.write(self._fd, str, len) end ---- Seek within the file descriptor. ---- ---- whence: "set" | "cur" | "end" ----@param whence '"set"'|'"cur"'|'"end"' ----@param off integer ----@return integer|nil pos, string|nil err function FdBackend:seek(whence, off) if not self._fd then return nil, "closed" @@ -94,27 +50,14 @@ function FdBackend:seek(whence, off) return self._ops.seek(self._fd, whence, off) end ---- Register for readability events on this fd. ----@param task Task ----@return WaitToken function FdBackend:on_readable(task) return poller.get():wait(assert(self._fd, "closed fd"), "rd", task) end ---- Register for writability events on this fd. ----@param task Task ----@return WaitToken function FdBackend:on_writable(task) return poller.get():wait(assert(self._fd, "closed fd"), "wr", task) end ---- Close the backend and underlying fd. ---- ---- Returns: ---- ok : true on success, false on failure ---- err : error string or nil ---- ----@return boolean ok, string|nil err function FdBackend:close() if self._fd == nil then return true, nil @@ -123,8 +66,6 @@ function FdBackend:close() local fd = self._fd self._fd = nil - -- Matches old behaviour: fd is considered closed from the Lua side - -- even if close() reports an error. return self._ops.close(fd) end @@ -134,17 +75,40 @@ end --- Build a concrete fd backend module from low-level ops. --- ---- ops must provide: ---- set_nonblock(fd) -> ok, err|nil, errno|nil +--- Required ops: +--- set_nonblock(fd) -> ok:boolean, err|nil --- read(fd, max) -> s|nil, err|nil ---- write(fd, str, len) -> n|nil, err|nil +--- write(fd, s, len)-> n|nil, err|nil --- seek(fd, whence, off) -> pos|nil, err|nil ---- close(fd) -> ok, err|nil +--- close(fd) -> ok:boolean, err|nil +--- +--- Optional file ops (used by fibers.io.file): +--- open_file(path, mode, perms) -> fd|nil, err|nil +--- pipe() -> rd_fd|nil, wr_fd|nil, err|nil +--- mktemp(prefix, perms) -> fd|nil, tmpname_or_err +--- fsync(fd) -> ok:boolean, err|nil +--- rename(old, new) -> ok:boolean, err|nil +--- unlink(path) -> ok:boolean, err|nil +--- decode_access(flags) -> readable:boolean, writable:boolean +--- ignore_sigpipe() -> ok:boolean, err|nil --- ---- ops.is_supported() -> boolean (optional) +--- Optional socket ops (used by fibers.io.socket): +--- socket(domain, stype, protocol) -> fd|nil, err|nil +--- bind(fd, sa) -> ok:boolean, err|nil +--- listen(fd) -> ok:boolean, err|nil +--- accept(fd) -> newfd|nil, err|nil, again:boolean +--- connect_start(fd, sa) -> ok:boolean|nil, err|nil, inprogress:boolean +--- connect_finish(fd) -> ok:boolean, err|nil +--- +--- Optional metadata: +--- modes : table +--- permissions : table +--- AF_UNIX : integer +--- SOCK_STREAM : integer +--- is_supported() -> boolean --- ---@param ops table ----@return table backend_module -- { new = fn, is_supported = fn } +---@return table backend_module local function build_backend(ops) local required = { "set_nonblock", "read", "write", "seek", "close" } for _, k in ipairs(required) do @@ -178,9 +142,151 @@ local function build_backend(ops) return true end + -------------------------------------------------------------------- + -- File-level helpers + -------------------------------------------------------------------- + + local function open_file(path, mode, perms) + assert(type(ops.open_file) == "function", + "fd_backend backend does not implement open_file") + return ops.open_file(path, mode, perms) + end + + local function pipe() + assert(type(ops.pipe) == "function", + "fd_backend backend does not implement pipe") + return ops.pipe() + end + + local function mktemp(prefix, perms) + assert(type(ops.mktemp) == "function", + "fd_backend backend does not implement mktemp") + return ops.mktemp(prefix, perms) + end + + local function fsync(fd) + if not ops.fsync then + return true, nil + end + return ops.fsync(fd) + end + + local function rename(oldpath, newpath) + assert(type(ops.rename) == "function", + "fd_backend backend does not implement rename") + return ops.rename(oldpath, newpath) + end + + local function unlink(path) + assert(type(ops.unlink) == "function", + "fd_backend backend does not implement unlink") + return ops.unlink(path) + end + + local function decode_access(flags) + if not ops.decode_access then + error("fd_backend backend does not implement decode_access") + end + return ops.decode_access(flags) + end + + local function ignore_sigpipe() + if ops.ignore_sigpipe then + return ops.ignore_sigpipe() + end + return true, nil + end + + local function init_nonblocking(fd) + return ops.set_nonblock(fd) + end + + local function close_fd(fd) + return ops.close(fd) + end + + -------------------------------------------------------------------- + -- Socket-level helpers (optional) + -------------------------------------------------------------------- + + local function socket(domain, stype, protocol) + if not ops.socket then + error("fd_backend backend does not implement socket()") + end + return ops.socket(domain, stype, protocol or 0) + end + + local function bind(fd, sa) + if not ops.bind then + error("fd_backend backend does not implement bind()") + end + return ops.bind(fd, sa) + end + + local function listen(fd) + if not ops.listen then + error("fd_backend backend does not implement listen()") + end + return ops.listen(fd) + end + + --- accept(fd) -> newfd|nil, err|nil, again:boolean + local function accept(fd) + if not ops.accept then + error("fd_backend backend does not implement accept()") + end + local newfd, err, again = ops.accept(fd) + return newfd, err, again + end + + --- connect_start(fd, sa) -> ok|nil, err|nil, inprogress:boolean + local function connect_start(fd, sa) + if not ops.connect_start then + error("fd_backend backend does not implement connect_start()") + end + local ok, err, inprogress = ops.connect_start(fd, sa) + return ok, err, inprogress + end + + --- connect_finish(fd) -> ok:boolean, err|nil + local function connect_finish(fd) + if not ops.connect_finish then + error("fd_backend backend does not implement connect_finish()") + end + return ops.connect_finish(fd) + end + return { - new = new, - is_supported = is_supported, + new = new, + is_supported = is_supported, + + -- low-level helper + set_nonblock = init_nonblocking, + close_fd = close_fd, + + -- file-level helpers + open_file = open_file, + pipe = pipe, + mktemp = mktemp, + fsync = fsync, + rename = rename, + unlink = unlink, + decode_access = decode_access, + ignore_sigpipe = ignore_sigpipe, + + -- socket-level helpers + socket = socket, + bind = bind, + listen = listen, + accept = accept, + connect_start = connect_start, + connect_finish = connect_finish, + + -- metadata (if provided) + modes = ops.modes or {}, + permissions = ops.permissions or {}, + AF_UNIX = ops.AF_UNIX, + SOCK_STREAM = ops.SOCK_STREAM, } end diff --git a/src/fibers/io/fd_backend/ffi.lua b/src/fibers/io/fd_backend/ffi.lua index 3b7bcfd..94d2604 100644 --- a/src/fibers/io/fd_backend/ffi.lua +++ b/src/fibers/io/fd_backend/ffi.lua @@ -35,18 +35,74 @@ ffi.cdef[[ int close(int fd); int fcntl(int fd, int cmd, ...); char *strerror(int errnum); + + int open(const char *pathname, int flags, int mode); + int pipe(int pipefd[2]); + int fsync(int fd); + int rename(const char *oldpath, const char *newpath); + int unlink(const char *pathname); + + typedef void (*sighandler_t)(int); + sighandler_t signal(int signum, sighandler_t handler); + + /* socket API */ + typedef unsigned short sa_family_t; + typedef unsigned int socklen_t; + + struct sockaddr { + sa_family_t sa_family; + char sa_data[14]; + }; + + struct sockaddr_un { + sa_family_t sun_family; + char sun_path[108]; + }; + + int socket(int domain, int type, int protocol); + int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); + int listen(int sockfd, int backlog); + int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); + int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen); + int getsockopt(int sockfd, int level, int optname, void *optval, socklen_t *optlen); ]] -- POSIX fcntl command numbers on Linux. local F_GETFL = 3 local F_SETFL = 4 --- Linux O_NONBLOCK (matches luaposix O_NONBLOCK on this platform). +-- Linux O_* constants (values as on glibc/Linux; adjust if you support other ABIs). +local O_RDONLY = 0x0000 +local O_WRONLY = 0x0001 +local O_RDWR = 0x0002 +local O_ACCMODE = 0x0003 +local O_CREAT = 0x0040 +local O_EXCL = 0x0080 +local O_TRUNC = 0x0200 +local O_APPEND = 0x0400 local O_NONBLOCK = 0x00000800 --- Errno values: EAGAIN/EWOULDBLOCK are both 11 on Linux. +-- Permission bits (standard POSIX values). +local S_IRUSR = 0x0100 +local S_IWUSR = 0x0080 +local S_IRGRP = 0x0020 +local S_IROTH = 0x0004 +local S_IWGRP = 0x0010 +local S_IWOTH = 0x0002 + +-- Errno values (Linux). local EAGAIN = 11 local EWOULDBLOCK = 11 +local EINPROGRESS = 115 + +-- Socket constants (Linux ABI). +local AF_UNIX = 1 +local SOCK_STREAM = 1 +local SOL_SOCKET = 1 +local SO_ERROR = 4 +local SOMAXCONN = 128 + +local SIGPIPE = 13 local function strerror(e) local s = C.strerror(e) @@ -77,7 +133,7 @@ local function set_nonblock(fd) return false, ("F_SETFL failed: %s"):format(strerror(e)), e end - -- Sanity check. + -- Optional sanity check. local after = toint(getfl_fp(fd, F_GETFL)) if after < 0 then local e = get_errno() @@ -101,7 +157,6 @@ end local SEEK = { set = 0, cur = 1, ["end"] = 2 } local function read_fd(fd, max) - -- max > 0 and fd non-nil guaranteed by core. local buf = ffi.new("char[?]", max) local n = toint(C.read(fd, buf, max)) @@ -125,7 +180,6 @@ local function read_fd(fd, max) end local function write_fd(fd, str, len) - -- len > 0 and fd non-nil guaranteed by core. local buf = ffi.new("char[?]", len) ffi.copy(buf, str, len) @@ -163,13 +217,303 @@ local function close_fd(fd) return true, nil end +---------------------------------------------------------------------- +-- File-level helpers +---------------------------------------------------------------------- + +local function open_fd(path, flags, perms) + local c_path = ffi.new("char[?]", #path + 1) + ffi.copy(c_path, path) + local fd = toint(C.open(c_path, flags, perms or 0)) + if fd < 0 then + local e = get_errno() + return nil, strerror(e) + end + return fd, nil +end + +local function pipe_fd() + local fds = ffi.new("int[2]") + local rc = toint(C.pipe(fds)) + if rc ~= 0 then + local e = get_errno() + return nil, nil, strerror(e) + end + return toint(fds[0]), toint(fds[1]), nil +end + +local function fsync_fd(fd) + local rc = toint(C.fsync(fd)) + if rc ~= 0 then + local e = get_errno() + return false, strerror(e) + end + return true, nil +end + +local function rename_file(oldpath, newpath) + local c_old = ffi.new("char[?]", #oldpath + 1) + ffi.copy(c_old, oldpath) + local c_new = ffi.new("char[?]", #newpath + 1) + ffi.copy(c_new, newpath) + + local rc = toint(C.rename(c_old, c_new)) + if rc ~= 0 then + local e = get_errno() + return false, strerror(e) + end + return true, nil +end + +local function unlink_file(path) + local c_path = ffi.new("char[?]", #path + 1) + ffi.copy(c_path, path) + + local rc = toint(C.unlink(c_path)) + if rc ~= 0 then + local e = get_errno() + return false, strerror(e) + end + return true, nil +end + +-- Mode and permission tables mirror the old file.lua behaviour, +-- but live in the backend. + +---@type table +local modes = { + r = O_RDONLY, + w = bit.bor(O_WRONLY, O_CREAT, O_TRUNC), + a = bit.bor(O_WRONLY, O_CREAT, O_APPEND), + ["r+"] = O_RDWR, + ["w+"] = bit.bor(O_RDWR, O_CREAT, O_TRUNC), + ["a+"] = bit.bor(O_RDWR, O_CREAT, O_APPEND), +} + +do + local binary_modes = {} + for k, v in pairs(modes) do + binary_modes[k .. "b"] = v + end + for k, v in pairs(binary_modes) do + modes[k] = v + end +end + +---@type table +local permissions = {} +permissions["rw-r--r--"] = bit.bor(S_IRUSR, S_IWUSR, S_IRGRP, S_IROTH) +permissions["rw-rw-rw-"] = bit.bor(permissions["rw-r--r--"], S_IWGRP, S_IWOTH) + +local function open_file(path, mode, perms) + mode = mode or "r" + local flags = modes[mode] + if not flags then + return nil, "invalid mode: " .. tostring(mode) + end + + local p + if perms == nil then + p = permissions["rw-rw-rw-"] + elseif type(perms) == "string" then + p = permissions[perms] or perms + else + p = perms + end + + return open_fd(path, flags, p) +end + +local function mktemp(prefix, perms) + -- Normalise perms: nil -> default, string -> lookup in permissions table. + if perms == nil then + perms = permissions["rw-r--r--"] + elseif type(perms) == "string" then + perms = permissions[perms] or perms + end + + -- Caller is responsible for seeding math.random appropriately. + local start = math.random(1e7) + local tmpnam, fd, err + + for i = start, start + 10 do + tmpnam = prefix .. "." .. i + fd, err = open_fd(tmpnam, bit.bor(O_CREAT, O_RDWR, O_EXCL), perms) + if fd then + return fd, tmpnam + end + end + + return nil, ("failed to create temporary file %s: %s"):format( + tostring(tmpnam), + tostring(err) + ) +end + +local function decode_access(flags) + local acc = bit.band(flags, O_ACCMODE) + if acc == O_RDONLY then + return true, false + elseif acc == O_WRONLY then + return false, true + elseif acc == O_RDWR then + return true, true + end + -- Fallback: if we cannot interpret, assume read/write. + return true, true +end + +local function ignore_sigpipe() + -- Best-effort ignore of SIGPIPE. If this fails, we treat it as non-fatal. + local handler_t = ffi.typeof("sighandler_t") + local SIG_IGN = ffi.cast(handler_t, 1) + + local old = C.signal(SIGPIPE, SIG_IGN) + if old == nil then + local e = get_errno() + return false, strerror(e) + end + return true, nil +end + +---------------------------------------------------------------------- +-- Socket helpers (AF_UNIX, SOCK_STREAM, path string sockaddr) +---------------------------------------------------------------------- + +local function make_sockaddr_un(path) + local sa = ffi.new("struct sockaddr_un") + sa.sun_family = AF_UNIX + + local maxlen = 108 - 1 + local p = path + if #p > maxlen then + p = p:sub(1, maxlen) + end + ffi.fill(sa.sun_path, 108) + ffi.copy(sa.sun_path, p) + + -- Full struct size is fine for bind/connect. + local len = ffi.sizeof("struct sockaddr_un") + return sa, len +end + +local function socket_fd(domain, stype, protocol) + local fd = toint(C.socket(domain, stype, protocol or 0)) + if fd < 0 then + local e = get_errno() + return nil, strerror(e), e + end + return fd, nil, nil +end + +local function bind_fd(fd, sa) + -- For now, sa is expected to be a UNIX-domain path string. + if type(sa) ~= "string" then + return false, "unsupported sockaddr representation", nil + end + local c_sa, len = make_sockaddr_un(sa) + local rc = toint(C.bind(fd, ffi.cast("struct sockaddr *", c_sa), len)) + if rc ~= 0 then + local e = get_errno() + return false, strerror(e), e + end + return true, nil, nil +end + +local function listen_fd(fd) + local rc = toint(C.listen(fd, SOMAXCONN)) + if rc ~= 0 then + local e = get_errno() + return false, strerror(e), e + end + return true, nil, nil +end + +--- accept(fd) -> newfd|nil, err|nil, again:boolean +local function accept_fd(fd) + local new_fd = toint(C.accept(fd, nil, nil)) + if new_fd < 0 then + local e = get_errno() + if e == EAGAIN or e == EWOULDBLOCK then + return nil, nil, true + end + return nil, strerror(e), false + end + return new_fd, nil, false +end + +--- connect_start(fd, sa) -> ok|nil, err|nil, inprogress:boolean +local function connect_start_fd(fd, sa) + if type(sa) ~= "string" then + return nil, "unsupported sockaddr representation", false + end + local c_sa, len = make_sockaddr_un(sa) + local rc = toint(C.connect(fd, ffi.cast("struct sockaddr *", c_sa), len)) + if rc == 0 then + return true, nil, false + end + local e = get_errno() + if e == EINPROGRESS then + return nil, nil, true + end + return nil, strerror(e), false +end + +--- connect_finish(fd) -> ok:boolean, err|nil +local function connect_finish_fd(fd) + local errval = ffi.new("int[1]") + local sz = ffi.new("socklen_t[1]", ffi.sizeof("int")) + local rc = toint(C.getsockopt(fd, SOL_SOCKET, SO_ERROR, errval, sz)) + if rc ~= 0 then + local e = get_errno() + return false, strerror(e) + end + local soerr = errval[0] + if soerr == 0 then + return true, nil + end + return false, strerror(soerr) +end + +---------------------------------------------------------------------- +-- Capability probe +---------------------------------------------------------------------- + +local function is_supported() + return true +end + local ops = { - set_nonblock = set_nonblock, - read = read_fd, - write = write_fd, - seek = seek_fd, - close = close_fd, - is_supported = function() return true end, + set_nonblock = set_nonblock, + read = read_fd, + write = write_fd, + seek = seek_fd, + close = close_fd, + + open_file = open_file, + pipe = pipe_fd, + mktemp = mktemp, + fsync = fsync_fd, + rename = rename_file, + unlink = unlink_file, + decode_access = decode_access, + ignore_sigpipe = ignore_sigpipe, + + -- socket ops + socket = socket_fd, + bind = bind_fd, + listen = listen_fd, + accept = accept_fd, + connect_start = connect_start_fd, + connect_finish = connect_finish_fd, + + modes = modes, + permissions = permissions, + + AF_UNIX = AF_UNIX, + SOCK_STREAM = SOCK_STREAM, + + is_supported = is_supported, } return core.build_backend(ops) diff --git a/src/fibers/io/fd_backend/posix.lua b/src/fibers/io/fd_backend/posix.lua index 16d7153..9c3aa3e 100644 --- a/src/fibers/io/fd_backend/posix.lua +++ b/src/fibers/io/fd_backend/posix.lua @@ -1,120 +1,398 @@ -- fibers/io/fd_backend/posix.lua -- --- luaposix-based FD backend for files/sockets. --- Intended as a fallback where FFI is unavailable or undesired. +-- luaposix-based FD backend (no FFI). +-- Intended to be selected via fibers.io.fd_backend. -- ---@module 'fibers.io.fd_backend.posix' -local core = require 'fibers.io.fd_backend.core' +local core = require 'fibers.io.fd_backend.core' -local ok_unistd, unistd = pcall(require, 'posix.unistd') -local ok_fcntl, fcntl = pcall(require, 'posix.fcntl') -local ok_errno, errno = pcall(require, 'posix.errno') +local unistd = require 'posix.unistd' +local stdio = require 'posix.stdio' +local fcntl = require 'posix.fcntl' +local pstat = require 'posix.sys.stat' +local errno = require 'posix.errno' +local psig = require 'posix.signal' +local socket_mod = require 'posix.sys.socket' -if not (ok_unistd and ok_fcntl and ok_errno) then - return { - is_supported = function() return false end, - } -end - -local bit = rawget(_G, "bit") or require 'bit32' +local bit = rawget(_G, "bit") or require 'bit32' -local EAGAIN = errno.EAGAIN -local EWOULDBLOCK = errno.EWOULDBLOCK or errno.EAGAIN - -local SEEK_SET = fcntl.SEEK_SET or 0 -local SEEK_CUR = fcntl.SEEK_CUR or 1 -local SEEK_END = fcntl.SEEK_END or 2 +local function errno_msg(prefix, err, eno) + if err and err ~= "" then + return err + end + if eno then + return ("%s (errno %d)"):format(prefix, eno) + end + return prefix +end ---------------------------------------------------------------------- --- Non-blocking helper +-- set_nonblock / basic ops ---------------------------------------------------------------------- local function set_nonblock(fd) - local flags, err, en = fcntl.fcntl(fd, fcntl.F_GETFL) + local flags, err, eno = fcntl.fcntl(fd, fcntl.F_GETFL) if flags == nil then - return nil, err or ("fcntl(F_GETFL) errno " .. tostring(en)), en + return false, errno_msg("fcntl(F_GETFL)", err, eno), eno end - - local newflags = bit.bor(flags, fcntl.O_NONBLOCK) - local ok2, err2, en2 = fcntl.fcntl(fd, fcntl.F_SETFL, newflags) - if ok2 == nil then - return nil, err2 or ("fcntl(F_SETFL) errno " .. tostring(en2)), en2 + local newflags = bit.bor(flags, fcntl.O_NONBLOCK) + local ok, err2, eno2 = fcntl.fcntl(fd, fcntl.F_SETFL, newflags) + if ok == nil then + return false, errno_msg("fcntl(F_SETFL)", err2, eno2), eno2 end - return true, nil, nil end ----------------------------------------------------------------------- --- Low-level ops implementing the core contract ----------------------------------------------------------------------- - -local SEEK = { - set = SEEK_SET, - cur = SEEK_CUR, - ["end"] = SEEK_END, -} - local function read_fd(fd, max) - -- max > 0 and fd non-nil guaranteed by core. - local s, err, en = unistd.read(fd, max) + local s, err, eno = unistd.read(fd, max) if s == nil then - if en == EAGAIN or en == EWOULDBLOCK then - return nil, nil -- would block + if eno == errno.EAGAIN or eno == errno.EWOULDBLOCK then + return nil, nil end - return nil, err or ("errno " .. tostring(en)) + return nil, errno_msg("read failed", err, eno) end - - -- s may be "" at EOF. return s, nil end local function write_fd(fd, str, _len) - local n, err, en = unistd.write(fd, str) + local n, err, eno = unistd.write(fd, str) if n == nil then - if en == EAGAIN or en == EWOULDBLOCK then - return nil, nil -- would block + if eno == errno.EAGAIN or eno == errno.EWOULDBLOCK then + return nil, nil end - return nil, err or ("errno " .. tostring(en)) + return nil, errno_msg("write failed", err, eno) end - return n, nil end +local SEEK = { set = unistd.SEEK_SET, cur = unistd.SEEK_CUR, ["end"] = unistd.SEEK_END } + local function seek_fd(fd, whence, off) local w = SEEK[whence] if not w then return nil, "bad whence: " .. tostring(whence) end - - local pos, err, en = unistd.lseek(fd, off, w) + local pos, err, eno = unistd.lseek(fd, off, w) if pos == nil then - return nil, err or ("errno " .. tostring(en)) + return nil, errno_msg("lseek failed", err, eno) end return pos, nil end local function close_fd(fd) - local ok, err, en = unistd.close(fd) - if ok == nil or ok == false then - return false, err or ("errno " .. tostring(en)) + local ok, err, eno = unistd.close(fd) + if ok == nil then + return false, errno_msg("close failed", err, eno) end return true, nil end -local ops = { - set_nonblock = set_nonblock, - read = read_fd, - write = write_fd, - seek = seek_fd, - close = close_fd, +---------------------------------------------------------------------- +-- File-level helpers +---------------------------------------------------------------------- + +-- Mode and permission tables as before, but using POSIX constants. + +local modes = { + r = fcntl.O_RDONLY, + w = bit.bor(fcntl.O_WRONLY, fcntl.O_CREAT, fcntl.O_TRUNC), + a = bit.bor(fcntl.O_WRONLY, fcntl.O_CREAT, fcntl.O_APPEND), + ["r+"] = fcntl.O_RDWR, + ["w+"] = bit.bor(fcntl.O_RDWR, fcntl.O_CREAT, fcntl.O_TRUNC), + ["a+"] = bit.bor(fcntl.O_RDWR, fcntl.O_CREAT, fcntl.O_APPEND), } -local backend = core.build_backend(ops) +do + local binary_modes = {} + for k, v in pairs(modes) do + binary_modes[k .. "b"] = v + end + for k, v in pairs(binary_modes) do + modes[k] = v + end +end + +local permissions = {} +permissions["rw-r--r--"] = bit.bor(pstat.S_IRUSR, pstat.S_IWUSR, pstat.S_IRGRP, pstat.S_IROTH) +permissions["rw-rw-rw-"] = bit.bor(permissions["rw-r--r--"], pstat.S_IWGRP, pstat.S_IWOTH) + +local function open_file(path, mode, perms) + mode = mode or "r" + local flags = modes[mode] + if not flags then + return nil, "invalid mode: " .. tostring(mode) + end + + local p + if perms == nil then + p = permissions["rw-rw-rw-"] + elseif type(perms) == "string" then + p = permissions[perms] or perms + else + p = perms + end --- Preserve explicit is_supported for symmetry with ffi backend. -backend.is_supported = function() + local fd, err, eno = fcntl.open(path, flags, p) + if not fd then + return nil, errno_msg("open failed", err, eno) + end + return fd, nil +end + +local function pipe_fds() + local rd, wr, err, eno = unistd.pipe() + if not rd then + return nil, nil, errno_msg("pipe failed", err, eno) + end + return rd, wr, nil +end + +local function mktemp(prefix, perms) + -- Normalise perms: nil -> default, string -> lookup in permissions table. + if perms == nil then + perms = permissions["rw-r--r--"] + elseif type(perms) == "string" then + perms = permissions[perms] or perms + end + + local start = math.random(1e7) + local tmpnam, fd, err, eno + + for i = start, start + 10 do + tmpnam = prefix .. "." .. i + fd, err, eno = fcntl.open( + tmpnam, + bit.bor(fcntl.O_CREAT, fcntl.O_RDWR, fcntl.O_EXCL), + perms + ) + if fd then + return fd, tmpnam + end + end + + return nil, ("failed to create temporary file %s: %s"):format( + tostring(tmpnam), + tostring(errno_msg("open", err, eno)) + ) +end + +local function fsync_fd(fd) + local ok, err, eno = unistd.fsync(fd) + if ok == nil then + return false, errno_msg("fsync failed", err, eno) + end + return true, nil +end + +local function rename_file(oldpath, newpath) + local ok, err, eno = stdio.rename(oldpath, newpath) + if ok == nil then + return false, errno_msg("rename failed", err, eno) + end + return true, nil +end + +local function unlink_file(path) + local ok, err, eno = unistd.unlink(path) + if ok == nil then + return false, errno_msg("unlink failed", err, eno) + end + return true, nil +end + +local function decode_access(flags) + local o_wr = fcntl.O_WRONLY or 0 + local o_rdwr = fcntl.O_RDWR or 0 + local readable + if o_wr ~= 0 then + readable = (bit.band(flags, o_wr) ~= o_wr) + else + readable = true + end + + local writable = false + if o_wr ~= 0 and bit.band(flags, o_wr) ~= 0 then + writable = true + end + if o_rdwr ~= 0 and bit.band(flags, o_rdwr) ~= 0 then + writable = true + end + + if not readable and not writable then + readable = true + end + + return readable, writable +end + +local function ignore_sigpipe() + local ok, err, eno = psig.signal(psig.SIGPIPE, psig.SIG_IGN) + if ok == nil then + return false, errno_msg("signal(SIGPIPE)", err, eno) + end + return true, nil +end + +---------------------------------------------------------------------- +-- Socket helpers on top of posix.sys.socket +---------------------------------------------------------------------- + +---------------------------------------------------------------------- +-- Socket helpers on top of posix.sys.socket (AF_UNIX focus) +---------------------------------------------------------------------- + +--- Create a socket fd. +---@param domain integer +---@param stype integer +---@param protocol? integer +---@return integer|nil fd, string|nil err, integer|nil eno +local function socket_fd(domain, stype, protocol) + local fd, err, eno = socket_mod.socket(domain, stype, protocol or 0) + if fd == nil then + return nil, errno_msg("socket failed", err, eno), eno + end + return fd, nil, nil +end + +--- Bind a socket to an address token. +--- +--- For AF_UNIX, we treat sa as a path string. +---@param fd integer +---@param sa any +---@return boolean ok, string|nil err, integer|nil eno +local function bind_fd(fd, sa) + local addr + if type(sa) == "string" then + addr = { family = socket_mod.AF_UNIX, path = sa } + elseif type(sa) == "table" then + addr = sa + else + return false, "unsupported sockaddr representation", nil + end + + local ok, err, eno = socket_mod.bind(fd, addr) + -- LuaPosix returns 0 on success, nil on error. + if ok == nil then + return false, errno_msg("bind failed", err, eno), eno + end + return true, nil, nil +end + +--- Put a listening socket into listen state. +---@param fd integer +---@return boolean ok, string|nil err, integer|nil eno +local function listen_fd(fd) + local backlog = socket_mod.SOMAXCONN or 128 + local ok, err, eno = socket_mod.listen(fd, backlog) + if ok == nil then + return false, errno_msg("listen failed", err, eno), eno + end + return true, nil, nil +end + +--- accept(fd) -> newfd|nil, err|nil, again:boolean +---@param fd integer +---@return integer|nil newfd, string|nil err, boolean again +local function accept_fd(fd) + -- LuaPosix: accept(fd) -> connfd, addr | nil, errmsg, errnum + local newfd, addr_or_err, errnum = socket_mod.accept(fd) + if newfd ~= nil then + return newfd, nil, false + end + + local eno = errnum + if eno == errno.EAGAIN or eno == errno.EWOULDBLOCK then + return nil, nil, true + end + + return nil, errno_msg("accept failed", addr_or_err, eno), false +end + +--- Start a non-blocking connect. +--- connect_start(fd, sa) -> ok|nil, err|nil, inprogress:boolean +---@param fd integer +---@param sa any +---@return boolean|nil ok, string|nil err, boolean inprogress +local function connect_start_fd(fd, sa) + local addr + if type(sa) == "string" then + addr = { family = socket_mod.AF_UNIX, path = sa } + elseif type(sa) == "table" then + addr = sa + else + return nil, "unsupported sockaddr representation", false + end + + -- LuaPosix: connect(fd, addr) -> 0 | nil, errmsg, errnum + local ok, err, eno = socket_mod.connect(fd, addr) + if ok ~= nil then + -- Successful connect (may still be non-blocking socket, but connect has completed). + return true, nil, false + end + + if eno == errno.EINPROGRESS then + return nil, nil, true + end + + return nil, errno_msg("connect failed", err, eno), false +end + +--- Complete a non-blocking connect using SO_ERROR. +---@param fd integer +---@return boolean ok, string|nil err +local function connect_finish_fd(fd) + local soerr, err, eno = socket_mod.getsockopt( + fd, socket_mod.SOL_SOCKET, socket_mod.SO_ERROR + ) + if soerr == nil then + return false, errno_msg("getsockopt(SO_ERROR) failed", err, eno) + end + if soerr == 0 then + return true, nil + end + return false, "connect error errno " .. tostring(soerr) +end + +local function is_supported() + -- If we reached here, luaposix is present; assume support. return true end -return backend +---------------------------------------------------------------------- +-- Assemble ops and build backend +---------------------------------------------------------------------- + +local ops = { + set_nonblock = set_nonblock, + read = read_fd, + write = write_fd, + seek = seek_fd, + close = close_fd, + + open_file = open_file, + pipe = pipe_fds, + mktemp = mktemp, + fsync = fsync_fd, + rename = rename_file, + unlink = unlink_file, + decode_access = decode_access, + ignore_sigpipe = ignore_sigpipe, + + socket = socket_fd, + bind = bind_fd, + listen = listen_fd, + accept = accept_fd, + connect_start = connect_start_fd, + connect_finish = connect_finish_fd, + + modes = modes, + permissions = permissions, + + AF_UNIX = socket_mod.AF_UNIX, + SOCK_STREAM = socket_mod.SOCK_STREAM, + + is_supported = is_supported, +} + +return core.build_backend(ops) diff --git a/src/fibers/io/file.lua b/src/fibers/io/file.lua index 4cd2996..caf3392 100644 --- a/src/fibers/io/file.lua +++ b/src/fibers/io/file.lua @@ -3,88 +3,91 @@ -- File-backed streams on top of fd_backend + stream. -- -- Exposes: --- fdopen(fd[, flags[, filename]]) -> Stream --- open(filename[, mode[, perms]]) -> Stream | nil, err --- pipe() -> read_stream, write_stream --- mktemp(prefix[, perms]) -> fd, tmpname --- tmpfile([perms[, tmpdir]]) -> Stream (auto-unlink on close) --- init_nonblocking(fd) -> sets fd non-blocking (compat) +-- fdopen(fd, flags_or_mode[, filename]) -> Stream +-- open(filename[, mode[, perms]]) -> Stream | nil, err +-- pipe() -> read_stream, write_stream +-- mktemp(prefix[, perms]) -> fd, tmpname_or_err +-- tmpfile([perms[, tmpdir]]) -> Stream (auto-unlink on close) +-- init_nonblocking(fd) -> ok, err|nil +-- ---@module 'fibers.io.file' -local sc = require 'fibers.utils.syscall' local stream = require 'fibers.io.stream' local fd_back = require 'fibers.io.fd_backend' -local bit = rawget(_G, "bit") or require 'bit32' - --- Ignore SIGPIPE so write() failures are reported via errno. -sc.signal(sc.SIGPIPE, sc.SIG_IGN) +-- Best-effort: ignore SIGPIPE so write failures report via errno/return codes. +do + if fd_back.ignore_sigpipe then + fd_back.ignore_sigpipe() -- errors are treated as non-fatal + end +end ---------------------------------------------------------------------- --- Mode and permission tables +-- Mode / permission policy (OS-agnostic) ---------------------------------------------------------------------- ----@type table -local modes = { - r = sc.O_RDONLY, - w = bit.bor(sc.O_WRONLY, sc.O_CREAT, sc.O_TRUNC), - a = bit.bor(sc.O_WRONLY, sc.O_CREAT, sc.O_APPEND), - ["r+"] = sc.O_RDWR, - ["w+"] = bit.bor(sc.O_RDWR, sc.O_CREAT, sc.O_TRUNC), - ["a+"] = bit.bor(sc.O_RDWR, sc.O_CREAT, sc.O_APPEND), -} - -do - local binary_modes = {} - for k, v in pairs(modes) do - binary_modes[k .. "b"] = v - end - for k, v in pairs(binary_modes) do - modes[k] = v +-- We keep the string conventions, but leave numeric mapping to the backend. +---@type table +local function mode_access(mode) + assert(type(mode) == "string", "mode must be a string") + local plus = mode:find("+", 1, true) ~= nil + local c = mode:sub(1, 1) + + if c == "r" then + if plus then + return true, true + else + return true, false + end + elseif c == "w" or c == "a" then + if plus then + return true, true + else + return false, true + end + else + error("invalid mode: " .. tostring(mode)) end end ----@type table -local permissions = {} -permissions["rw-r--r--"] = bit.bor(sc.S_IRUSR, sc.S_IWUSR, sc.S_IRGRP, sc.S_IROTH) -permissions["rw-rw-rw-"] = bit.bor(permissions["rw-r--r--"], sc.S_IWGRP, sc.S_IWOTH) - ---------------------------------------------------------------------- --- Internal: wrap fd as a Stream +-- Internal: wrap an fd as a Stream ---------------------------------------------------------------------- --- Wrap an fd in a Stream using fd_backend. +--- +--- flags_or_mode may be: +--- * number : backend-specific open flags (decode_access will be used) +--- * string : Lua-style mode string ("r", "w+", "rb", etc.) +--- * table : { readable = bool, writable = bool } +--- ---@param fd integer ----@param flags? integer +---@param flags_or_mode any ---@param filename? string ---@return Stream -local function fdopen(fd, flags, filename) - -- If flags are not supplied, query them. - if flags == nil then - flags = assert(sc.fcntl(fd, sc.F_GETFL)) +local function fdopen(fd, flags_or_mode, filename) + assert(type(fd) == "number", "fdopen: fd must be a number") + + local readable, writable + + local t = type(flags_or_mode) + if t == "number" then + assert(fd_back.decode_access, "backend does not implement decode_access") + readable, writable = fd_back.decode_access(flags_or_mode) + elseif t == "string" then + readable, writable = mode_access(flags_or_mode) + elseif t == "table" then + readable = not not flags_or_mode.readable + writable = not not flags_or_mode.writable else - -- Historically needed for some 32-bit environments. - if sc.O_LARGEFILE then - flags = bit.bor(flags, sc.O_LARGEFILE) - end - end - - -- Determine readability / writability from flags. - local readable, writable = false, false - local mode = bit.band(flags, sc.O_ACCMODE) - if mode == sc.O_RDONLY or mode == sc.O_RDWR then - readable = true - end - if mode == sc.O_WRONLY or mode == sc.O_RDWR then - writable = true + error("fdopen: invalid flags_or_mode: " .. tostring(flags_or_mode)) end - local stat = sc.fstat(fd) - local blksize = stat and stat.st_blksize or nil - local io = fd_back.new(fd, { filename = filename }) - return stream.open(io, readable, writable, blksize) + -- We no longer try to adjust buffer size based on fstat; stream.open + -- will apply its default buffer sizes. + return stream.open(io, readable, writable) end ---------------------------------------------------------------------- @@ -92,30 +95,23 @@ end ---------------------------------------------------------------------- --- Open a file by name as a Stream. +--- +--- mode : "r", "w", "a", "r+", "w+", "a+" (with optional "b" suffix) +--- perms : integer or symbolic string (e.g. "rw-rw-rw-"), backend-defined. +--- ---@param filename string ---@param mode? string ---@param perms? integer|string ---@return Stream|nil f, string|nil err local function open_file(filename, mode, perms) mode = mode or "r" - local flags = modes[mode] - if not flags then - return nil, "invalid mode: " .. tostring(mode) - end - - -- Default permissions; umask still applies. - if perms == nil then - perms = permissions["rw-rw-rw-"] - else - perms = permissions[perms] or perms - end - local fd, err = sc.open(filename, flags, perms) + local fd, err = fd_back.open_file(filename, mode, perms) if not fd then return nil, err end - return fdopen(fd, flags, filename) + return fdopen(fd, mode, filename) end ---------------------------------------------------------------------- @@ -125,9 +121,13 @@ end --- Create a unidirectional pipe as two Streams (read, write). ---@return Stream r_stream, Stream w_stream local function pipe() - local rd, wr = assert(sc.pipe()) - local r_stream = fdopen(rd, sc.O_RDONLY) - local w_stream = fdopen(wr, sc.O_WRONLY) + local rd, wr, err = fd_back.pipe() + if not rd then + error(err or "pipe() failed") + end + + local r_stream = fdopen(rd, "r") + local w_stream = fdopen(wr, "w") return r_stream, w_stream end @@ -135,49 +135,37 @@ end -- mktemp / tmpfile ---------------------------------------------------------------------- ---- Create a temporary file with a unique name. +--- Create a temporary file with a unique name (backend-level). +--- +--- perms may be an integer mask or a symbolic string understood by the backend. ---@param prefix string ----@param perms? integer +---@param perms? integer|string ---@return integer|nil fd, string tmpname_or_err local function mktemp(prefix, perms) - perms = perms or permissions["rw-r--r--"] - - -- Caller is responsible for seeding math.random appropriately. - local start = math.random(1e7) - local tmpnam, fd, err - - for i = start, start + 10 do - tmpnam = prefix .. "." .. i - fd, err = sc.open(tmpnam, bit.bor(sc.O_CREAT, sc.O_RDWR, sc.O_EXCL), perms) - if fd then - return fd, tmpnam - end + perms = perms or "rw-r--r--" + local fd, tmpnam_or_err = fd_back.mktemp(prefix, perms) + if not fd then + return nil, tmpnam_or_err end - - -- Environmental failure: report as (nil, err) rather than raising. - return nil, ("failed to create temporary file %s: %s"):format( - tostring(tmpnam), - tostring(err) - ) + return fd, tmpnam_or_err end --- Create a temporary file wrapped as a Stream, with unlink-on-close semantics. ----@param perms? integer +---@param perms? integer|string ---@param tmpdir? string ---@return Stream|nil f, string|nil err local function tmpfile(perms, tmpdir) - perms = perms or permissions["rw-r--r--"] + perms = perms or "rw-r--r--" tmpdir = tmpdir or os.getenv("TMPDIR") or "/tmp" - ---@cast tmpdir string -- narrow for LuaLS + ---@cast tmpdir string local fd, tmpnam_or_err = mktemp(tmpdir .. "/tmp", perms) if not fd then - -- Propagate mktemp failure as (nil, err). return nil, tmpnam_or_err end ---@type Stream - local f = fdopen(fd, sc.O_RDWR, tmpnam_or_err) + local f = fdopen(fd, "r+", tmpnam_or_err) -- We want unlink-on-close semantics by default, with a way to -- disable that via :rename(). @@ -185,8 +173,7 @@ local function tmpfile(perms, tmpdir) assert(io, "tmpfile backend missing") ---@cast io StreamBackend - ---@type fun(self: StreamBackend): boolean, string|nil - local old_close = io.close + local old_close = assert(io.close, "tmpfile backend missing close()") --- Rename the temporary file and disable unlink-on-close behaviour. ---@param newname string @@ -201,13 +188,12 @@ local function tmpfile(perms, tmpdir) local real_fd = io.fileno and io:fileno() or fd if real_fd then - sc.fsync(real_fd) + fd_back.fsync(real_fd) end local fname = assert(io.filename, "tmpfile has no filename") - local ok, err = sc.rename(fname, newname) + local ok, err = fd_back.rename(fname, newname) if not ok then - -- Environmental failure: return nil, err. return nil, ("failed to rename %s to %s: %s"):format( tostring(fname), tostring(newname), @@ -224,16 +210,13 @@ local function tmpfile(perms, tmpdir) --- Close the fd and unlink the temporary file. ---@return boolean ok, string|nil err function io:close() - -- First close the descriptor. local ok, err = old_close(self) if not ok then return ok, err end local fname = assert(self.filename, "tmpfile has no filename") - -- Then unlink the temporary file. If this fails we report it, but - -- do not raise. - local ok2, err2 = sc.unlink(fname) + local ok2, err2 = fd_back.unlink(fname) if not ok2 then return false, ("failed to remove %s: %s"):format( tostring(fname), @@ -251,11 +234,11 @@ end -- Compatibility helper ---------------------------------------------------------------------- ---- Put an fd into non-blocking mode. +--- Put an fd into non-blocking mode using the backend. ---@param fd integer ---@return boolean ok, string|nil err local function init_nonblocking(fd) - return assert(sc.set_nonblock(fd)) + return fd_back.set_nonblock(fd) end ---------------------------------------------------------------------- @@ -269,6 +252,9 @@ return { mktemp = mktemp, tmpfile = tmpfile, init_nonblocking = init_nonblocking, - modes = modes, - permissions = permissions, + + -- For callers that previously used file.modes / file.permissions, + -- re-export backend metadata if present. + modes = fd_back.modes or {}, + permissions = fd_back.permissions or {}, } diff --git a/src/fibers/io/poller.lua b/src/fibers/io/poller.lua index 61190d1..10350ef 100644 --- a/src/fibers/io/poller.lua +++ b/src/fibers/io/poller.lua @@ -6,7 +6,7 @@ local candidates = { 'fibers.io.poller.epoll', -- Linux + FFI/epoll - -- 'fibers.io.poller.select', -- luaposix poll/select + 'fibers.io.poller.select', -- luaposix poll/select } for _, name in ipairs(candidates) do diff --git a/src/fibers/io/poller/select.lua b/src/fibers/io/poller/select.lua index 00b8692..f9ecedc 100644 --- a/src/fibers/io/poller/select.lua +++ b/src/fibers/io/poller/select.lua @@ -1,6 +1,6 @@ -- fibers/io/poller/select.lua -- --- posix.poll()-based poller backend (no epoll required). +-- luaposix.poll()-based poller backend (no epoll required). -- Intended to be selected via fibers.io.poller. -- ---@module 'fibers.io.poller.select' diff --git a/src/fibers/io/socket.lua b/src/fibers/io/socket.lua index 6e4bd5d..97a8dae 100644 --- a/src/fibers/io/socket.lua +++ b/src/fibers/io/socket.lua @@ -7,17 +7,18 @@ -- listen_unix(path, opts?) -> Socket (listening AF_UNIX) -- connect_unix(path, stype?, proto?) -> Stream -- --- Where Socket supports: +-- Socket (AF_UNIX focus) supports: -- :listen_unix(path) --- :accept_op() -> Op (resolves to Stream | nil, err) --- :accept() -> Stream | nil, err --- :connect_op(sa)-> Op (resolves to Stream | nil, err) --- :connect(sa) -> Stream | nil, err --- :connect_unix(path) / :connect_unix_op(path) +-- :accept_op() -> Op (resolves to Stream|nil, err) +-- :accept() -> Stream|nil, err +-- :connect_op(sa) -> Op (sa currently a UNIX path string) +-- :connect(sa) +-- :connect_unix_op(path) +-- :connect_unix(path) -- :close() +-- ---@module 'fibers.io.socket' -local sc = require 'fibers.utils.syscall' local wait = require 'fibers.wait' local poller_mod = require 'fibers.io.poller' local fd_backend = require 'fibers.io.fd_backend' @@ -26,80 +27,82 @@ local perform = require 'fibers.performer'.perform ---@class Socket ---@field fd integer|nil ----@field accept fun(self: Socket): (boolean|nil), any ----@field listen_unix fun(self: Socket, path: string): (boolean|nil), any ----@field connect_unix fun(self: Socket, path: string): (Stream|nil), any ----@field close fun(self: Socket): boolean, any - local Socket = {} Socket.__index = Socket --- Ignore SIGPIPE once; write errors will be reported via errno instead. -sc.signal(sc.SIGPIPE, sc.SIG_IGN) +---------------------------------------------------------------------- +-- Internal helpers +---------------------------------------------------------------------- + +--- Wrap an fd as a full-duplex Stream. +---@param fd integer +---@param filename? string +---@return Stream +local function fd_to_stream(fd, filename) + local io = fd_backend.new(fd, { filename = filename }) + -- For sockets we assume readable + writable. + return stream_mod.open(io, true, true) +end ---- Wrap a raw fd in a Socket. +--- Create a new non-blocking socket object from an fd. ---@param fd integer ---@return Socket local function new_socket(fd) - -- The fd itself is left for fd_backend.new to put into non-blocking mode - -- and to register with the poller when used for I/O. + -- Ensure non-blocking behaviour. + local ok, err = fd_backend.set_nonblock(fd) + if not ok then + fd_backend.close_fd(fd) + error("set_nonblock(socket fd) failed: " .. tostring(err)) + end return setmetatable({ fd = fd }, Socket) end ---- Create a new non-blocking socket. +--- Return underlying fd or error if closed. +---@return integer +function Socket:_fd() + local fd = self.fd + assert(fd, "socket is closed") + return fd +end + +---------------------------------------------------------------------- +-- Constructors +---------------------------------------------------------------------- + +--- Create a new non-blocking socket via the backend. ---@param domain integer ---@param stype integer ---@param protocol? integer ---@return Socket|nil s, any err local function socket(domain, stype, protocol) - local fd, err = sc.socket(domain, stype, protocol or 0) + local fd, err = fd_backend.socket(domain, stype, protocol or 0) if not fd then return nil, err end - -- We expect non-blocking behaviour; let fd_backend enforce this when - -- the fd is wrapped. For defensive programming you can also call: - -- sc.set_nonblock(fd) - sc.set_nonblock(fd) + local ok, nerr = fd_backend.set_nonblock(fd) + if not ok then + fd_backend.close_fd(fd) + return nil, nerr + end return new_socket(fd) end ---------------------------------------------------------------------- --- Helpers: wrap an fd into a Stream ----------------------------------------------------------------------- - ---- Wrap an fd as a full-duplex Stream. ----@param fd integer ----@param filename? string ----@return Stream -local function fd_to_stream(fd, filename) - local stat = sc.fstat(fd) - local blksize = stat and stat.st_blksize or nil - - local io = fd_backend.new(fd, { filename = filename }) - -- For sockets we assume readable + writable. - return stream_mod.open(io, true, true, blksize) -end - ----------------------------------------------------------------------- --- Listening and address helpers +-- Listening and address helpers (UNIX domain) ---------------------------------------------------------------------- --- Listen on a UNIX-domain path using this Socket. ---@param path string ---@return boolean|nil ok, any err function Socket:listen_unix(path) - assert(self.fd, "socket is closed") - - local sa = sc.getsockname(self.fd) - sa.path = path + local fd = self:_fd() - local ok, err = sc.bind(self.fd, sa) + local ok, err = fd_backend.bind(fd, path) if not ok then - -- Environmental failure: address in use, permissions, etc. return nil, ("bind failed: %s"):format(tostring(err)) end - ok, err = sc.listen(self.fd) + ok, err = fd_backend.listen(fd) if not ok then return nil, ("listen failed: %s"):format(tostring(err)) end @@ -114,29 +117,24 @@ end --- Build an Op that accepts a connection and returns a Stream. ---@return Op function Socket:accept_op() - assert(self.fd, "socket is closed") - local P = poller_mod.get() - local fd = self.fd + local fd = self:_fd() local function step() - local new_fd, err, errno = sc.accept(fd) + local new_fd, err, again = fd_backend.accept(fd) if new_fd then - -- Successful accept. return true, new_fd, nil end - if errno == sc.EAGAIN or errno == sc.EWOULDBLOCK then + if again then -- Would block: wait for readability. return false end -- Hard error. - return true, nil, err or ("errno " .. tostring(errno)) + return true, nil, err end - -- Only declare the parameter you actually use. local function register(task) - -- wait.waitable will call this as register(task, suspension, leaf_wrap), - -- but the extra arguments are harmlessly discarded by Lua. + -- poller wait on listening fd for read readiness. return P:wait(fd, "rd", task) end @@ -144,7 +142,7 @@ function Socket:accept_op() if not new_fd then return nil, err end - sc.set_nonblock(new_fd) + -- fd_to_stream will mark it non-blocking via fd_backend.new(). return fd_to_stream(new_fd) end @@ -158,51 +156,42 @@ function Socket:accept() end ---------------------------------------------------------------------- --- connect() as an Op +-- connect() as an Op (AF_UNIX path as opaque "sa") ---------------------------------------------------------------------- ---- Build an Op that connects this Socket to a sockaddr. +--- Build an Op that connects this Socket to an address token. +--- Currently sa is expected to be a UNIX-domain path string. ---@param sa any ---@return Op function Socket:connect_op(sa) - assert(self.fd, "socket is closed") - local P = poller_mod.get() - local fd = self.fd + local fd = self:_fd() local state = "initial" local function step() if state == "initial" then - local ok, err, errno = sc.connect(fd, sa) + local ok, err, inprogress = fd_backend.connect_start(fd, sa) if ok then - -- Immediate success. return true, true, nil end - if errno == sc.EINPROGRESS then - -- Standard non-blocking connect semantics: wait for writability. + if inprogress then state = "waiting" return false end - -- Hard failure. - return true, false, err or ("errno " .. tostring(errno)) + return true, false, err elseif state == "waiting" then - -- Connection completed or failed; check SO_ERROR. - local soerr = sc.getsockopt(fd, sc.SOL_SOCKET, sc.SO_ERROR) - if soerr == nil then - return true, false, "getsockopt(SO_ERROR) failed" - end - if soerr == 0 then - return true, true, nil - else - return true, false, "connect error errno " .. tostring(soerr) + local ok, err = fd_backend.connect_finish(fd) + if not ok then + return true, false, err end + return true, true, nil else return true, false, "invalid connect state" end end - -- Again, only the argument you use. local function register(task) + -- Non-blocking connect completion is signalled via writability. return P:wait(fd, "wr", task) end @@ -211,6 +200,7 @@ function Socket:connect_op(sa) return nil, err end local new_fd = fd + -- Hand ownership of the fd to the Stream; prevent double-close in Socket:close(). self.fd = nil return fd_to_stream(new_fd) end @@ -233,11 +223,7 @@ end ---@param path string ---@return Op function Socket:connect_unix_op(path) - assert(self.fd, "socket is closed") - - local sa = sc.getsockname(self.fd) - sa.path = path - return self:connect_op(sa) + return self:connect_op(path) end --- Connect synchronously to a UNIX-domain path. @@ -254,32 +240,36 @@ end local function listen_unix(path, opts) opts = opts or {} - local s, err = socket(sc.AF_UNIX, opts.stype or sc.SOCK_STREAM, opts.protocol) + local stype = opts.stype or fd_backend.SOCK_STREAM + local protocol = opts.protocol or 0 + + local s, err = socket(fd_backend.AF_UNIX, stype, protocol) if not s then return nil, err end local ok, lerr = s:listen_unix(path) if not ok then - -- Clean up the socket on failure, then propagate the error. s:close() return nil, lerr end if opts.ephemeral then local parent_close = s.close - ---@diagnostic disable-next-line: inject-field function s:close() local ok1, err1 = parent_close(self) - local ok2, err2, errno2 = sc.unlink(path) - -- Treat ENOENT as benign; other failures are reported. - if not ok2 and errno2 ~= sc.ENOENT then - return false, ("failed to remove %s: %s"):format(tostring(path), tostring(err2)) + local ok2, err2 = fd_backend.unlink(path) + if not ok2 then + return false, ("failed to remove %s: %s"):format( + tostring(path), + tostring(err2) + ) end - -- If the socket close was fine, report success overall. - if ok1 == false then return false, err1 end + if ok1 == false then + return false, err1 + end return true, nil end end @@ -293,12 +283,17 @@ end ---@param protocol? integer ---@return Stream|nil stream, any err local function connect_unix(path, stype, protocol) - local s, err = socket(sc.AF_UNIX, stype or sc.SOCK_STREAM, protocol) + stype = stype or fd_backend.SOCK_STREAM + protocol = protocol or 0 + + local s, err = socket(fd_backend.AF_UNIX, stype, protocol) if not s then return nil, err end + local stream, cerr = s:connect_unix(path) if not stream then + s:close() return nil, cerr end return stream @@ -312,10 +307,11 @@ end ---@return boolean ok, any err function Socket:close() if self.fd then - sc.close(self.fd) + local ok, err = fd_backend.close_fd(self.fd) self.fd = nil + return ok, err end - return true + return true, nil end ---------------------------------------------------------------------- @@ -323,8 +319,12 @@ end ---------------------------------------------------------------------- return { - socket = socket, - listen_unix = listen_unix, - connect_unix = connect_unix, - Socket = Socket, + socket = socket, + listen_unix = listen_unix, + connect_unix = connect_unix, + Socket = Socket, + + -- re-export useful constants for callers + AF_UNIX = fd_backend.AF_UNIX, + SOCK_STREAM = fd_backend.SOCK_STREAM, } diff --git a/src/fibers/io/stream.lua b/src/fibers/io/stream.lua index 0546906..36f5b4f 100644 --- a/src/fibers/io/stream.lua +++ b/src/fibers/io/stream.lua @@ -133,7 +133,7 @@ local function make_read_step(stream, buf, min, max, terminator) if err then return true, buf, tally, err end - if data == nil then + if not data then if tally >= min then return true, buf, tally end diff --git a/tests/test_io-exec_backend.lua b/tests/test_io-exec_backend.lua index a5439e6..aef2db8 100644 --- a/tests/test_io-exec_backend.lua +++ b/tests/test_io-exec_backend.lua @@ -9,34 +9,31 @@ print('testing: fibers.io.exec_backend') package.path = "../src/?.lua;" .. package.path local proc_backend = require 'fibers.io.exec_backend' -local sc = require 'fibers.utils.syscall' local stdlib = require 'posix.stdlib' -local function read_all(fd) - local chunks = {} - while true do - local s, err, errno = sc.read(fd, 4096) - assert(s ~= nil or err, "read returned nil without error") - if s == nil then - error(("read failed: %s (errno %s)"):format(tostring(err), tostring(errno))) - end - if #s == 0 then - break - end - chunks[#chunks + 1] = s - end - return table.concat(chunks) +---------------------------------------------------------------------- +-- ExecStreamConfig helpers +---------------------------------------------------------------------- + +local function inherit_stream() + return { mode = "inherit" } end --- Block in a simple loop on nonblock_wait until the child is finished. --- New backend contract: nonblock_wait() → done:boolean, code, signal, err +---------------------------------------------------------------------- +-- Helpers +---------------------------------------------------------------------- + +-- Block in a simple loop on the backend's poll operation until the +-- child is finished. +-- exec_backend.core wires ops.poll(state) -> done:boolean, code|nil, signal|nil, err|nil local function wait_blocking(backend) while true do - local exited, code, sig, err = backend:nonblock_wait() - assert(err == nil, "nonblock_wait error: " .. tostring(err)) - if exited then + local done, code, sig, err = backend._ops.poll(backend._state) + assert(err == nil, "poll error: " .. tostring(err)) + if done then return code, sig end + -- Busy wait is acceptable here: tests are short-lived and single-process. end end @@ -46,17 +43,20 @@ end local function test_simple_exit() local spec = { - argv = { "sh", "-c", "exit 7" }, - cwd = nil, - env = nil, - stdin_fd = nil, - stdout_fd = nil, - stderr_fd = nil, - flags = nil, + argv = { "sh", "-c", "exit 7" }, + cwd = nil, + env = nil, + flags = nil, + stdin = inherit_stream(), + stdout = inherit_stream(), + stderr = inherit_stream(), } - local backend, err = proc_backend.start(spec) - assert(backend, "start failed: " .. tostring(err)) + -- start() now returns a ProcHandle: { backend = ExecBackend, stdin, stdout, stderr } + local handle, err = proc_backend.start(spec) + assert(handle, "start failed: " .. tostring(err)) + + local backend = assert(handle.backend, "no backend in handle") local code, sig = wait_blocking(backend) assert(code == 7, @@ -72,34 +72,33 @@ local function test_env_inherit() -- Ensure a parent variable is set. assert(stdlib.setenv("PROC_BACKEND_TEST", "parent_inherit")) - -- Pipe to capture stdout. - local rd, wr = assert(sc.pipe()) + -- Shell script checks inherited value and exits 0 only on match. + local script = [[ + if [ "$PROC_BACKEND_TEST" = "parent_inherit" ]; then + exit 0 + else + exit 42 + fi + ]] local spec = { - argv = { "sh", "-c", 'printf "%s\n" "$PROC_BACKEND_TEST"' }, - cwd = nil, - env = nil, -- inherit environment - stdin_fd = nil, - stdout_fd = wr, -- child stdout -> pipe write end - stderr_fd = wr, -- send stderr the same way for simplicity - flags = nil, + argv = { "sh", "-c", script }, + cwd = nil, + env = nil, -- inherit environment + flags = nil, + stdin = inherit_stream(), + stdout = inherit_stream(), + stderr = inherit_stream(), } - local backend, err = proc_backend.start(spec) - assert(backend, "start failed: " .. tostring(err)) - - -- Parent no longer needs write end. - assert(sc.close(wr)) - - local out = read_all(rd) - assert(sc.close(rd)) + local handle, err = proc_backend.start(spec) + assert(handle, "start failed: " .. tostring(err)) + local backend = assert(handle.backend, "no backend in handle") local code, sig = wait_blocking(backend) - assert(code == 0, "expected shell to exit 0") assert(sig == nil, "expected no terminating signal") - - assert(out == "parent_inherit\n", - ("expected 'parent_inherit', got %q"):format(out)) + assert(code == 0, + ("expected exit code 0 from env inherit test, got %s"):format(tostring(code))) end ---------------------------------------------------------------------- @@ -107,35 +106,35 @@ end ---------------------------------------------------------------------- local function test_env_override() - -- Parent value that should be hidden/overridden. + -- Parent value that should be overridden in the child. assert(stdlib.setenv("PROC_BACKEND_TEST", "parent_value")) - local rd, wr = assert(sc.pipe()) + local script = [[ + if [ "$PROC_BACKEND_TEST" = "child_value" ]; then + exit 0 + else + exit 43 + fi + ]] local spec = { - argv = { "sh", "-c", 'printf "%s\n" "$PROC_BACKEND_TEST"' }, - cwd = nil, - env = { PROC_BACKEND_TEST = "child_value" }, -- override - stdin_fd = nil, - stdout_fd = wr, - stderr_fd = wr, - flags = nil, + argv = { "sh", "-c", script }, + cwd = nil, + env = { PROC_BACKEND_TEST = "child_value" }, -- override + flags = nil, + stdin = inherit_stream(), + stdout = inherit_stream(), + stderr = inherit_stream(), } - local backend, err = proc_backend.start(spec) - assert(backend, "start failed: " .. tostring(err)) - - assert(sc.close(wr)) - - local out = read_all(rd) - assert(sc.close(rd)) + local handle, err = proc_backend.start(spec) + assert(handle, "start failed: " .. tostring(err)) + local backend = assert(handle.backend, "no backend in handle") local code, sig = wait_blocking(backend) - assert(code == 0, "expected shell to exit 0") assert(sig == nil, "expected no terminating signal") - - assert(out == "child_value\n", - ("expected 'child_value', got %q"):format(out)) + assert(code == 0, + ("expected exit code 0 from env override test, got %s"):format(tostring(code))) end ---------------------------------------------------------------------- From d0301efe9dfdfde1026b5eb10ea5d1bd9c218109 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sat, 6 Dec 2025 01:11:56 +0000 Subject: [PATCH 070/138] removes sc time --- src/fibers/sched.lua | 10 +- src/fibers/utils/time.lua | 62 +++++++++++ src/fibers/utils/time/core.lua | 72 ++++++++++++ src/fibers/utils/time/ffi.lua | 152 +++++++++++++++++++++++++ src/fibers/utils/time/linux.lua | 189 ++++++++++++++++++++++++++++++++ src/fibers/utils/time/posix.lua | 104 ++++++++++++++++++ 6 files changed, 584 insertions(+), 5 deletions(-) create mode 100644 src/fibers/utils/time.lua create mode 100644 src/fibers/utils/time/core.lua create mode 100644 src/fibers/utils/time/ffi.lua create mode 100644 src/fibers/utils/time/linux.lua create mode 100644 src/fibers/utils/time/posix.lua diff --git a/src/fibers/sched.lua b/src/fibers/sched.lua index 7877d58..12c31a3 100644 --- a/src/fibers/sched.lua +++ b/src/fibers/sched.lua @@ -3,7 +3,7 @@ --- Core cooperative scheduler for fiber tasks. ---@module 'fibers.sched' -local sc = require 'fibers.utils.syscall' +local time = require 'fibers.utils.time' local timer = require 'fibers.timer' local MAX_SLEEP_TIME = 10 @@ -32,10 +32,10 @@ local Scheduler = {} Scheduler.__index = Scheduler --- Create a new scheduler instance. ----@param get_time? fun(): number # monotonic time source (defaults to sc.monotime) +---@param get_time? fun(): number # monotonic time source (defaults to fibers.utils.time.now) ---@return Scheduler local function new(get_time) - local now_src = get_time or sc.monotime + local now_src = get_time or time.now local now = now_src() local ret = setmetatable({ @@ -157,13 +157,13 @@ function Scheduler:wait_for_events() local next_time = self:next_wake_time() local timeout = math.min(self.maxsleep, next_time - now) - if timeout < 0 then timeout = 0 end if self.event_waiter then self.event_waiter:wait_for_events(self, now, timeout) else - sc.floatsleep(timeout) + -- No poller installed; fall back to process-blocking sleep. + time.sleep_blocking(timeout) end end diff --git a/src/fibers/utils/time.lua b/src/fibers/utils/time.lua new file mode 100644 index 0000000..928d40c --- /dev/null +++ b/src/fibers/utils/time.lua @@ -0,0 +1,62 @@ +-- fibers.utils.time +-- +-- Top-level time provider shim. +-- +-- Backends (priority order): +-- 1. fibers.utils.time.ffi - clock_gettime + nanosleep (via ffi_compat) +-- 2. fibers.utils.time.posix - luaposix clock_gettime/gettimeofday +-- 3. fibers.utils.time.linux - /proc/uptime or os.time + os.execute("sleep") +-- +---@module 'fibers.utils.time' + +local candidates = { + 'fibers.utils.time.ffi', + 'fibers.utils.time.posix', + 'fibers.utils.time.linux', +} + +local chosen + +for _, name in ipairs(candidates) do + local ok, mod = pcall(require, name) + if ok and type(mod) == "table" and mod.is_supported and mod.is_supported() then + chosen = mod + break + end +end + +if not chosen then + error("fibers.utils.time: no suitable time backend available on this platform") +end + +local function now() + return chosen.now() +end + +--- Best-effort process-blocking, non-busy sleep. +--- +--- Intended only for the scheduler path when no poller task source is +--- installed. In normal operation you should continue to use the +--- scheduler-based fibres.sleep module. +---@param dt number +local function sleep_blocking(dt) + if dt <= 0 then return end + + if type(chosen.sleep) == "function" then + return chosen.sleep(dt) + end + + -- Last resort: busy loop on now(). This path should only be used in + -- very constrained environments. + local start = now() + while now() - start < dt do end +end + +return { + now = now, + resolution = chosen.resolution, + source = chosen.source or chosen.impl, + impl = chosen.impl, + monotonic = chosen.monotonic ~= false, + sleep_blocking = sleep_blocking, +} diff --git a/src/fibers/utils/time/core.lua b/src/fibers/utils/time/core.lua new file mode 100644 index 0000000..c39f553 --- /dev/null +++ b/src/fibers/utils/time/core.lua @@ -0,0 +1,72 @@ +-- fibers.utils.time.core +-- +-- Contract and helpers for time providers. + +---@module 'fibers.utils.time.core' + +---@class TimeSourceOps +---@field name string|nil +---@field impl string|nil +---@field monotonic boolean|nil +---@field now fun(): number +---@field resolution number|nil +---@field sleep fun(dt:number)|nil -- optional process-blocking sleep + +---@class TimeSource +---@field name string +---@field impl string +---@field monotonic boolean +---@field now fun(): number +---@field resolution number|nil +---@field sleep fun(dt:number)|nil + +local M = {} + +--- Build a normalised TimeSource from a backend ops table. +---@param ops TimeSourceOps +---@return TimeSource +function M.build_source(ops) + assert(type(ops) == "table", "time source ops must be a table") + assert(type(ops.now) == "function", "time source requires now()") + + local src = { + name = ops.name or "time", + impl = ops.impl or ops.name or "time", + monotonic = ops.monotonic ~= false, + now = ops.now, + resolution = ops.resolution, + sleep = ops.sleep, + } + + return src +end + +--- Estimate timer resolution by sampling consecutive now() calls. +--- +--- This is inherently a busy-loop; only use at initialisation. +---@param now_fn fun(): number +---@param samples? integer +---@return number|nil +function M.estimate_resolution(now_fn, samples) + samples = samples or 256 + assert(type(now_fn) == "function", "estimate_resolution: now_fn must be a function") + + local best = math.huge + local last = now_fn() + + for _ = 1, samples do + local t = now_fn() + local dt = t - last + last = t + if dt > 0 and dt < best then + best = dt + end + end + + if best == math.huge or best <= 0 then + return nil + end + return best +end + +return M diff --git a/src/fibers/utils/time/ffi.lua b/src/fibers/utils/time/ffi.lua new file mode 100644 index 0000000..9f9bc5c --- /dev/null +++ b/src/fibers/utils/time/ffi.lua @@ -0,0 +1,152 @@ +-- fibers.utils.time.ffi +-- +-- FFI-based time provider using clock_gettime() and nanosleep(). +-- +---@module 'fibers.utils.time.ffi' + +local core = require 'fibers.utils.time.core' +local ffi_c = require 'fibers.utils.ffi_compat' + +if not (ffi_c.is_supported and ffi_c.is_supported()) then + return { is_supported = function() return false end } +end + +local ffi = ffi_c.ffi +local C = ffi_c.C +local toint = ffi_c.tonumber +local errno = ffi_c.errno + +ffi.cdef[[ + typedef long time_t; + struct timespec { + time_t tv_sec; + long tv_nsec; + }; + + int clock_gettime(int clk_id, struct timespec *tp); + int nanosleep(const struct timespec *req, struct timespec *rem); + + char *strerror(int errnum); +]] + +-- Linux / POSIX clock ids. +local CLOCK_MONOTONIC = 1 +local CLOCK_MONOTONIC_RAW = 4 + +local EINTR = 4 + +local function strerror(e) + local s = C.strerror(e) + if s == nil then + return "errno " .. tostring(e) + end + return ffi.string(s) +end + +local function make_now(clock_id) + local ts = ffi.new("struct timespec[1]") + + return function() + local rc = toint(C.clock_gettime(clock_id, ts)) + if rc ~= 0 then + local e = errno() + error("clock_gettime failed: " .. strerror(e)) + end + local t = ts[0] + return tonumber(t.tv_sec) + tonumber(t.tv_nsec) * 1e-9 + end +end + +local function probe_clock(clock_id) + local ok, now_or_err = pcall(make_now, clock_id) + if not ok then + return nil, now_or_err + end + local now = now_or_err + + local ok2, t = pcall(now) + if not ok2 or type(t) ~= "number" then + return nil, t + end + + return now, nil +end + +local function sleep_blocking(dt) + if dt <= 0 then return end + + local sec = math.floor(dt) + local nsec = math.floor((dt - sec) * 1e9) + if nsec < 0 then nsec = 0 end + if nsec >= 1e9 then + sec = sec + 1 + nsec = nsec - 1e9 + end + + local req = ffi.new("struct timespec[1]") + req[0].tv_sec = sec + req[0].tv_nsec = nsec + + while true do + local rc = toint(C.nanosleep(req, req)) + if rc == 0 then + break + end + local e = errno() + if e ~= EINTR then + break + end + -- EINTR: req now holds remaining time; loop again. + end +end + +local function build() + -- Prefer MONOTONIC_RAW, fall back to MONOTONIC. + local now, err = probe_clock(CLOCK_MONOTONIC_RAW) + local impl = "ffi.clock_gettime(CLOCK_MONOTONIC_RAW)" + + if not now then + now, err = probe_clock(CLOCK_MONOTONIC) + impl = "ffi.clock_gettime(CLOCK_MONOTONIC)" + end + + if not now then + error(err or "no usable clock_gettime clock") + end + + local src = core.build_source{ + name = "clock_gettime", + impl = impl, + monotonic = true, + now = now, + sleep = sleep_blocking, + } + + src.resolution = src.resolution or core.estimate_resolution(src.now) + + return src +end + +local function is_supported() + local ok, res = pcall(build) + if not ok then + return false + end + return res and true or false +end + +if not is_supported() then + return { is_supported = function() return false end } +end + +local src = build() + +return { + is_supported = is_supported, + now = src.now, + resolution = src.resolution, + source = src.name, + impl = src.impl, + monotonic = src.monotonic, + sleep = src.sleep, +} diff --git a/src/fibers/utils/time/linux.lua b/src/fibers/utils/time/linux.lua new file mode 100644 index 0000000..5e167ef --- /dev/null +++ b/src/fibers/utils/time/linux.lua @@ -0,0 +1,189 @@ +-- fibers.utils.time.linux +-- +-- Generic Linux / POSIX fallback time provider. +-- +-- Strategy: +-- * Probe os.execute("sleep 0.01") to see if shell sleep exists and +-- accepts fractional seconds. +-- * If that succeeds and /proc/uptime is available, use /proc/uptime +-- for a monotonic clock and fractional "sleep dt". +-- * Otherwise, fall back to os.time() and integer-second sleep. +-- +---@module 'fibers.utils.time.linux' + +local core = require 'fibers.utils.time.core' + +local UPTIME_PATH = "/proc/uptime" + +local function have_proc_uptime() + local f = io.open(UPTIME_PATH, "r") + if not f then + return false + end + f:close() + return true +end + +local function read_uptime() + local f = io.open(UPTIME_PATH, "r") + if not f then + return nil + end + local line = f:read("*l") + f:close() + if not line then + return nil + end + local first = line:match("^%s*([0-9]+%.?[0-9]*)") + if not first then + return nil + end + return tonumber(first) +end + +--- Normalise os.execute return across Lua versions. +---@param cmd string +---@return boolean ok +local function command_succeeds(cmd) + if type(os) ~= "table" or type(os.execute) ~= "function" then + return false + end + + local ok, a, b = pcall(os.execute, cmd) + if not ok then + return false + end + + -- Lua 5.2+: ok==true, a is true/nil/"exit"/"signal". + if a == true then + return true + end + if type(a) == "number" then + return a == 0 + end + if a == "exit" then + return b == 0 + end + + -- Lua 5.1: rc is numeric status. + if a ~= nil and b == nil and type(a) ~= "string" then + return a == 0 + end + + return false +end + +local function probe_sleep() + if type(os) ~= "table" or type(os.execute) ~= "function" then + return { available = false, fractional = false } + end + + -- Prefer to test fractional first. + if command_succeeds("sleep 0.01") then + return { available = true, fractional = true } + end + + -- Fall back to integral only. + if command_succeeds("sleep 1") then + return { available = true, fractional = false } + end + + return { available = false, fractional = false } +end + +local function build() + local sleep_info = probe_sleep() + local have_sleep = sleep_info.available + local frac_sleep = sleep_info.fractional + + local now + local impl + local monotonic + + if have_proc_uptime() and frac_sleep then + local base = read_uptime() + assert(type(base) == "number", "failed to read /proc/uptime") + + now = function() + local t = read_uptime() + if not t then + error("failed to read /proc/uptime") + end + return t - base + end + + impl = "linux.proc_uptime" + monotonic = true + else + -- Coarse wall-clock baseline; only used as a last resort. + now = function() return os.time() end + impl = "os.time" + monotonic = false + end + + local sleep_fn + if have_sleep then + sleep_fn = function(dt) + if dt <= 0 then + return + end + + if frac_sleep then + local secs = dt + if secs > 86400 then + secs = 86400 + elseif secs < 0 then + secs = 0 + end + local cmd = ("sleep %.6f"):format(secs) + command_succeeds(cmd) + else + local secs = math.ceil(dt) + if secs < 1 then + secs = 1 + end + local cmd = ("sleep %d"):format(secs) + command_succeeds(cmd) + end + end + end + + local src = core.build_source{ + name = "Linux fallback time", + impl = impl, + monotonic = monotonic, + now = now, + sleep = sleep_fn, + } + + if impl == "linux.proc_uptime" then + src.resolution = src.resolution or core.estimate_resolution(src.now, 64) + else + -- os.time() is normally second-resolution. + src.resolution = 1.0 + end + + return src +end + +local function is_supported() + -- This is intended as a generic Posix/Linux fallback, so be permissive: + -- require a usable os table at least. + return type(os) == "table" +end + +if not is_supported() then + return { is_supported = function() return false end } +end + +local src = build() + +return { + is_supported = function() return true end, + now = src.now, + resolution = src.resolution, + source = src.name, + impl = src.impl, + monotonic = src.monotonic, + sleep = src.sleep, +} diff --git a/src/fibers/utils/time/posix.lua b/src/fibers/utils/time/posix.lua new file mode 100644 index 0000000..94c68c1 --- /dev/null +++ b/src/fibers/utils/time/posix.lua @@ -0,0 +1,104 @@ +-- fibers.utils.time.posix +-- +-- luaposix-based time provider using clock_gettime() or gettimeofday(). +-- +---@module 'fibers.utils.time.posix' + +local core = require 'fibers.utils.time.core' + +local ok_time, ptime = pcall(require, 'posix.sys.time') +if not ok_time or type(ptime) ~= "table" then + return { is_supported = function() return false end } +end + +local function errno_msg(prefix, err, eno) + if err and err ~= "" then + return err + end + if eno then + return ("%s (errno %d)"):format(prefix, eno) + end + return prefix +end + +local function has_clock_gettime() + return type(ptime.clock_gettime) == "function" +end + +local function build_now_clock() + local clk_id = ptime.CLOCK_MONOTONIC or "monotonic" + + local function now() + local ts, err, eno = ptime.clock_gettime(clk_id) + if not ts then + error(errno_msg("clock_gettime failed", err, eno)) + end + return ts.tv_sec + ts.tv_nsec * 1e-9 + end + + local t = now() + assert(type(t) == "number", "clock_gettime did not yield a number") + + return now +end + +local function build_now_gettimeofday() + assert(type(ptime.gettimeofday) == "function", + "no clock_gettime and no gettimeofday") + + local function now() + local tv, err, eno = ptime.gettimeofday() + if not tv then + error(errno_msg("gettimeofday failed", err, eno)) + end + return tv.tv_sec + tv.tv_usec * 1e-6 + end + + return now, false +end + +local function build() + local now, monotonic + + if has_clock_gettime() then + now = build_now_clock() + monotonic = true + else + now, monotonic = build_now_gettimeofday() + end + + local src = core.build_source{ + name = "luaposix time", + impl = has_clock_gettime() and "posix.clock_gettime" or "posix.gettimeofday", + monotonic = monotonic, + now = now, + -- sleep: left nil; higher layers use pollers or the linux backend for blocking sleep. + } + + src.resolution = src.resolution or core.estimate_resolution(src.now) + + return src +end + +local function is_supported() + local ok, res = pcall(build) + if not ok then + return false + end + return res and true or false +end + +if not is_supported() then + return { is_supported = function() return false end } +end + +local src = build() + +return { + is_supported = is_supported, + now = src.now, + resolution = src.resolution, + source = src.name, + impl = src.impl, + monotonic = src.monotonic, +} From 3f39c7bc2179af93838696d56109c0a795cc0a76 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Mon, 8 Dec 2025 02:28:28 +0000 Subject: [PATCH 071/138] updates io backends, adds nixio backends --- src/fibers/io/exec_backend.lua | 5 +- src/fibers/io/exec_backend/nixio.lua | 636 +++++++++++++++++++++++++ src/fibers/io/exec_backend/sigchld.lua | 1 + src/fibers/io/fd_backend.lua | 5 +- src/fibers/io/fd_backend/nixio.lua | 442 +++++++++++++++++ src/fibers/io/fd_backend/posix.lua | 6 +- src/fibers/io/file.lua | 3 +- src/fibers/io/poller.lua | 5 +- src/fibers/io/poller/core.lua | 3 +- src/fibers/io/poller/nixio.lua | 105 ++++ 10 files changed, 1198 insertions(+), 13 deletions(-) create mode 100644 src/fibers/io/exec_backend/nixio.lua create mode 100644 src/fibers/io/fd_backend/nixio.lua create mode 100644 src/fibers/io/poller/nixio.lua diff --git a/src/fibers/io/exec_backend.lua b/src/fibers/io/exec_backend.lua index 5d4210b..6ef23c4 100644 --- a/src/fibers/io/exec_backend.lua +++ b/src/fibers/io/exec_backend.lua @@ -20,8 +20,9 @@ ---@field stderr Stream|nil local candidates = { - 'fibers.io.exec_backend.pidfd', -- Linux pidfd backend - 'fibers.io.exec_backend.sigchld', -- Portable SIGCHLD + self-pipe backend (luaposix) + -- 'fibers.io.exec_backend.pidfd', -- Linux pidfd backend + -- 'fibers.io.exec_backend.sigchld', -- Portable SIGCHLD + self-pipe backend (luaposix) + 'fibers.io.exec_backend.nixio', -- Portable SIGCHLD + self-pipe backend (luaposix) } local chosen diff --git a/src/fibers/io/exec_backend/nixio.lua b/src/fibers/io/exec_backend/nixio.lua new file mode 100644 index 0000000..d2753bd --- /dev/null +++ b/src/fibers/io/exec_backend/nixio.lua @@ -0,0 +1,636 @@ +-- fibers/io/exec_backend/nixio.lua +-- +-- Nixio-based exec backend using a per-command “reaper” process and +-- a sentinel pipe for completion notifications. +-- +-- Topology per command: +-- parent +-- ├─ reaper (Lua, this module) +-- │ └─ child (exec'ed programme) +-- └─ sentinel_r (read end of status pipe) +-- +-- Protocol on the sentinel pipe: +-- - reaper writes: "pid \n" +-- - later writes one of: +-- "exited \n" +-- "signaled \n" +-- "failed \n" +-- +-- Parent uses poller to wait for sentinel_r readability; when data +-- arrives, it parses lines and updates backend state. +-- +-- Uses nixio File objects as fds throughout. + +local core = require 'fibers.io.exec_backend.core' +local poller = require 'fibers.io.poller' +local runtime = require 'fibers.runtime' +local file_io = require 'fibers.io.file' +local stdio = require 'fibers.io.exec_backend.stdio' + +local ok, nixio = pcall(require, 'nixio') +if not ok or not nixio then + return { is_supported = function() return false end } +end + +local const = nixio.const or {} + +local unpack = rawget(table, "unpack") or _G.unpack + +local DEV_NULL = "/dev/null" + +---------------------------------------------------------------------- +-- Small helpers +---------------------------------------------------------------------- + +local function errno_msg(prefix) + local eno = nixio.errno() + local estr = (nixio.strerror and nixio.strerror(eno)) or ("errno " .. tostring(eno)) + if prefix then + return ("%s: %s"):format(prefix, estr) + end + return estr +end + +local function close_fd(f) + if f and f.close then + pcall(function() f:close() end) + end +end + +---------------------------------------------------------------------- +-- Stdio integration for exec_backend.stdio +---------------------------------------------------------------------- + +--- Open /dev/null for input or output (child side). +---@param is_output boolean +---@return any fd, string|nil err +local function open_dev_null(is_output) + local mode = is_output and "w" or "r" + local f, err = nixio.open(DEV_NULL, mode) + if not f then + return nil, err or errno_msg("open " .. DEV_NULL) + end + return f, nil +end + +--- Create a pipe (child <-> parent). +---@return any rd, any wr, string|nil err +local function make_pipe() + local rd, wr = nixio.pipe() + if not rd or not wr then + return nil, nil, errno_msg("pipe") + end + return rd, wr, nil +end + +--- We rely on explicit close() in child/reaper instead of close-on-exec. +---@param _ any +---@return boolean, string|nil +local function set_cloexec(_) + return true, nil +end + +--- Wrap a parent-side fd into a Stream. +---@param role '"stdin"'|'"stdout"'|'"stderr"' +---@param fd any -- nixio File +---@return Stream +local function open_stream(role, fd) + if role == "stdin" then + return file_io.fdopen(fd, "w") + else + return file_io.fdopen(fd, "r") + end +end + +---------------------------------------------------------------------- +-- Child exec path (runs in the real child process) +---------------------------------------------------------------------- + +--- Duplicate src onto dest_fd (0/1/2) using nixio.dup with nixio.stdin/stdout/stderr. +---@param src any -- nixio File +---@param dest_fd integer +local function setup_child_fd(src, dest_fd) + if not src then + return + end + + local curfd = src:fileno() + if curfd == dest_fd then + return + end + + local dest + if dest_fd == 0 then + dest = nixio.stdin + elseif dest_fd == 1 then + dest = nixio.stdout + elseif dest_fd == 2 then + dest = nixio.stderr + else + -- exec_backend.stdio only uses 0/1/2. + os.exit(127) + end + + local dup, _ = nixio.dup(src, dest) + if not dup then + os.exit(127) + end +end + +local function apply_child_env(env) + for name, value in pairs(env) do + if value == nil then + nixio.setenv(name) -- unset + else + local ok1, _ = nixio.setenv(name, tostring(value)) + if not ok1 then + os.exit(127) + end + end + end +end + +---@param child_spec table -- child-facing spec with *fd fields +---@param child_only table|nil +---@param parent_fds table|nil +---@param sentinel_w any|nil -- nixio File, closed in child +local function child_exec(child_spec, child_only, parent_fds, sentinel_w) + if sentinel_w then + close_fd(sentinel_w) + end + + if child_spec.cwd then + local ok1, _ = nixio.chdir(child_spec.cwd) + if not ok1 then + os.exit(127) + end + end + + if child_spec.flags and child_spec.flags.setsid then + local sid, _ = nixio.setsid() + if not sid then + os.exit(127) + end + end + + if child_spec.env then + apply_child_env(child_spec.env) + end + + setup_child_fd(child_spec.stdin_fd, 0) + setup_child_fd(child_spec.stdout_fd, 1) + setup_child_fd(child_spec.stderr_fd, 2) + + stdio.close_child_only(child_only, close_fd) + stdio.close_parent_fds(parent_fds, close_fd) + + local argv = child_spec.argv + local prog = assert(argv[1], "child_exec: argv[1] must be non-nil") + + -- execp(executable, ...) sets argv[0] automatically. + local n = #argv + if n == 1 then + nixio.execp(prog) + else + local args = {} + for i = 2, n do + args[#args + 1] = argv[i] + end + nixio.execp(prog, unpack(args)) + end + + os.exit(127) +end + +---------------------------------------------------------------------- +-- Backend state helpers and parsing +---------------------------------------------------------------------- + +---@class NixioExecState +---@field reaper_pid integer -- pid of the reaper process +---@field pid integer|nil -- for introspection; updated to child pid when known +---@field child_pid integer|nil -- pid of the real exec'ed child +---@field sentinel any -- nixio File (read end) +---@field exited boolean +---@field code integer|nil +---@field signal integer|nil +---@field err string|nil +---@field _buf string|nil +---@field _have_status boolean|nil +---@field _reaper_reaped boolean|nil + +local function parse_status_line_into_state(line, state) + line = line:gsub("\r", "") + local tag, rest = line:match("^(%S+)%s*(.*)$") + if not tag then + state.err = state.err or "invalid status line from reaper" + state.exited = true + state._have_status = true + return + end + + if tag == "pid" then + local cpid = tonumber(rest) + if cpid then + state.child_pid = cpid + state.pid = state.pid or cpid + end + return + elseif tag == "exited" then + local code = tonumber(rest) or 0 + state.code = code + state.signal = nil + state.err = state.err or nil + state.exited = true + state._have_status = true + return + elseif tag == "signaled" or tag == "signalled" then + local sig = tonumber(rest) or 0 + state.code = nil + state.signal = sig + state.err = state.err or nil + state.exited = true + state._have_status = true + return + elseif tag == "failed" then + local msg = rest ~= "" and rest or "exec backend failed" + state.code = nil + state.signal = nil + state.err = msg + state.exited = true + state._have_status = true + return + else + state.err = state.err or ("unknown status tag '" .. tostring(tag) .. "'") + state.exited = true + state._have_status = true + return + end +end + +local function reap_reaper(state) + if state._reaper_reaped or not state.reaper_pid then + return + end + local pid, _, _ = nixio.waitpid(state.reaper_pid, "nohang") + if pid and pid ~= 0 then + state._reaper_reaped = true + end +end + +---------------------------------------------------------------------- +-- Reaper process path +---------------------------------------------------------------------- + +--- Run in the per-command reaper process. +---@param child_spec table +---@param child_only table|nil +---@param parent_fds table|nil +---@param sentinel_r any -- nixio File (parent-side read end, close here) +---@param sentinel_w any -- nixio File (reaper-side writer) +local function reaper_main(child_spec, child_only, parent_fds, sentinel_r, sentinel_w) + -- Reaper does not need parent pipe ends or parent's sentinel read end. + stdio.close_parent_fds(parent_fds, close_fd) + close_fd(sentinel_r) + + -- Fork the real child. + local child_pid, err = nixio.fork() + if not child_pid then + if sentinel_w then + sentinel_w:write("failed " .. (err or errno_msg("fork")) .. "\n") + close_fd(sentinel_w) + end + os.exit(127) + end + + if child_pid == 0 then + -- In the real child. + child_exec(child_spec, child_only, parent_fds, sentinel_w) + os.exit(127) + end + + -- In the reaper. + stdio.close_child_only(child_only, close_fd) + + -- Tell parent the real child pid. + if sentinel_w then + pcall(function() + sentinel_w:write(("pid %d\n"):format(child_pid)) + end) + end + + -- Wait for the real child to exit. + local pid, how, what + while true do + pid, how, what = nixio.waitpid(child_pid) + if pid ~= nil then + break + end + local eno = nixio.errno() + if eno ~= const.EINTR then + break + end + end + + local line + if not pid then + line = "failed " .. errno_msg("waitpid") .. "\n" + else + if how == "exited" then + local code = tonumber(what) or 0 + line = ("exited %d\n"):format(code) + elseif how == "signaled" or how == "signalled" then + local sig = tonumber(what) or 0 + line = ("signaled %d\n"):format(sig) + else + line = ("failed unexpected %s %s\n"):format(tostring(how), tostring(what)) + end + end + + if sentinel_w then + pcall(function() + sentinel_w:write(line) + sentinel_w:close() + end) + end + + os.exit(0) +end + +---------------------------------------------------------------------- +-- Polling of sentinel in the parent +---------------------------------------------------------------------- + +--- Non-blocking poll of the sentinel pipe. +---@param state NixioExecState +---@return boolean done, integer|nil code, integer|nil signal, string|nil err +local function poll_state(state) + if state.exited then + return true, state.code, state.signal, state.err + end + + if not state.sentinel then + -- Sentinel has gone away without a status line. + if not state._have_status then + state.exited = true + state.err = state.err or "reaper sentinel closed" + end + reap_reaper(state) + return true, state.code, state.signal, state.err + end + + local bufsize = const.buffersize or 256 + + while true do + local chunk, _ = state.sentinel:read(bufsize) + + if not chunk then + local eno = nixio.errno() + if eno == const.EAGAIN or eno == const.EWOULDBLOCK or eno == const.EINTR then + -- Nothing available right now. + break + end + + -- Hard error; treat as completion if we do not yet have a status. + close_fd(state.sentinel) + state.sentinel = nil + if not state._have_status then + state.exited = true + state.err = state.err or "reaper sentinel closed" + end + reap_reaper(state) + break + end + + if #chunk == 0 then + -- EOF: writer closed pipe. + close_fd(state.sentinel) + state.sentinel = nil + if not state._have_status then + state.exited = true + state.err = state.err or "reaper sentinel closed" + end + reap_reaper(state) + break + end + + state._buf = (state._buf or "") .. chunk + + while true do + local line, rest = state._buf:match("^(.-)\n(.*)$") + if not line then + break + end + state._buf = rest + parse_status_line_into_state(line, state) + end + + if state.exited then + close_fd(state.sentinel) + state.sentinel = nil + reap_reaper(state) + break + end + end + + if state.exited then + return true, state.code, state.signal, state.err + else + return false, nil, nil, nil + end +end + +---------------------------------------------------------------------- +-- exec_backend.core ops +---------------------------------------------------------------------- + +--- spawn(spec) -> state, streams, err +---@param spec ExecProcSpec +---@return NixioExecState|nil state,{stdin:Stream|nil,stdout:Stream|nil,stderr:Stream|nil}|nil streams,string|nil err +local function spawn(spec) + assert(type(spec) == "table", "ExecBackend.spawn: spec must be a table") + assert(type(spec.argv) == "table" and spec.argv[1], + "ExecBackend.spawn: spec.argv must be a non-empty array") + + local child_spec, child_only, parent_fds, cfg_err = + stdio.build_child_stdio(spec, open_dev_null, make_pipe, set_cloexec, close_fd) + if not child_spec then + return nil, nil, cfg_err + end + + -- Sentinel pipe: reaper writes, parent reads. + local sentinel_r, sentinel_w = nixio.pipe() + if not sentinel_r or not sentinel_w then + stdio.close_child_only(child_only, close_fd) + stdio.close_parent_fds(parent_fds, close_fd) + return nil, nil, errno_msg("pipe (sentinel)") + end + + -- We will use the sentinel in blocking mode temporarily for a + -- handshake to learn the real child pid, then switch to non-blocking. + sentinel_r:setblocking(true) + + -- Fork the per-command reaper. + local reaper_pid, ferr = nixio.fork() + if not reaper_pid then + stdio.close_child_only(child_only, close_fd) + stdio.close_parent_fds(parent_fds, close_fd) + close_fd(sentinel_r) + close_fd(sentinel_w) + return nil, nil, ferr or errno_msg("fork (reaper)") + end + + if reaper_pid == 0 then + reaper_main(child_spec, child_only, parent_fds, sentinel_r, sentinel_w) + os.exit(127) + end + + -- Parent. + stdio.close_child_only(child_only, close_fd) + close_fd(sentinel_w) + + local state = { + reaper_pid = reaper_pid, + pid = reaper_pid, -- will be updated once child pid is known + child_pid = nil, + sentinel = sentinel_r, + exited = false, + code = nil, + signal = nil, + err = nil, + _buf = nil, + _have_status = false, + _reaper_reaped = false, + } + + -- Handshake: read sentinel until we have seen a pid line and/or a + -- terminal status. This guarantees child_pid is known before any + -- external code can attempt to send signals. + local bufsize = const.buffersize or 256 + + while not state.child_pid and not state._have_status do + local chunk, rerr = sentinel_r:read(bufsize) + if not chunk then + close_fd(sentinel_r) + state.sentinel = nil + return nil, nil, rerr or errno_msg("sentinel handshake read") + end + if #chunk == 0 then + close_fd(sentinel_r) + state.sentinel = nil + return nil, nil, "sentinel closed during handshake" + end + + state._buf = (state._buf or "") .. chunk + + while true do + local line, rest = state._buf:match("^(.-)\n(.*)$") + if not line then + break + end + state._buf = rest + parse_status_line_into_state(line, state) + end + end + + -- Switch sentinel to non-blocking for normal event-loop use. + sentinel_r:setblocking(false) + + local streams = stdio.build_parent_streams(parent_fds, open_stream) + + return state, streams, nil +end + +--- poll(state) -> done, code, signal, err +local function poll_backend(state) + return poll_state(state) +end + +--- register_wait(state, task, suspension, leaf_wrap) -> WaitToken +local function register_wait(state, task, _, _) + if not state.sentinel then + -- No fd to wait on; reschedule once so that step() can see terminal state. + local sched = runtime.current_scheduler + if sched and sched.schedule then + sched:schedule(task) + end + return { unlink = function() return false end } + end + + return poller.get():wait(state.sentinel, "rd", task) +end + +--- Send a signal to the real child. +---@param state NixioExecState +---@param sig integer|nil +---@return boolean ok, string|nil err +local function send_signal(state, sig) + sig = sig or const.SIGTERM or 15 + + -- If already finished, nothing to do. + if state.exited then + return true, nil + end + + -- Process any queued sentinel data (should not normally change + -- child_pid, as the handshake has already seen the pid line). + poll_state(state) + + if state.exited then + return true, nil + end + + local target = state.child_pid + if not target then + -- As a last resort, fall back to the reaper pid; this should be + -- unreachable in normal operation because the handshake ensures + -- child_pid is known. + target = state.reaper_pid or state.pid + end + if not target then + return false, "no child or reaper pid available" + end + + local ok1, err = nixio.kill(target, sig) + if not ok1 then + return false, err or errno_msg("kill") + end + return true, nil +end + +local function terminate(state) + return send_signal(state, const.SIGTERM or 15) +end + +local function kill_proc(state) + return send_signal(state, const.SIGKILL or 9) +end + +local function close_state(state) + close_fd(state.sentinel) + state.sentinel = nil + reap_reaper(state) + return true, nil +end + +local function is_supported() + return type(nixio) == "table" + and type(nixio.fork) == "function" + and type(nixio.waitpid) == "function" + and type(nixio.execp) == "function" + and type(nixio.pipe) == "function" + and type(nixio.open) == "function" +end + +local ops = { + spawn = spawn, + poll = poll_backend, + register_wait = register_wait, + send_signal = send_signal, + terminate = terminate, + kill = kill_proc, + close = close_state, + is_supported = is_supported, +} + +return core.build_backend(ops) diff --git a/src/fibers/io/exec_backend/sigchld.lua b/src/fibers/io/exec_backend/sigchld.lua index 936c643..7daf373 100644 --- a/src/fibers/io/exec_backend/sigchld.lua +++ b/src/fibers/io/exec_backend/sigchld.lua @@ -473,6 +473,7 @@ local function close_state(state) end local function is_supported() + if rawget(_G, "jit") then return false end -- rare LuaJit instability return psignal.SIGCHLD ~= nil end diff --git a/src/fibers/io/fd_backend.lua b/src/fibers/io/fd_backend.lua index ab3b46c..4d84b32 100644 --- a/src/fibers/io/fd_backend.lua +++ b/src/fibers/io/fd_backend.lua @@ -8,8 +8,9 @@ ---@module 'fibers.io.fd_backend' local candidates = { - 'fibers.io.fd_backend.ffi', -- FFI / libc - 'fibers.io.fd_backend.posix', -- luaposix + -- 'fibers.io.fd_backend.ffi', -- FFI / libc + -- 'fibers.io.fd_backend.posix', -- luaposix + 'fibers.io.fd_backend.nixio', -- nixio } for _, name in ipairs(candidates) do diff --git a/src/fibers/io/fd_backend/nixio.lua b/src/fibers/io/fd_backend/nixio.lua new file mode 100644 index 0000000..d507409 --- /dev/null +++ b/src/fibers/io/fd_backend/nixio.lua @@ -0,0 +1,442 @@ +-- fibers/io/fd_backend/nixio.lua +-- +-- nixio-based FD backend (no FFI / luaposix dependency). +-- Intended to be selected via fibers.io.fd_backend. +-- +---@module 'fibers.io.fd_backend.nixio' + +local core = require 'fibers.io.fd_backend.core' +local nixio = require 'nixio' +local fs = require 'nixio.fs' + +local const = nixio.const or {} + +local EAGAIN = const.EAGAIN or 11 +local EWOULDBLOCK = const.EWOULDBLOCK or EAGAIN +local EINPROGRESS = const.EINPROGRESS or 115 +local EALREADY = const.EALREADY or 114 + +-- Where available, reuse nixio’s numeric constants so callers see +-- sensible AF_* / SOCK_* values. Fall back to standard-ish defaults. +local AF_UNIX = const.AF_UNIX or 1 +local AF_INET = const.AF_INET +local AF_INET6 = const.AF_INET6 +local SOCK_STREAM = const.SOCK_STREAM or 1 +local SOCK_DGRAM = const.SOCK_DGRAM or 2 + +local function errno_msg(default, eno) + if not eno or eno == 0 then + return default + end + local s = nixio.strerror(eno) + if not s or s == "" then + return default .. " (errno " .. tostring(eno) .. ")" + end + return s +end + +---------------------------------------------------------------------- +-- Core ops: set_nonblock / read / write / seek / close +---------------------------------------------------------------------- + +-- fd here is a nixio.File or nixio.Socket +local function set_nonblock(fd) + if fd and fd.setblocking then + local ok, eno = fd:setblocking(false) + if ok ~= nil and ok ~= false then + return true, nil, eno + end + eno = eno or nixio.errno() + return false, errno_msg("setblocking(false) failed", eno), eno + end + -- If there is no setblocking, treat as already non-blocking. + return true, nil, nil +end + +local function read_fd(fd, max) + if not fd then + return nil, "closed" + end + + max = max or const.buffersize or 8192 + if max <= 0 then + return "", nil + end + + -- nixio.File:read / Socket:read both follow the same style: + -- data (success/EOF) + -- nil, msg, errno (error) + local data, msg, eno = fd:read(max) + + if data ~= nil then + -- data may be "" at EOF; that is acceptable to callers. + return data, nil + end + + eno = eno or nixio.errno() + + if eno == EAGAIN or eno == EWOULDBLOCK then + -- Would block, signal “not ready yet”. + return nil, nil + end + + if not eno or eno == 0 then + -- Treat as EOF. + return "", nil + end + + return nil, errno_msg(msg or "read failed", eno) +end + +local function write_fd(fd, str, len) + if not fd then + return nil, "closed" + end + + len = len or #str + if len == 0 then + return 0, nil + end + + -- For files: File.write(buf, offset, length) + -- For sockets: Socket.send / write(buf, offset, length) – same shape. + local n, msg, eno = fd:write(str, 0, len) + + if n ~= nil then + return n, nil + end + + eno = eno or nixio.errno() + + if eno == EAGAIN or eno == EWOULDBLOCK then + -- Would block. + return nil, nil + end + + return nil, errno_msg(msg or "write failed", eno) +end + +local SEEK_MAP = { + set = "set", + cur = "cur", + ["end"] = "end", +} + +local function seek_fd(fd, whence, off) + if not fd then + return nil, "closed" + end + + whence = SEEK_MAP[whence] or whence or "cur" + off = off or 0 + + if not fd.seek then + return nil, "seek not supported on this descriptor" + end + + local pos, msg, eno = fd:seek(off, whence) + if pos == nil then + eno = eno or nixio.errno() + return nil, errno_msg(msg or "seek failed", eno) + end + return pos, nil +end + +local function close_fd(fd) + if not fd then + return true, nil + end + + local ok, msg, eno = fd:close() + if ok == nil or ok == false then + eno = eno or nixio.errno() + return false, errno_msg(msg or "close failed", eno) + end + return true, nil +end + +---------------------------------------------------------------------- +-- File-level helpers: open_file / pipe / mktemp / fsync / rename / unlink +---------------------------------------------------------------------- + +-- For this backend we rely on nixio.open’s mode strings. +local function open_file(path, mode, perms) + mode = mode or "r" + + local f, eno = nixio.open(path, mode, perms) + if not f then + return nil, errno_msg("open failed", eno) + end + return f, nil +end + +local function pipe_fds() + local r, w, eno = nixio.pipe() + if not r then + return nil, nil, errno_msg("pipe failed", eno) + end + return r, w, nil +end + +local function mktemp(prefix, perms) + -- Very simple mktemp: we try a few names and rely on low collision + -- probability. This mirrors the earlier “simple” backend you tested. + local start = math.random(1e7) + local last_err + + for i = start, start + 10 do + local tmpnam = prefix .. "." .. i + local f, eno = nixio.open(tmpnam, "w+", perms) + if f then + return f, tmpnam + end + last_err = errno_msg("mktemp open failed", eno) + end + + return nil, last_err or "mktemp: failed to create temporary file" +end + +local function fsync_fd(fd) + if not fd or not fd.sync then + return true, nil + end + local ok, msg, eno = fd:sync(false) + if ok == nil or ok == false then + eno = eno or nixio.errno() + return false, errno_msg(msg or "fsync failed", eno) + end + return true, nil +end + +local function rename_file(oldpath, newpath) + local ok, msg, eno = fs.rename(oldpath, newpath) + if ok == nil or ok == false then + return false, errno_msg(msg or "rename failed", eno) + end + return true, nil +end + +local function unlink_file(path) + local ok, msg, eno = fs.unlink(path) + if ok == nil or ok == false then + return false, errno_msg(msg or "unlink failed", eno) + end + return true, nil +end + +-- For this backend, integer open flags are not used; when decode_access +-- is called we can conservatively assume read/write. +local function decode_access(_) + return true, true +end + +local function ignore_sigpipe() + -- Best-effort ignore of SIGPIPE. + if nixio.signal and nixio.SIGPIPE then + local ok, eno = nixio.signal(nixio.SIGPIPE, "ign") + if ok == nil or ok == false then + return false, errno_msg("signal(SIGPIPE) failed", eno) + end + end + return true, nil +end + +---------------------------------------------------------------------- +-- Socket helpers +---------------------------------------------------------------------- + +local function domain_to_str(domain) + if domain == AF_UNIX then + return "unix" + end + if AF_INET and domain == AF_INET then + return "inet" + end + if AF_INET6 and domain == AF_INET6 then + return "inet6" + end + error("fd_backend.nixio: unsupported address family: " .. tostring(domain)) +end + +local function stype_to_str(stype) + if stype == SOCK_STREAM then + return "stream" + end + if SOCK_DGRAM and stype == SOCK_DGRAM then + return "dgram" + end + error("fd_backend.nixio: unsupported socket type: " .. tostring(stype)) +end + +--- socket(domain, stype, protocol) -> fd|nil, err|nil, eno|nil +local function socket_fd(domain, stype, _) + local d = domain_to_str(domain) + local t = stype_to_str(stype) + + local s, eno = nixio.socket(d, t) + if not s then + return nil, errno_msg("socket failed", eno), eno + end + -- Returned “fd” is a nixio.Socket object. + return s, nil, nil +end + +--- bind(fd, sa) where fd is nixio.Socket; sa is e.g. UNIX path string. +local function bind_fd(fd, sa) + if not fd then + return false, "closed socket", nil + end + + local ok, msg, eno + + if type(sa) == "string" then + -- For AF_UNIX, host is path, port is ignored. We pass 0 as a dummy. + ok, msg, eno = fd:bind(sa, 0) + else + return false, "unsupported sockaddr representation", nil + end + + if ok == nil or ok == false then + eno = eno or nixio.errno() + return false, errno_msg(msg or "bind failed", eno), eno + end + + return true, nil, nil +end + +local function listen_fd(fd) + if not fd then + return false, "closed socket", nil + end + + local backlog = const.SOMAXCONN or 128 + local ok, msg, eno = fd:listen(backlog) + if ok == nil or ok == false then + eno = eno or nixio.errno() + return false, errno_msg(msg or "listen failed", eno), eno + end + return true, nil, nil +end + +--- accept(fd) -> newfd|nil, err|nil, again:boolean +local function accept_fd(fd) + if not fd then + return nil, "closed socket", false + end + + -- nixio.Socket.accept() -> newsock, host, port | nil, msg, errno + local newsock, _, _, msg, eno = fd:accept() + if newsock then + return newsock, nil, false + end + + eno = eno or nixio.errno() + if eno == EAGAIN or eno == EWOULDBLOCK then + return nil, nil, true + end + + return nil, errno_msg(msg or "accept failed", eno), false +end + +--- connect_start(fd, sa) -> ok|nil, err|nil, inprogress:boolean +local function connect_start_fd(fd, sa) + if not fd then + return nil, "closed socket", false + end + + local ok, msg, eno + + if type(sa) == "string" then + -- For AF_UNIX, host is path, port is ignored. + ok, msg, eno = fd:connect(sa, 0) + else + return nil, "unsupported sockaddr representation", false + end + + if ok then + return true, nil, false + end + + eno = eno or nixio.errno() + if eno == EINPROGRESS or eno == EALREADY or eno == EAGAIN then + -- Non-blocking connect in progress. + return nil, nil, true + end + + return nil, errno_msg(msg or "connect failed", eno), false +end + +--- connect_finish(fd) -> ok:boolean, err|nil +local function connect_finish_fd(fd) + if not fd then + return false, "closed socket" + end + + if not fd.getopt then + -- Fallback: if we cannot inspect SO_ERROR, assume success. + return true, nil + end + + local soerr, msg, eno = fd:getopt("socket", "error") + if soerr == nil then + eno = eno or nixio.errno() + return false, errno_msg(msg or "getsockopt(SO_ERROR) failed", eno) + end + + if soerr == 0 then + return true, nil + end + + return false, errno_msg("connect error", soerr) +end + +---------------------------------------------------------------------- +-- Capability probe +---------------------------------------------------------------------- + +local function is_supported() + -- If this module loaded, nixio was already required successfully. + return true +end + +---------------------------------------------------------------------- +-- Assemble ops and build backend +---------------------------------------------------------------------- + +local ops = { + -- Core file/socket descriptor ops + set_nonblock = set_nonblock, + read = read_fd, + write = write_fd, + seek = seek_fd, + close = close_fd, + + -- File-level helpers + open_file = open_file, + pipe = pipe_fds, + mktemp = mktemp, + fsync = fsync_fd, + rename = rename_file, + unlink = unlink_file, + decode_access = decode_access, + ignore_sigpipe = ignore_sigpipe, + + -- Socket-level helpers + socket = socket_fd, + bind = bind_fd, + listen = listen_fd, + accept = accept_fd, + connect_start = connect_start_fd, + connect_finish = connect_finish_fd, + + -- Metadata for callers (fibers.io.socket re-exports these) + modes = {}, -- not used for nixio; kept for compatibility + permissions = {}, + + AF_UNIX = AF_UNIX, + SOCK_STREAM = SOCK_STREAM, + + is_supported = is_supported, +} + +return core.build_backend(ops) diff --git a/src/fibers/io/fd_backend/posix.lua b/src/fibers/io/fd_backend/posix.lua index 9c3aa3e..982e526 100644 --- a/src/fibers/io/fd_backend/posix.lua +++ b/src/fibers/io/fd_backend/posix.lua @@ -55,7 +55,7 @@ local function read_fd(fd, max) return s, nil end -local function write_fd(fd, str, _len) +local function write_fd(fd, str, _) local n, err, eno = unistd.write(fd, str) if n == nil then if eno == errno.EAGAIN or eno == errno.EWOULDBLOCK then @@ -238,10 +238,6 @@ end -- Socket helpers on top of posix.sys.socket ---------------------------------------------------------------------- ----------------------------------------------------------------------- --- Socket helpers on top of posix.sys.socket (AF_UNIX focus) ----------------------------------------------------------------------- - --- Create a socket fd. ---@param domain integer ---@param stype integer diff --git a/src/fibers/io/file.lua b/src/fibers/io/file.lua index caf3392..61b7d90 100644 --- a/src/fibers/io/file.lua +++ b/src/fibers/io/file.lua @@ -66,7 +66,8 @@ end ---@param filename? string ---@return Stream local function fdopen(fd, flags_or_mode, filename) - assert(type(fd) == "number", "fdopen: fd must be a number") + -- assert(type(fd) == "number", "fdopen: fd must be a number") + assert(type(fd) ~= nil, "fdopen: fd must be non-nil") local readable, writable diff --git a/src/fibers/io/poller.lua b/src/fibers/io/poller.lua index 10350ef..f6a14b8 100644 --- a/src/fibers/io/poller.lua +++ b/src/fibers/io/poller.lua @@ -5,8 +5,9 @@ -- to a pure-luaposix select/poll implementation. local candidates = { - 'fibers.io.poller.epoll', -- Linux + FFI/epoll - 'fibers.io.poller.select', -- luaposix poll/select + -- 'fibers.io.poller.epoll', -- Linux + FFI/epoll + -- 'fibers.io.poller.select', -- luaposix poll/select + 'fibers.io.poller.nixio', -- nixio poll/select } for _, name in ipairs(candidates) do diff --git a/src/fibers/io/poller/core.lua b/src/fibers/io/poller/core.lua index f6b55f2..2f3f690 100644 --- a/src/fibers/io/poller/core.lua +++ b/src/fibers/io/poller/core.lua @@ -63,7 +63,8 @@ end ---@param task Task ---@return WaitToken function Poller:wait(fd, dir, task) - assert(type(fd) == "number", "fd must be number") + -- assert(type(fd) == "number", "fd must be number") + assert(type(fd) ~= nil, "fd must be non-nil") assert(dir == "rd" or dir == "wr", "dir must be 'rd' or 'wr'") local ws = (dir == "rd") and self.rd or self.wr diff --git a/src/fibers/io/poller/nixio.lua b/src/fibers/io/poller/nixio.lua new file mode 100644 index 0000000..45fca09 --- /dev/null +++ b/src/fibers/io/poller/nixio.lua @@ -0,0 +1,105 @@ +-- fibers/io/poller/nixio.lua +-- +-- nixio.poll()-based poller backend (no epoll / luaposix dependency). +-- Intended to be selected via fibers.io.poller. +-- +---@module 'fibers.io.poller.nixio' + +local core = require 'fibers.io.poller.core' +local nixio = require 'nixio' + +---------------------------------------------------------------------- +-- Backend ops for poller.core +---------------------------------------------------------------------- + +local function new_backend() + -- No persistent kernel state required; everything is derived from the + -- current waitsets on each poll call. + return {} +end + +--- Build the fds table in the shape expected by nixio.poll: +--- fds[i] = { fd = , events = } +--- +--- Here the "fd" field is the nixio object itself; poll() accepts that. +local function build_fds(rd_waitset, wr_waitset) + local fds = {} + local index = 1 + + -- We need to iterate over the union of keys in both waitsets. + local seen = {} + + for fd, list in pairs(rd_waitset.buckets) do + if list and #list > 0 then + local events = nixio.poll_flags("in") + fds[index] = { fd = fd, events = events } + seen[fd] = index + index = index + 1 + end + end + + for fd, list in pairs(wr_waitset.buckets) do + if list and #list > 0 then + local pos = seen[fd] + if pos then + local e = fds[pos] + e.events = nixio.poll_flags(e.events, "out") + else + local events = nixio.poll_flags("out") + fds[index] = { fd = fd, events = events } + seen[fd] = index + index = index + 1 + end + end + end + + return fds +end + +local function poll_backend(_, timeout_ms, rd_waitset, wr_waitset) + local fds = build_fds(rd_waitset, wr_waitset) + + -- nixio.poll(fds, timeout_ms) -> nready, fds' + local nready, fds_ret, _, _ = nixio.poll(fds, timeout_ms) + + if not nready then + -- Treat EINTR as benign; anything else can reasonably surface. + -- For a "simple" backend you can choose to treat all errors as + -- "no events"; if you prefer you can error() here instead. + return {} + end + + if nready == 0 then + return {} + end + + local events = {} + + for _, info in pairs(fds_ret) do + local revents = info.revents or 0 + if revents ~= 0 then + local flags = nixio.poll_flags(revents) + events[info.fd] = { + rd = not not (flags["in"] or flags.hup or flags.err or flags.nval), + wr = not not (flags.out or flags.err or flags.nval), + err = not not (flags.err or flags.nval), + } + end + end + + return events +end + +local function is_supported() + local ok = pcall(require, 'nixio') + return ok +end + +local ops = { + new_backend = new_backend, + poll = poll_backend, + -- on_wait_change not needed; state is rebuilt each poll. + is_supported = is_supported, +} + +return core.build_poller(ops) From 536270e8080877e53b0ca4741e960dafdba006ef Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Mon, 8 Dec 2025 02:33:39 +0000 Subject: [PATCH 072/138] updates utils/time, scheduler and tests to use new time, removes unused tests --- src/fibers/sched.lua | 6 +- src/fibers/utils/time.lua | 42 +---- src/fibers/utils/time/core.lua | 128 +++++++------ src/fibers/utils/time/ffi.lua | 191 ++++++++++--------- src/fibers/utils/time/linux.lua | 285 ++++++++++++++--------------- src/fibers/utils/time/luaposix.lua | 137 ++++++++++++++ src/fibers/utils/time/nixio.lua | 175 ++++++++++++++++++ src/fibers/utils/time/posix.lua | 104 ----------- tests/test_cond.lua | 6 +- tests/test_exec.lua | 258 -------------------------- tests/test_io-exec.lua | 224 +++++++++++++++++++++-- tests/test_io-file.lua | 2 + tests/test_runtime.lua | 10 +- tests/test_sched.lua | 10 +- tests/test_timer.lua | 24 +-- tests/test_utils-fixed_buffer.lua | 94 ---------- tests/test_waitgroup.lua | 6 +- 17 files changed, 878 insertions(+), 824 deletions(-) create mode 100644 src/fibers/utils/time/luaposix.lua create mode 100644 src/fibers/utils/time/nixio.lua delete mode 100644 src/fibers/utils/time/posix.lua delete mode 100644 tests/test_exec.lua delete mode 100644 tests/test_utils-fixed_buffer.lua diff --git a/src/fibers/sched.lua b/src/fibers/sched.lua index 12c31a3..b6045f5 100644 --- a/src/fibers/sched.lua +++ b/src/fibers/sched.lua @@ -32,10 +32,10 @@ local Scheduler = {} Scheduler.__index = Scheduler --- Create a new scheduler instance. ----@param get_time? fun(): number # monotonic time source (defaults to fibers.utils.time.now) +---@param get_time? fun(): number # monotonic time source (defaults to fibers.utils.time.monotonic) ---@return Scheduler local function new(get_time) - local now_src = get_time or time.now + local now_src = get_time or time.monotonic local now = now_src() local ret = setmetatable({ @@ -163,7 +163,7 @@ function Scheduler:wait_for_events() self.event_waiter:wait_for_events(self, now, timeout) else -- No poller installed; fall back to process-blocking sleep. - time.sleep_blocking(timeout) + time._block(timeout) end end diff --git a/src/fibers/utils/time.lua b/src/fibers/utils/time.lua index 928d40c..3dd0c7e 100644 --- a/src/fibers/utils/time.lua +++ b/src/fibers/utils/time.lua @@ -3,15 +3,17 @@ -- Top-level time provider shim. -- -- Backends (priority order): --- 1. fibers.utils.time.ffi - clock_gettime + nanosleep (via ffi_compat) --- 2. fibers.utils.time.posix - luaposix clock_gettime/gettimeofday --- 3. fibers.utils.time.linux - /proc/uptime or os.time + os.execute("sleep") +-- 1. fibers.utils.time.ffi - clock_gettime + nanosleep (via ffi_compat) +-- 2. fibers.utils.time.luaposix - luaposix clock_gettime/nanosleep +-- 3. fibers.utils.time.nixio - nixio gettime, nanosleep + /proc/uptime +-- 4. fibers.utils.time.linux - /proc/uptime or os.time + os.execute("sleep") -- ---@module 'fibers.utils.time' local candidates = { 'fibers.utils.time.ffi', - 'fibers.utils.time.posix', + 'fibers.utils.time.luaposix', + 'fibers.utils.time.nixio', 'fibers.utils.time.linux', } @@ -29,34 +31,4 @@ if not chosen then error("fibers.utils.time: no suitable time backend available on this platform") end -local function now() - return chosen.now() -end - ---- Best-effort process-blocking, non-busy sleep. ---- ---- Intended only for the scheduler path when no poller task source is ---- installed. In normal operation you should continue to use the ---- scheduler-based fibres.sleep module. ----@param dt number -local function sleep_blocking(dt) - if dt <= 0 then return end - - if type(chosen.sleep) == "function" then - return chosen.sleep(dt) - end - - -- Last resort: busy loop on now(). This path should only be used in - -- very constrained environments. - local start = now() - while now() - start < dt do end -end - -return { - now = now, - resolution = chosen.resolution, - source = chosen.source or chosen.impl, - impl = chosen.impl, - monotonic = chosen.monotonic ~= false, - sleep_blocking = sleep_blocking, -} +return chosen diff --git a/src/fibers/utils/time/core.lua b/src/fibers/utils/time/core.lua index c39f553..dd195da 100644 --- a/src/fibers/utils/time/core.lua +++ b/src/fibers/utils/time/core.lua @@ -1,72 +1,88 @@ --- fibers.utils.time.core +-- fibers/utils/time/core.lua -- --- Contract and helpers for time providers. +-- Core glue for time backends. +-- +---@class TimeSourceInfo +---@field name string +---@field resolution number +---@field monotonic boolean +---@field epoch string|nil ----@module 'fibers.utils.time.core' +---@class TimeSleepInfo +---@field name string +---@field resolution number +---@field clock "realtime"|"monotonic"|string ----@class TimeSourceOps ----@field name string|nil ----@field impl string|nil ----@field monotonic boolean|nil ----@field now fun(): number ----@field resolution number|nil ----@field sleep fun(dt:number)|nil -- optional process-blocking sleep +---@class TimeOps +---@field realtime fun(): number +---@field monotonic fun(): number +---@field realtime_info TimeSourceInfo +---@field monotonic_info TimeSourceInfo +---@field _block fun(dt: number): boolean, string|nil +---@field block_info TimeSleepInfo +---@field is_supported fun(): boolean|nil -- optional ----@class TimeSource ----@field name string ----@field impl string ----@field monotonic boolean ----@field now fun(): number ----@field resolution number|nil ----@field sleep fun(dt:number)|nil +local function build_backend(ops) + assert(type(ops) == "table", "time backend ops must be a table") + assert(type(ops.realtime) == "function", "time ops.realtime must be a function") + assert(type(ops.monotonic) == "function", "time ops.monotonic must be a function") + assert(type(ops._block) == "function", "time ops._block must be a function") + assert(type(ops.realtime_info) == "table", "time ops.realtime_info must be a table") + assert(type(ops.monotonic_info) == "table", "time ops.monotonic_info must be a table") + assert(type(ops.block_info) == "table", "time ops.block_info must be a table") -local M = {} + local function realtime() + return ops.realtime() + end ---- Build a normalised TimeSource from a backend ops table. ----@param ops TimeSourceOps ----@return TimeSource -function M.build_source(ops) - assert(type(ops) == "table", "time source ops must be a table") - assert(type(ops.now) == "function", "time source requires now()") + local function monotonic() + return ops.monotonic() + end - local src = { - name = ops.name or "time", - impl = ops.impl or ops.name or "time", - monotonic = ops.monotonic ~= false, - now = ops.now, - resolution = ops.resolution, - sleep = ops.sleep, - } + local function block(dt) + assert(type(dt) == "number" and dt >= 0, "block: dt must be a non-negative number") + return ops._block(dt) + end - return src -end + local function info() + return { + realtime = ops.realtime_info, + monotonic = ops.monotonic_info, + sleep = ops.block_info, + } + end ---- Estimate timer resolution by sampling consecutive now() calls. ---- ---- This is inherently a busy-loop; only use at initialisation. ----@param now_fn fun(): number ----@param samples? integer ----@return number|nil -function M.estimate_resolution(now_fn, samples) - samples = samples or 256 - assert(type(now_fn) == "function", "estimate_resolution: now_fn must be a function") + local function realtime_source() + return ops.realtime_info + end - local best = math.huge - local last = now_fn() + local function monotonic_source() + return ops.monotonic_info + end - for _ = 1, samples do - local t = now_fn() - local dt = t - last - last = t - if dt > 0 and dt < best then - best = dt - end + local function block_source() + return ops.block_info end - if best == math.huge or best <= 0 then - return nil + local function is_supported() + if type(ops.is_supported) == "function" then + return not not ops.is_supported() + end + return true end - return best + + return { + realtime = realtime, + monotonic = monotonic, + _block = block, + info = info, + realtime_source = realtime_source, + monotonic_source = monotonic_source, + block_source = block_source, + is_supported = is_supported, + } end -return M +return { + build_backend = build_backend, +} diff --git a/src/fibers/utils/time/ffi.lua b/src/fibers/utils/time/ffi.lua index 9f9bc5c..6a8583e 100644 --- a/src/fibers/utils/time/ffi.lua +++ b/src/fibers/utils/time/ffi.lua @@ -1,40 +1,47 @@ --- fibers.utils.time.ffi +-- fibers/utils/time/ffi.lua -- --- FFI-based time provider using clock_gettime() and nanosleep(). +-- Time backend using clock_gettime(2) and nanosleep(2) via ffi_compat. +-- Linux-oriented; requires fibers.utils.ffi_compat. -- ---@module 'fibers.utils.time.ffi' -local core = require 'fibers.utils.time.core' -local ffi_c = require 'fibers.utils.ffi_compat' +local core = require 'fibers.utils.time.core' +local ffi_c = require 'fibers.utils.ffi_compat' if not (ffi_c.is_supported and ffi_c.is_supported()) then return { is_supported = function() return false end } end -local ffi = ffi_c.ffi -local C = ffi_c.C -local toint = ffi_c.tonumber -local errno = ffi_c.errno +local ffi = ffi_c.ffi +local C = ffi_c.C +local toint = ffi_c.tonumber +local get_errno = ffi_c.errno ffi.cdef[[ typedef long time_t; + typedef long suseconds_t; + struct timespec { time_t tv_sec; long tv_nsec; }; int clock_gettime(int clk_id, struct timespec *tp); + int clock_getres(int clk_id, struct timespec *tp); int nanosleep(const struct timespec *req, struct timespec *rem); - char *strerror(int errnum); ]] --- Linux / POSIX clock ids. -local CLOCK_MONOTONIC = 1 -local CLOCK_MONOTONIC_RAW = 4 +-- Linux/glibc constants. +local CLOCK_REALTIME = 0 +local CLOCK_MONOTONIC = 1 local EINTR = 4 +---------------------------------------------------------------------- +-- Helpers +---------------------------------------------------------------------- + local function strerror(e) local s = C.strerror(e) if s == nil then @@ -43,110 +50,118 @@ local function strerror(e) return ffi.string(s) end -local function make_now(clock_id) - local ts = ffi.new("struct timespec[1]") +local function ts_to_seconds(ts) + return tonumber(ts.tv_sec) + tonumber(ts.tv_nsec) * 1e-9 +end - return function() - local rc = toint(C.clock_gettime(clock_id, ts)) - if rc ~= 0 then - local e = errno() - error("clock_gettime failed: " .. strerror(e)) - end - local t = ts[0] - return tonumber(t.tv_sec) + tonumber(t.tv_nsec) * 1e-9 +local function read_clock(clk_id) + local ts = ffi.new("struct timespec[1]") + local rc = toint(C.clock_gettime(clk_id, ts)) + if rc ~= 0 then + local e = get_errno() + error("clock_gettime failed: " .. strerror(e)) end + return ts_to_seconds(ts[0]) end -local function probe_clock(clock_id) - local ok, now_or_err = pcall(make_now, clock_id) - if not ok then - return nil, now_or_err +local function clock_resolution(clk_id) + local ts = ffi.new("struct timespec[1]") + local rc = toint(C.clock_getres(clk_id, ts)) + if rc ~= 0 then + -- Fall back to a conservative default (1 ms) if the query fails. + return 1e-3 end - local now = now_or_err + return ts_to_seconds(ts[0]) +end - local ok2, t = pcall(now) - if not ok2 or type(t) ~= "number" then - return nil, t - end +---------------------------------------------------------------------- +-- Blocking sleep +---------------------------------------------------------------------- - return now, nil -end +local function _block(dt) + if dt <= 0 then + return true, nil + end -local function sleep_blocking(dt) - if dt <= 0 then return end + local req = ffi.new("struct timespec[1]") + local rem = ffi.new("struct timespec[1]") local sec = math.floor(dt) - local nsec = math.floor((dt - sec) * 1e9) - if nsec < 0 then nsec = 0 end - if nsec >= 1e9 then + local frac = dt - sec + local nsec = math.floor(frac * 1e9 + 0.5) + if nsec >= 1000000000 then sec = sec + 1 - nsec = nsec - 1e9 + nsec = nsec - 1000000000 end - local req = ffi.new("struct timespec[1]") req[0].tv_sec = sec req[0].tv_nsec = nsec while true do - local rc = toint(C.nanosleep(req, req)) + local rc = toint(C.nanosleep(req, rem)) if rc == 0 then - break + return true, nil end - local e = errno() - if e ~= EINTR then - break + local e = get_errno() + if e == EINTR then + -- Interrupted; continue with remaining time. + req[0].tv_sec = rem[0].tv_sec + req[0].tv_nsec = rem[0].tv_nsec + else + return false, "nanosleep failed: " .. strerror(e) end - -- EINTR: req now holds remaining time; loop again. end end -local function build() - -- Prefer MONOTONIC_RAW, fall back to MONOTONIC. - local now, err = probe_clock(CLOCK_MONOTONIC_RAW) - local impl = "ffi.clock_gettime(CLOCK_MONOTONIC_RAW)" +---------------------------------------------------------------------- +-- Metadata and support +---------------------------------------------------------------------- - if not now then - now, err = probe_clock(CLOCK_MONOTONIC) - impl = "ffi.clock_gettime(CLOCK_MONOTONIC)" - end - - if not now then - error(err or "no usable clock_gettime clock") - end - - local src = core.build_source{ - name = "clock_gettime", - impl = impl, - monotonic = true, - now = now, - sleep = sleep_blocking, - } - - src.resolution = src.resolution or core.estimate_resolution(src.now) - - return src -end +local realtime_res = clock_resolution(CLOCK_REALTIME) +local monotonic_res = clock_resolution(CLOCK_MONOTONIC) local function is_supported() - local ok, res = pcall(build) - if not ok then - return false - end - return res and true or false -end - -if not is_supported() then - return { is_supported = function() return false end } + -- Probe both clocks once; treat errors as lack of support. + local ok = pcall(function() + read_clock(CLOCK_REALTIME) + read_clock(CLOCK_MONOTONIC) + end) + return ok end -local src = build() +local ops = { + realtime = function() + return read_clock(CLOCK_REALTIME) + end, + + monotonic = function() + return read_clock(CLOCK_MONOTONIC) + end, + + realtime_info = { + name = "clock_gettime(CLOCK_REALTIME)", + resolution = realtime_res, + monotonic = false, + epoch = "unix", + }, + + monotonic_info = { + name = "clock_gettime(CLOCK_MONOTONIC)", + resolution = monotonic_res, + monotonic = true, + epoch = "unspecified", + }, + + _block = _block, + + block_info = { + name = "nanosleep", + resolution = monotonic_res, + clock = "monotonic", + }, -return { is_supported = is_supported, - now = src.now, - resolution = src.resolution, - source = src.name, - impl = src.impl, - monotonic = src.monotonic, - sleep = src.sleep, } + + +return core.build_backend(ops) diff --git a/src/fibers/utils/time/linux.lua b/src/fibers/utils/time/linux.lua index 5e167ef..2a3fcf7 100644 --- a/src/fibers/utils/time/linux.lua +++ b/src/fibers/utils/time/linux.lua @@ -1,31 +1,18 @@ --- fibers.utils.time.linux +-- fibers/utils/time/linux.lua -- --- Generic Linux / POSIX fallback time provider. --- --- Strategy: --- * Probe os.execute("sleep 0.01") to see if shell sleep exists and --- accepts fractional seconds. --- * If that succeeds and /proc/uptime is available, use /proc/uptime --- for a monotonic clock and fractional "sleep dt". --- * Otherwise, fall back to os.time() and integer-second sleep. +-- Pure Lua fallback time backend for Linux. +-- Monotonic time from /proc/uptime; blocking sleep via shell "sleep". -- ---@module 'fibers.utils.time.linux' local core = require 'fibers.utils.time.core' -local UPTIME_PATH = "/proc/uptime" - -local function have_proc_uptime() - local f = io.open(UPTIME_PATH, "r") - if not f then - return false - end - f:close() - return true -end +---------------------------------------------------------------------- +-- Monotonic time: /proc/uptime +---------------------------------------------------------------------- -local function read_uptime() - local f = io.open(UPTIME_PATH, "r") +local function read_uptime_raw() + local f = io.open("/proc/uptime", "r") if not f then return nil end @@ -34,156 +21,168 @@ local function read_uptime() if not line then return nil end - local first = line:match("^%s*([0-9]+%.?[0-9]*)") - if not first then + local first = line:match("^(%S+)") + return first +end + +local function read_uptime() + local token = read_uptime_raw() + if not token then return nil end - return tonumber(first) + local v = tonumber(token) + return v +end + +-- Estimate resolution from the number of fractional digits in /proc/uptime. +local monotonic_resolution = 1.0 +do + local token = read_uptime_raw() + if token then + local frac = token:match("%.([0-9]+)") + if frac and #frac > 0 then + monotonic_resolution = 10 ^ (-#frac) + end + end end ---- Normalise os.execute return across Lua versions. ----@param cmd string ----@return boolean ok -local function command_succeeds(cmd) - if type(os) ~= "table" or type(os.execute) ~= "function" then - return false +local function monotonic() + local v = read_uptime() + if not v then + error("fallback monotonic: /proc/uptime not available") end + return v +end - local ok, a, b = pcall(os.execute, cmd) - if not ok then - return false +---------------------------------------------------------------------- +-- Realtime: prefer luasocket.gettime, fall back to os.time +---------------------------------------------------------------------- + +local realtime_fn +local realtime_name +local realtime_resolution + +do + local ok, socket = pcall(require, 'socket') + if ok and type(socket) == "table" and type(socket.gettime) == "function" then + realtime_fn = socket.gettime + realtime_name = "socket.gettime" + realtime_resolution = 1e-6 -- approximate; depends on platform + else + realtime_fn = function() return os.time() end + realtime_name = "os.time" + realtime_resolution = 1.0 end +end + +local function realtime() + return realtime_fn() +end - -- Lua 5.2+: ok==true, a is true/nil/"exit"/"signal". +---------------------------------------------------------------------- +-- Blocking sleep: shell "sleep", fractional if available +---------------------------------------------------------------------- + +local function command_succeeded(a, b, c) + -- Lua 5.1: boolean, "exit"/"signal", code if a == true then return true end + -- Lua 5.2+: numeric exit code if type(a) == "number" then return a == 0 end - if a == "exit" then - return b == 0 - end - - -- Lua 5.1: rc is numeric status. - if a ~= nil and b == nil and type(a) ~= "string" then - return a == 0 + -- Some implementations: nil, "exit", code + if a == nil and b == "exit" then + return c == 0 end - return false end -local function probe_sleep() - if type(os) ~= "table" or type(os.execute) ~= "function" then - return { available = false, fractional = false } - end - - -- Prefer to test fractional first. - if command_succeeds("sleep 0.01") then - return { available = true, fractional = true } - end - - -- Fall back to integral only. - if command_succeeds("sleep 1") then - return { available = true, fractional = false } - end +local function run_sleep(arg) + local cmd = "sleep " .. arg + local a, b, c = os.execute(cmd) + return command_succeeded(a, b, c) +end - return { available = false, fractional = false } +local has_fractional_sleep do + local ok = run_sleep("0.01") + has_fractional_sleep = ok end -local function build() - local sleep_info = probe_sleep() - local have_sleep = sleep_info.available - local frac_sleep = sleep_info.fractional - - local now - local impl - local monotonic - - if have_proc_uptime() and frac_sleep then - local base = read_uptime() - assert(type(base) == "number", "failed to read /proc/uptime") - - now = function() - local t = read_uptime() - if not t then - error("failed to read /proc/uptime") - end - return t - base +local function _block(dt) + if dt <= 0 then + return true, nil + end + + if has_fractional_sleep then + -- Round to centiseconds. + local centis = math.max(1, math.floor(dt * 100 + 0.5)) + local arg = string.format("%.2f", centis / 100.0) + local ok = run_sleep(arg) + if not ok then + -- As a last resort, busy-wait using monotonic time. + local start = monotonic() + while monotonic() - start < dt do end + return true, "sleep command failed; used busy-wait" end - - impl = "linux.proc_uptime" - monotonic = true + return true, nil else - -- Coarse wall-clock baseline; only used as a last resort. - now = function() return os.time() end - impl = "os.time" - monotonic = false - end - - local sleep_fn - if have_sleep then - sleep_fn = function(dt) - if dt <= 0 then - return - end - - if frac_sleep then - local secs = dt - if secs > 86400 then - secs = 86400 - elseif secs < 0 then - secs = 0 - end - local cmd = ("sleep %.6f"):format(secs) - command_succeeds(cmd) - else - local secs = math.ceil(dt) - if secs < 1 then - secs = 1 - end - local cmd = ("sleep %d"):format(secs) - command_succeeds(cmd) - end + -- Integral seconds only; round up so we do not undersleep. + local secs = math.ceil(dt) + local ok = run_sleep(tostring(secs)) + if not ok then + local start = monotonic() + while monotonic() - start < dt do end + return true, "sleep command failed; used busy-wait" end + return true, nil end - - local src = core.build_source{ - name = "Linux fallback time", - impl = impl, - monotonic = monotonic, - now = now, - sleep = sleep_fn, - } - - if impl == "linux.proc_uptime" then - src.resolution = src.resolution or core.estimate_resolution(src.now, 64) - else - -- os.time() is normally second-resolution. - src.resolution = 1.0 - end - - return src end -local function is_supported() - -- This is intended as a generic Posix/Linux fallback, so be permissive: - -- require a usable os table at least. - return type(os) == "table" -end +---------------------------------------------------------------------- +-- Capability and metadata +---------------------------------------------------------------------- -if not is_supported() then - return { is_supported = function() return false end } +local function is_supported() + -- This backend is only considered usable if /proc/uptime exists. + local f = io.open("/proc/uptime", "r") + if f then + f:close() + return true + end + return false end -local src = build() - -return { - is_supported = function() return true end, - now = src.now, - resolution = src.resolution, - source = src.name, - impl = src.impl, - monotonic = src.monotonic, - sleep = src.sleep, +local ops = { + realtime = realtime, + monotonic = monotonic, + + realtime_info = { + name = realtime_name, + resolution = realtime_resolution, + monotonic = false, + epoch = "unix", + }, + + monotonic_info = { + name = "/proc/uptime", + resolution = monotonic_resolution, + monotonic = true, + epoch = "unspecified", + }, + + _block = _block, + + block_info = { + name = has_fractional_sleep + and "sleep (shell, fractional)" + or "sleep (shell, integral)", + resolution = has_fractional_sleep and 0.01 or 1.0, + clock = "realtime", + }, + + is_supported = is_supported, } + +return core.build_backend(ops) diff --git a/src/fibers/utils/time/luaposix.lua b/src/fibers/utils/time/luaposix.lua new file mode 100644 index 0000000..d405121 --- /dev/null +++ b/src/fibers/utils/time/luaposix.lua @@ -0,0 +1,137 @@ +-- fibers/utils/time/luaposix.lua +-- +-- Time backend using posix.time (clock_gettime/nanosleep) and posix.unistd. +-- +---@module 'fibers.utils.time.luaposix' + +local core = require 'fibers.utils.time.core' + +local ok_time, ptime = pcall(require, 'posix.time') +if not ok_time or type(ptime) ~= "table" then + return { is_supported = function() return false end } +end + +local ok_unistd, unistd = pcall(require, 'posix.unistd') +if not ok_unistd or type(unistd) ~= "table" then + return { is_supported = function() return false end } +end + +local errno = require 'posix.errno' + +local CLOCK_REALTIME = ptime.CLOCK_REALTIME +local CLOCK_MONOTONIC = ptime.CLOCK_MONOTONIC + +if not CLOCK_REALTIME or not CLOCK_MONOTONIC then + return { is_supported = function() return false end } +end + +---------------------------------------------------------------------- +-- Helpers +---------------------------------------------------------------------- + +local function ts_to_seconds(ts) + return ts.tv_sec + ts.tv_nsec * 1e-9 +end + +local function read_clock(clk_id) + local ts, err = ptime.clock_gettime(clk_id) + if not ts then + error("clock_gettime failed: " .. tostring(err)) + end + return ts_to_seconds(ts) +end + +local function clock_resolution(clk_id) + if type(ptime.clock_getres) == "function" then + local ts = select(1, ptime.clock_getres(clk_id)) + if ts then + return ts_to_seconds(ts) + end + end + -- Fallback if clock_getres is missing or fails. + return 1e-3 +end + +---------------------------------------------------------------------- +-- Blocking sleep +---------------------------------------------------------------------- + +local function _block(dt) + if dt <= 0 then + return true, nil + end + + local sec = math.floor(dt) + local frac = dt - sec + local nsec = math.floor(frac * 1e9 + 0.5) + if nsec >= 1000000000 then + sec = sec + 1 + nsec = nsec - 1000000000 + end + + local req = { tv_sec = sec, tv_nsec = nsec } + + while true do + local ok, err, eno, rem = ptime.nanosleep(req) + if ok then + return true, nil + end + if eno == errno.EINTR and rem then + -- Interrupted; continue with remaining time. + req = rem + else + return false, err or ("nanosleep failed (errno " .. tostring(eno) .. ")") + end + end +end + +---------------------------------------------------------------------- +-- Metadata and support +---------------------------------------------------------------------- + +local realtime_res = clock_resolution(CLOCK_REALTIME) +local monotonic_res = clock_resolution(CLOCK_MONOTONIC) + +local function is_supported() + local ok = pcall(function() + read_clock(CLOCK_MONOTONIC) + end) + return ok +end + +local ops = { + realtime = function() + return read_clock(CLOCK_REALTIME) + end, + + monotonic = function() + return read_clock(CLOCK_MONOTONIC) + end, + + realtime_info = { + name = "posix.time.clock_gettime(CLOCK_REALTIME)", + resolution = realtime_res, + monotonic = false, + epoch = "unix", + }, + + monotonic_info = { + name = "posix.time.clock_gettime(CLOCK_MONOTONIC)", + resolution = monotonic_res, + monotonic = true, + epoch = "unspecified", + }, + + _block = _block, + + block_info = { + name = "posix.time.nanosleep", + resolution = monotonic_res, + clock = "monotonic", + }, + + is_supported = is_supported, +} + + +return core.build_backend(ops) diff --git a/src/fibers/utils/time/nixio.lua b/src/fibers/utils/time/nixio.lua new file mode 100644 index 0000000..cefa7c1 --- /dev/null +++ b/src/fibers/utils/time/nixio.lua @@ -0,0 +1,175 @@ +-- fibers/utils/time/nixio.lua +-- +-- Time backend using nixio.gettime, nixio.nanosleep and /proc/uptime. +-- +---@module 'fibers.utils.time.nixio' + +local core = require 'fibers.utils.time.core' + +local ok, nixio = pcall(require, 'nixio') +if not ok or type(nixio) ~= "table" then + return { is_supported = function() return false end } +end + +---------------------------------------------------------------------- +-- Monotonic time via /proc/uptime +---------------------------------------------------------------------- + +local UPTIME_PATH = "/proc/uptime" + +--- Read the first field from /proc/uptime as a number. +---@return number|nil value, string|nil err +local function read_uptime() + local f, err = io.open(UPTIME_PATH, "r") + if not f then + return nil, ("failed to open %s: %s"):format(UPTIME_PATH, tostring(err)) + end + + local line = f:read("*l") + f:close() + + if not line then + return nil, ("failed to read %s: empty file"):format(UPTIME_PATH) + end + + local first = line:match("^%s*(%S+)") + if not first then + return nil, ("failed to parse %s: no fields"):format(UPTIME_PATH) + end + + local val = tonumber(first) + if not val then + return nil, ("failed to parse %s: non-numeric uptime '%s'"):format( + UPTIME_PATH, + tostring(first) + ) + end + + return val, nil +end + +-- Probe once at load time so metadata and is_supported() can report accurately. +local monotonic_ok do + local v = read_uptime() + monotonic_ok = (v ~= nil) +end + +---------------------------------------------------------------------- +-- Core time functions +---------------------------------------------------------------------- + +--- Wall-clock time: seconds since Unix epoch as a Lua number. +local function realtime() + -- nixio.gettime() already returns epoch seconds with fractional part. + return nixio.gettime() +end + +--- Monotonic time in seconds. +--- +--- Primary source: /proc/uptime (seconds since boot, fractional). +--- If this fails at call time for any reason, fall back to +--- nixio.gettime() rather than raising; monotonic_info.name still +--- reflects /proc/uptime as the intended primary source. +local function monotonic() + local v = read_uptime() + if v ~= nil then + return v + end + -- Degraded path: not truly monotonic, but avoids hard failure. + return nixio.gettime() +end + +---------------------------------------------------------------------- +-- Blocking sleep +---------------------------------------------------------------------- + +---@param dt number +---@return boolean ok, string|nil err +local function _block(dt) + if type(dt) ~= "number" then + return false, "sleep: dt must be a number" + end + if dt <= 0 then + return true, nil + end + + local deadline = monotonic() + dt + + while true do + local now = monotonic() + local remaining = deadline - now + if remaining <= 0 then + return true, nil + end + + local secs = math.floor(remaining) + if secs < 0 then secs = 0 end + + local frac = remaining - secs + if frac < 0 then frac = 0 end + local nsec = math.floor(frac * 1e9 + 0.5) + + -- nixio.nanosleep(seconds, nanoseconds) + local ok_ns, err, eno = nixio.nanosleep(secs, nsec) + if not ok_ns then + local msg = tostring(err or eno or "") + if msg ~= "" and msg ~= "EINTR" then + return false, ("nixio.nanosleep failed: %s"):format(msg) + end + -- EINTR or unknown soft error: loop again and recompute remaining. + end + end +end + +---------------------------------------------------------------------- +-- Metadata and support +---------------------------------------------------------------------- + +local function is_supported() + -- Require: nixio present, gettime/nanosleep available, and /proc/uptime + -- readable at initialisation. + if type(nixio.gettime) ~= "function" then + return false + end + if type(nixio.nanosleep) ~= "function" then + return false + end + if not monotonic_ok then + return false + end + return true +end + +local ops = { + realtime = realtime, + + monotonic = monotonic, + + realtime_info = { + name = "nixio.gettime", + resolution = 0.001, -- approximate; depends on platform + monotonic = false, + epoch = "unix", + }, + + monotonic_info = { + name = "/proc/uptime", + -- /proc/uptime is typically centisecond resolution. + resolution = 0.01, + monotonic = true, + epoch = "unspecified", + }, + + _block = _block, + + block_info = { + name = "nixio.nanosleep", + resolution = 0.001, + clock = "monotonic", + }, + + is_supported = is_supported, +} + + +return core.build_backend(ops) diff --git a/src/fibers/utils/time/posix.lua b/src/fibers/utils/time/posix.lua deleted file mode 100644 index 94c68c1..0000000 --- a/src/fibers/utils/time/posix.lua +++ /dev/null @@ -1,104 +0,0 @@ --- fibers.utils.time.posix --- --- luaposix-based time provider using clock_gettime() or gettimeofday(). --- ----@module 'fibers.utils.time.posix' - -local core = require 'fibers.utils.time.core' - -local ok_time, ptime = pcall(require, 'posix.sys.time') -if not ok_time or type(ptime) ~= "table" then - return { is_supported = function() return false end } -end - -local function errno_msg(prefix, err, eno) - if err and err ~= "" then - return err - end - if eno then - return ("%s (errno %d)"):format(prefix, eno) - end - return prefix -end - -local function has_clock_gettime() - return type(ptime.clock_gettime) == "function" -end - -local function build_now_clock() - local clk_id = ptime.CLOCK_MONOTONIC or "monotonic" - - local function now() - local ts, err, eno = ptime.clock_gettime(clk_id) - if not ts then - error(errno_msg("clock_gettime failed", err, eno)) - end - return ts.tv_sec + ts.tv_nsec * 1e-9 - end - - local t = now() - assert(type(t) == "number", "clock_gettime did not yield a number") - - return now -end - -local function build_now_gettimeofday() - assert(type(ptime.gettimeofday) == "function", - "no clock_gettime and no gettimeofday") - - local function now() - local tv, err, eno = ptime.gettimeofday() - if not tv then - error(errno_msg("gettimeofday failed", err, eno)) - end - return tv.tv_sec + tv.tv_usec * 1e-6 - end - - return now, false -end - -local function build() - local now, monotonic - - if has_clock_gettime() then - now = build_now_clock() - monotonic = true - else - now, monotonic = build_now_gettimeofday() - end - - local src = core.build_source{ - name = "luaposix time", - impl = has_clock_gettime() and "posix.clock_gettime" or "posix.gettimeofday", - monotonic = monotonic, - now = now, - -- sleep: left nil; higher layers use pollers or the linux backend for blocking sleep. - } - - src.resolution = src.resolution or core.estimate_resolution(src.now) - - return src -end - -local function is_supported() - local ok, res = pcall(build) - if not ok then - return false - end - return res and true or false -end - -if not is_supported() then - return { is_supported = function() return false end } -end - -local src = build() - -return { - is_supported = is_supported, - now = src.now, - resolution = src.resolution, - source = src.name, - impl = src.impl, - monotonic = src.monotonic, -} diff --git a/tests/test_cond.lua b/tests/test_cond.lua index 0ff88fa..61a2997 100644 --- a/tests/test_cond.lua +++ b/tests/test_cond.lua @@ -7,7 +7,7 @@ package.path = "../src/?.lua;" .. package.path local cond = require 'fibers.cond' local runtime = require 'fibers.runtime' local sleep = require 'fibers.sleep' -local sc = require 'fibers.utils.syscall' +local time = require 'fibers.utils.time' local equal = require 'fibers.utils.helper'.equal @@ -34,9 +34,9 @@ runtime.spawn_raw(function() sleep.sleep(1) - local start_time = sc.monotime() + local start_time = time.monotonic() c:signal() - local end_time = sc.monotime() + local end_time = time.monotonic() print("Time taken to signal fiber: ", (end_time - start_time) / fiber_count) runtime.stop() diff --git a/tests/test_exec.lua b/tests/test_exec.lua deleted file mode 100644 index 6cbc10f..0000000 --- a/tests/test_exec.lua +++ /dev/null @@ -1,258 +0,0 @@ --- test_fibers_exec.lua - --- look one level up -package.path = "../src/?.lua;" .. package.path - -local fibers = require 'fibers' -local sleep = require 'fibers.sleep' -local channel = require "fibers.channel" -local exec = require 'fibers.exec' -local pollio = require 'fibers.pollio' -local waitgroup = require "fibers.waitgroup" -local context = require "fibers.context" -local sc = require 'fibers.utils.syscall' - -pollio.install_poll_io_handler() - -local function count_dir_items(path) - local count - local p = io.popen('ls -1 "' .. path .. '" | wc -l') - if p then - local output = p:read("*all") - count = tonumber(output) - p:close() - end - return count -end - -local function count_zombies() - local count - local p = io.popen('ps | grep -c " Z "') - if p then - local output = p:read("*all") - count = tonumber(output) - p:close() - end - return count -end - --- Test 1: Test basic command execution -local function test_basic_execution() - local output, err = exec.command('echo', 'Hello, World!'):combined_output() - if err then error(err) end - -- remember that echo will append a new line character! - assert(output == "Hello, World!\n", "Expected 'Hello, World!' but got: " .. output) - assert(err == nil, "Expected no error but got: ", err) -end - --- Test 2: Test command error handling -local function test_command_error() - local output, err = exec.command('nonexistent_command'):combined_output() - assert(output == nil, "Expected no output but got: ", output) - assert(err ~= nil, "Expected an error!") -end - --- Test 3: Test command with arguments -local function test_command_with_args() - local output, err = exec.command('echo', 'arg1', 'arg2'):combined_output() - if err then error(err) end - assert(output == "arg1 arg2\n", "Expected 'arg1 arg2' but got: " .. output) - assert(err == nil, "Expected no error but got: ", err) -end - --- Test 4: Test command IO redirection -local function test_io_redirection() - local msgs = {"Hello", "World"} - local cmd = exec.command('cat') - local stdin_pipe = assert(cmd:stdin_pipe()) - local stdout_pipe = assert(cmd:stdout_pipe()) - local signal_chan = channel.new() - local err = cmd:start() - assert(cmd:start(), "Expected error on starting command twice") - assert(err == nil, "Expected no error but got:", err) - fibers.spawn(function () - for _, v in ipairs(msgs) do - stdin_pipe:write(v) - signal_chan:get() - end - stdin_pipe:close() - end) - for _, v in ipairs(msgs) do - assert(stdout_pipe:read_some_chars() == v) - signal_chan:put(1) - end - assert(stdout_pipe:read_some_chars() == nil) - stdout_pipe:close() - err = cmd:wait() - assert(err == nil, "Expected no error but got:", err) - assert(cmd.process.state == 0) -end - --- Test 5: Test command kill -local function test_kill() - local cmd = exec.command('/bin/sh', '-c', 'sleep 5') - cmd:setpgid(true) -- ensures that children run in a separate process group - local starttime = sc.monotime() - local err = cmd:start() - assert(err == nil, "Expected no error but got:", err) - cmd:kill() - local exit_code = cmd:wait() - assert(exit_code == sc.SIGKILL) - assert(sc.monotime()-starttime < 4, sc.monotime()-starttime) -end - --- Test 6: Testing context -local function test_context() - local ctx, _ = context.with_timeout(context.background(), 0.00001) - local cmd = exec.command_context(ctx, 'sleep', '5') - local starttime = sc.monotime() - local err = cmd:start() - assert(err == nil, "Expected no error but got:", err) - assert(cmd:wait() == sc.SIGKILL) - assert(sc.monotime()-starttime < 4, sc.monotime()-starttime) -end - --- Test 7: Cancel context during output -local function test_cancel_during_output() - local ctx, cancel = context.with_cancel(context.background()) - local cmd = exec.command_context(ctx, '/bin/sh', '-c', 'for i in $(seq 1 10000); do echo y; sleep 0.001; done') - :setpgid(true) - - fibers.spawn(function() - -- Let it run for a short moment, then cancel - sleep.sleep(0.00001) - cancel() - end) - - local err = cmd:run() - assert(err == sc.SIGKILL, "Expected error due to cancellation") -end - --- Test 8: Context already cancelled before start -local function test_cancel_before_start() - local ctx, cancel = context.with_cancel(context.background()) - cancel() - - local cmd = exec.command_context(ctx, '/bin/true') - local err = cmd:start() - assert(err ~= nil, "Expected start() to fail due to cancelled context") -end - -local function test_cleanup_on_crash(id) - local function is_process_running(pid) - local f = assert(io.popen('ps -p ' .. pid .. ' | wc -l')) - local output = f:read("*all") - f:close() - return tonumber(output) > 1 -- More than 1 means the process is running - end - local temp_script_dir = "/tmp/crash_test" .. id .. ".lua" - - local script = [[ - package.path = "../src/?.lua;" .. package.path - local fibers = require 'fibers' - local exec = require 'fibers.exec' - local sleep = require 'fibers.sleep' - local pollio = require 'fibers.pollio' - local sc = require 'fibers.utils.syscall' - pollio.install_poll_io_handler() - - local function main() - local cmd = exec.command('sleep', '1') - cmd:setprdeathsig(sc.SIGKILL) - local err = cmd:start() - if err then - error(err) - end - io.stdout:write(tostring(cmd.process.pid) .. "\n") - io.stdout:flush() - sleep.sleep(0.01) - print(obj.obj) - end - - fibers.run(main) - ]] - - -- Write the script to a temporary file - local file = assert(io.open(temp_script_dir, "w")) - file:write(script) - file:close() - - -- Execute the script using luajit - local cmd = exec.command('luajit', temp_script_dir) - local stdout = assert(cmd:stdout_pipe()) - assert(cmd:start() == nil, "Expected no error on start") - - -- Wait for line output from script - local lines - for _ = 1, 10 do - lines = stdout:read_line() - if lines then - break - end - sleep.sleep(0.01) -- Wait a bit for output - end - local exit_code = cmd:wait() - stdout:close() - assert(exit_code ~= 0, "Expected non-zero exit code due to crash") - - os.remove(temp_script_dir) - - -- Get the process ID of the sleep command - local pid = lines and tonumber(lines:match("^(%d+)")) - assert(pid, "Expected a valid process ID from output: " .. tostring(lines or 'nil')) - local is_running = is_process_running(pid) - - -- If we have a valid pid, make sure to cleanup the sleep command explicitly - if pid and pid > 0 and is_running then - -- Send SIGKILL to the process - local kill_cmd = assert(io.popen('kill -9 ' .. tostring(pid))) - kill_cmd:close() - - -- Give a small amount of time for the kill to take effect - sleep.sleep(0.1) - - local running = is_process_running(pid) - - assert(not running, - "Process " .. pid .. " still exists after kill") - end - - assert(not is_running, "Child sleep command is still running") -end - --- Main test function -local function main() - local pid = sc.getpid() - local base_open_fds = count_dir_items("/proc/"..pid.."/fd") - local base_zombies = count_zombies() - local reps = 100 - print("testing: fibers.exec") - local tests = { - test_basic_execution = test_basic_execution, - test_command_error = test_command_error, - test_command_with_args = test_command_with_args, - test_io_redirection = test_io_redirection, - test_kill = test_kill, - test_context = test_context, - test_cancel_during_output = test_cancel_during_output, - test_cancel_before_start = test_cancel_before_start, - test_cleanup_on_crash = test_cleanup_on_crash, - } - for k, v in pairs(tests) do - local wg = waitgroup.new() - for i = 1, reps do - wg:add(1) - fibers.spawn(function () - v(i) - wg:done() - end) - end - wg:wait() - assert(base_open_fds == count_dir_items("/proc/"..pid.."/fd"), k.." left open fds!") - assert(base_zombies == count_zombies(), k.." created zombies!") - print(k..": passed!") - end - print("test: ok") -end - -fibers.run(main) diff --git a/tests/test_io-exec.lua b/tests/test_io-exec.lua index 93d54c5..3e91af4 100644 --- a/tests/test_io-exec.lua +++ b/tests/test_io-exec.lua @@ -1,8 +1,8 @@ --- tests/test_exec.lua +-- tests/test_io-exec.lua -- -- Ad hoc tests and usage examples for fibers.io.exec. -- --- Run as: luajit test_exec.lua +-- Run as: luajit test_io-exec.lua print("testing: fibers.io.exec") @@ -13,6 +13,85 @@ local fibers = require 'fibers' local exec = require 'fibers.io.exec' local op = require 'fibers.op' local sleep = require 'fibers.sleep' +local poller = require 'fibers.io.poller' + +---------------------------------------------------------------------- +-- Helpers for shell-based FD / zombie counting +---------------------------------------------------------------------- + +local function warm_up_exec_backend() + -- Run a trivial command once to force backend initialisation + fibers.run(function() + local proc = exec.command{ + "sh", "-c", "true", + stdin = "null", + stdout = "null", + stderr = "null", + } + local status, code, _, err = fibers.perform(proc:run_op()) + assert(err == nil, "warm-up wait error: " .. tostring(err)) + assert(status == "exited", "warm-up status: " .. tostring(status)) + assert(code == 0, "warm-up exit code: " .. tostring(code)) + end) +end + +local function shell_capture(cmd) + local p, perr = io.popen(cmd, "r") + assert(p, "io.popen failed: " .. tostring(perr)) + local out = p:read("*a") or "" + p:close() + return out +end + +-- Force poller initialisation so its FDs are part of the baseline. +poller.get() + +-- Force exec backend initialisation (self-pipe, reaper, etc.). +warm_up_exec_backend() + +-- Count open FDs of the parent (luajit) process using /proc and $PPID. +local function get_fd_count_for_parent() + local script = [=[ +ls "/proc/$PPID/fd" 2>/dev/null | wc -l +]=] + local ok, out = pcall(shell_capture, script) + if not ok then + return nil + end + local n = out:match("(%d+)") + return n and tonumber(n) or nil +end + +-- Count zombie children (state 'Z') of the parent (luajit) process. +-- Uses /proc/*/stat and awk; best-effort only. +local function get_zombie_count_for_parent() + local script = [=[ +parent="$PPID" +count=0 +for stat in /proc/[0-9]*/stat; do + [ -r "$stat" ] || continue + set -- $(awk '{print $4, $3}' "$stat" 2>/dev/null) + ppid="$1" + state="$2" + if [ "$ppid" = "$parent" ] && [ "$state" = "Z" ]; then + count=$((count+1)) + fi +done +printf '%s\n' "$count" +]=] + local ok, out = pcall(shell_capture, script) + if not ok then + return nil + end + local n = out:match("(%d+)") + return n and tonumber(n) or nil +end + +local baseline_fd_count = get_fd_count_for_parent() +local baseline_zombie_count = get_zombie_count_for_parent() + +print(("baseline: fds=%s zombies=%s") + :format(tostring(baseline_fd_count), tostring(baseline_zombie_count))) ---------------------------------------------------------------------- -- Tests @@ -49,9 +128,9 @@ local function stdin_stdout_pipe_round_trip() } assert(proc, "command creation failed") - local stdin_stream, sin_err = proc:stdin_stream() + local stdin_stream, sin_err = proc:stdin_stream() local stdout_stream, sout_err = proc:stdout_stream() - assert(stdin_stream and not sin_err, "expected stdin stream, got error: " .. tostring(sin_err)) + assert(stdin_stream and not sin_err, "expected stdin stream, got error: " .. tostring(sin_err)) assert(stdout_stream and not sout_err, "expected stdout stream, got error: " .. tostring(sout_err)) local msg = "line1\nline2\n" @@ -91,9 +170,9 @@ local function stderr_pipe_vs_stderr_is_stdout() local out1, oerr1 = out_stream1:read_all() local errout1, eerr1 = err_stream1:read_all() - assert(oerr1 == nil, "stdout read error: " .. tostring(oerr1)) - assert(eerr1 == nil, "stderr read error: " .. tostring(eerr1)) - assert(out1 == "out\n", ("unexpected stdout: %q"):format(out1)) + assert(oerr1 == nil, "stdout read error: " .. tostring(oerr1)) + assert(eerr1 == nil, "stderr read error: " .. tostring(eerr1)) + assert(out1 == "out\n", ("unexpected stdout: %q"):format(out1)) assert(errout1 == "err\n", ("unexpected stderr: %q"):format(errout1)) fibers.perform(proc1:run_op()) @@ -136,10 +215,10 @@ local function output_op_normal_completion() assert(proc, "command creation failed") local out, status, code, sig, err = fibers.perform(proc:output_op()) - assert(err == nil, "output_op error: " .. tostring(err)) + assert(err == nil, "output_op error: " .. tostring(err)) assert(status == "exited", "expected status 'exited'") - assert(code == 0, "expected exit code 0") - assert(sig == nil, "expected no signal") + assert(code == 0, "expected exit code 0") + assert(sig == nil, "expected no signal") -- /bin/sh echo will append a newline. assert(out == "bracket\n", ("unexpected output from output_op: %q"):format(out)) @@ -150,7 +229,7 @@ local function wait_op_with_timeout_pattern() print("running: wait_op_with_timeout_pattern") local proc = exec.command{ - "/bin/sh", "-c", "sleep 0.5", + "/bin/sh", "-c", "sleep 1", stdin = "null", stdout = "null", stderr = "null", @@ -234,12 +313,103 @@ local function spawn_op_basic_usage() assert(out == "via_op", ("unexpected stdout from spawn_ev: %q"):format(out)) local status, code, sig, werr = fibers.perform(proc:run_op()) - assert(werr == nil, "wait error: " .. tostring(werr)) + assert(werr == nil, "wait error: " .. tostring(werr)) + assert(status == "exited", "expected status 'exited'") + assert(sig == nil, "expected no signal") + assert(code == 0, "expected exit 0 from spawned process") +end + +---------------------------------------------------------------------- +-- 8. Torture: many short-lived processes in sequence. +---------------------------------------------------------------------- + +local function many_short_lived_processes_stress() + print("running: many_short_lived_processes_stress") + + local N = 50 + + for i = 1, N do + local proc = exec.command{ + "sh", "-c", ("printf 'run-%d'; exit %d"):format(i, i % 256), + stdin = "null", + stdout = "pipe", + stderr = (i % 2 == 0) and "null" or "pipe", + } + assert(proc, ("command creation failed at iteration %d"):format(i)) + + local stdout_stream, serr = proc:stdout_stream() + assert(stdout_stream and not serr, + ("stdout stream error at iteration %d: %s"):format(i, tostring(serr))) + + local out, rerr = stdout_stream:read_all() + assert(rerr == nil, + ("read_all error at iteration %d: %s"):format(i, tostring(rerr))) + assert(out == ("run-%d"):format(i), + ("unexpected stdout at iteration %d: %q"):format(i, out)) + + local status, code, sig, werr = fibers.perform(proc:run_op()) + assert(werr == nil, + ("wait error at iteration %d: %s"):format(i, tostring(werr))) + assert(status == "exited", + ("status not 'exited' at iteration %d: %s"):format(i, tostring(status))) + assert(sig == nil, + ("signal not nil at iteration %d: %s"):format(i, tostring(sig))) + assert(code == i % 256, + ("exit code mismatch at iteration %d: got %d, expected %d") + :format(i, code, i % 256)) + end +end + +---------------------------------------------------------------------- +-- 9. Torture: large stdout via output_op. +---------------------------------------------------------------------- + +local function large_output_output_op_stress() + print("running: large_output_output_op_stress") + + local lines = 5000 + local script = ([[ +i=1 +while [ $i -le %d ]; do + echo "line-$i" + i=$((i+1)) +done +]]):format(lines) + + local proc = exec.command{ + "sh", "-c", script, + stdin = "null", + stdout = "pipe", + stderr = "pipe", + } + assert(proc, "command creation failed") + + local out, status, code, sig, err = fibers.perform(proc:output_op()) + assert(err == nil, "output_op error: " .. tostring(err)) assert(status == "exited", "expected status 'exited'") - assert(sig == nil, "expected no signal") - assert(code == 0, "expected exit 0 from spawned process") + assert(code == 0, "expected exit code 0") + assert(sig == nil, "expected no signal") + + local count = 0 + for line in out:gmatch("([^\n]*)\n") do + if line ~= "" then + count = count + 1 + end + end + assert(count == lines, + ("unexpected number of lines from large_output_output_op_stress: got %d, expected %d") + :format(count, lines)) + + assert(out:find("line-1", 1, true), + "large output missing 'line-1'") + assert(out:find("line-" .. tostring(lines), 1, true), + "large output missing last line marker") end +---------------------------------------------------------------------- +-- Main +---------------------------------------------------------------------- + local function main() simple_exit_code() stdin_stdout_pipe_round_trip() @@ -248,8 +418,32 @@ local function main() wait_op_with_timeout_pattern() shutdown_long_running_process() spawn_op_basic_usage() + many_short_lived_processes_stress() + large_output_output_op_stress() end fibers.run(main) -print("test_exec.lua: all assertions passed") +local final_fd_count = get_fd_count_for_parent() +local final_zombie_count = get_zombie_count_for_parent() + +print(("final: fds=%s zombies=%s") + :format(tostring(final_fd_count), tostring(final_zombie_count))) + +if baseline_fd_count and final_fd_count then + assert(final_fd_count == baseline_fd_count, + ("FD leak detected: baseline=%d final=%d") + :format(baseline_fd_count, final_fd_count)) +else + print("FD leak check skipped (could not read /proc or count FDs)") +end + +if baseline_zombie_count and final_zombie_count then + assert(final_zombie_count <= baseline_zombie_count, + ("zombie leak detected: baseline=%d final=%d") + :format(baseline_zombie_count, final_zombie_count)) +else + print("Zombie leak check skipped (could not read /proc or count zombies)") +end + +print("test_io-exec.lua: all assertions passed") diff --git a/tests/test_io-file.lua b/tests/test_io-file.lua index 4052810..64d5234 100644 --- a/tests/test_io-file.lua +++ b/tests/test_io-file.lua @@ -32,6 +32,8 @@ local stream_mod = require 'fibers.io.stream' local perform = fibers.perform +math.randomseed(os.time()) + ---------------------------------------------------------------------- -- 1. tmpfile round-trip ---------------------------------------------------------------------- diff --git a/tests/test_runtime.lua b/tests/test_runtime.lua index 37504f0..5b4b2e1 100644 --- a/tests/test_runtime.lua +++ b/tests/test_runtime.lua @@ -5,7 +5,7 @@ print('testing: fibers.fiber') package.path = "../src/?.lua;" .. package.path local runtime = require 'fibers.runtime' -local sc = require 'fibers.utils.syscall' +local time = require 'fibers.utils.time' local equal = require 'fibers.utils.helper'.equal local log = {} @@ -26,7 +26,7 @@ runtime.current_scheduler:run() assert(equal(log, {'a', 'b', 'c'})) -- Test performance -local start_time = sc.monotime() +local start_time = time.monotonic() local fiber_count = 1e4 local count = 0 local function inc() @@ -38,14 +38,14 @@ for _=1, fiber_count do end) end -local end_time = sc.monotime() +local end_time = time.monotonic() print("Fiber creation time: "..(end_time - start_time)/fiber_count) -start_time = sc.monotime() +start_time = time.monotonic() for _=1,3*fiber_count do -- run fibers, each fiber yields 3 times runtime.current_scheduler:run() end -end_time = sc.monotime() +end_time = time.monotonic() print("Fiber operation time: "..(end_time - start_time)/(2*3*fiber_count)) assert(count == 3*fiber_count) diff --git a/tests/test_sched.lua b/tests/test_sched.lua index 57e49c7..980de47 100644 --- a/tests/test_sched.lua +++ b/tests/test_sched.lua @@ -5,12 +5,12 @@ print("test: fibers.sched") package.path = "../src/?.lua;" .. package.path local sched = require 'fibers.sched' -local sc = require 'fibers.utils.syscall' +local time = require 'fibers.utils.time' -- Measure initialization time -local start_time = sc.monotime() +local start_time = time.monotonic() local scheduler = sched.new() -local end_time = sc.monotime() +local end_time = time.monotonic() print("Scheduler initialization time: " .. (end_time - start_time)) local count = 0 @@ -28,13 +28,13 @@ local event_count = 1e4 local t = scheduler:now() -- Measure task scheduling time -start_time = sc.monotime() +start_time = time.monotonic() for _=1,event_count do local dt = math.random()/1e2 t = t + dt scheduler:schedule_at_time(t, {run=task_run, scheduled=t}) end -end_time = sc.monotime() +end_time = time.monotonic() print("Task scheduling time: " .. (end_time - start_time)/event_count) while count < event_count do diff --git a/tests/test_timer.lua b/tests/test_timer.lua index e013a60..68e2802 100644 --- a/tests/test_timer.lua +++ b/tests/test_timer.lua @@ -5,34 +5,34 @@ print("test: fibers.timer") package.path = "../src/?.lua;" .. package.path local timer = require 'fibers.timer' -local sc = require 'fibers.utils.syscall' +local time = require 'fibers.utils.time' local noop_sched = { schedule = function() end } local function test_advance_time() local wheel = timer.new(10) local hour = 60 * 60 - local start_time = sc.monotime() + local start_time = time.monotonic() wheel:advance(hour, noop_sched) - local end_time = sc.monotime() + local end_time = time.monotonic() print("Time to advance wheel by an hour: "..(end_time - start_time).." seconds") end local function test_event_scheduling(event_count) - local wheel = timer.new(sc.monotime()) + local wheel = timer.new(time.monotonic()) local t = wheel.now - local start_time = sc.monotime() + local start_time = time.monotonic() for _=1, event_count do local dt = math.random() t = t + dt wheel:add_absolute(t, t) end - local end_time = sc.monotime() + local end_time = time.monotonic() print("Time to add "..event_count.." events: "..(end_time - start_time).." seconds") end local function test_event_expiration(event_count) - local wheel = timer.new(sc.monotime()) + local wheel = timer.new(time.monotonic()) local last = 0 local count = 0 local check = {} @@ -52,15 +52,15 @@ local function test_event_expiration(event_count) wheel:add_absolute(t, t) end - local start_time = sc.monotime() + local start_time = time.monotonic() wheel:advance(t + 1, check) - local end_time = sc.monotime() + local end_time = time.monotonic() print("Time to advance wheel to expire "..event_count.." events: "..(end_time - start_time).." seconds") assert(count == event_count) end local function test_large_intervals() - local wheel = timer.new(sc.monotime()) + local wheel = timer.new(time.monotonic()) local far_future = 1e6 -- Far future time local event_triggered = false wheel:add_absolute(wheel.now + far_future, 'far_future_event') @@ -69,7 +69,7 @@ local function test_large_intervals() end local function test_small_intervals() - local wheel = timer.new(sc.monotime()) + local wheel = timer.new(time.monotonic()) local very_near_future = 1e-6 -- Very near future time local event_triggered = false wheel:add_absolute(wheel.now + very_near_future, 'near_future_event') @@ -78,7 +78,7 @@ local function test_small_intervals() end local function test_advance_now_update() - local wheel = timer.new(sc.monotime()) + local wheel = timer.new(time.monotonic()) local advance_time = 100 -- Advance by 100 seconds local start_time = wheel.now wheel:advance(start_time + advance_time, noop_sched) diff --git a/tests/test_utils-fixed_buffer.lua b/tests/test_utils-fixed_buffer.lua deleted file mode 100644 index 033e2f8..0000000 --- a/tests/test_utils-fixed_buffer.lua +++ /dev/null @@ -1,94 +0,0 @@ ---- Tests the fixed_buffer implementation. -print('testing: fibers.utils.fixed_buffer') - -package.path = "../src/?.lua;" .. package.path - -local buffer = require 'fibers.utils.fixed_buffer' -local sc = require 'fibers.utils.syscall' -local ffi = sc.is_LuaJIT and require 'ffi' or require 'cffi' - -local equal = require 'fibers.utils.helper'.equal - -local function assert_throws(f, ...) - local success, ret = pcall(f, ...) - assert(not success, "expected failure but got " .. tostring(ret)) -end - -local function assert_avail(b, readable, writable) - assert(b:read_avail() == readable) - assert(b:write_avail() == writable) -end - -local function write_str(b, str) - local scratch = ffi.new('uint8_t[?]', #str) - ffi.copy(scratch, str, #str) - b:write(scratch, #str) -end - -local function read_str(b, count) - local scratch = ffi.new('uint8_t[?]', count) - b:read(scratch, count) - return ffi.string(scratch, count) -end - -local function test_basic() - assert_throws(buffer.new, 0) - - local b = buffer.new(16) - assert_avail(b, 0, 16) - - for _ = 1, 10 do - local s = "hello" - write_str(b, s) - assert(read_str(b, #s) == s) - assert_avail(b, 0, 16) - end - - -- Peek and skip - write_str(b, "abc") - local ptr, len = b:peek() - assert(len >= 3) - assert(ffi.string(ptr, 3) == "abc") - b:advance_read(3) - assert(b:is_empty()) -end - -local function test_find() - local b = buffer.new(64) - write_str(b, "abcdef\n12345") - assert(b:find("\n123") == 6) - b:advance_read(7) - assert(read_str(b, 5) == "12345") -end - -local function test_large_buffer() - local size = 2 ^ 20 -- 1 MB - local data = ffi.new('uint8_t[?]', size) - for i = 0, size - 1 do - data[i] = i % 256 - end - - local b = buffer.new(size) - - local start = sc.monotime() - b:write(data, size) - local write_time = sc.monotime() - start - print(string.format("Wrote %d MB in %d microseconds", size / 2^20, write_time * 1e6)) - - local read_buf = ffi.new('uint8_t[?]', size) - start = sc.monotime() - b:read(read_buf, size) - local read_time = sc.monotime() - start - print(string.format("Read %d MB in %d microseconds", size / 2^20, read_time * 1e6)) - - start = sc.monotime() - assert(equal(data, read_buf), "Data mismatch") - local verify_time = sc.monotime() - start - print(string.format("Verified in %d microseconds", verify_time * 1e6)) -end - -test_basic() -test_find() -test_large_buffer() - -print("test: ok") diff --git a/tests/test_waitgroup.lua b/tests/test_waitgroup.lua index e61c2a8..2d7125d 100644 --- a/tests/test_waitgroup.lua +++ b/tests/test_waitgroup.lua @@ -8,7 +8,7 @@ package.path = "../src/?.lua;" .. package.path local fibers = require 'fibers' local sleep = require 'fibers.sleep' local waitgroup = require 'fibers.waitgroup' -local sc = require 'fibers.utils.syscall' +local time = require 'fibers.utils.time' local perform, choice = fibers.perform, fibers.choice @@ -87,7 +87,7 @@ local function test_complex() end) end - local start = sc.monotime() + local start = time.monotonic() -- Spawn fibers and add to the waitgroup for _ = 1, numFibers do one_sec_work(wg) end @@ -111,7 +111,7 @@ local function test_complex() ) end - assert(sc.monotime() - start > 0.05) + assert(time.monotonic() - start > 0.05) print("Complex test: ok") end From 8c4cce3dccfbf14679b1ba492327efdb27242858 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Mon, 8 Dec 2025 05:29:14 +0000 Subject: [PATCH 073/138] changes fibers.spawn to not pass scope first to thunk --- src/fibers.lua | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/fibers.lua b/src/fibers.lua index c3dd7b2..8442d7f 100644 --- a/src/fibers.lua +++ b/src/fibers.lua @@ -95,12 +95,19 @@ end --- Spawn a fiber under the current scope. --- ---- fn is called as fn(scope, ...). ----@param fn fun(s: Scope, ...): any +--- fn is called as fn(...). +---@param fn fun(...): any ---@param ... any local function spawn(fn, ...) - local s = Scope.current() - return s:spawn(fn, ...) + local s = Scope.current() + local args = { ... } + + -- Wrapper that discards the scope parameter injected by Scope:spawn. + local function shim(_, ...) + return fn(...) + end + + return s:spawn(shim, unpack(args)) end return { From 58b070c28c179880ae2916edbd5e40c6ded7431a Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Mon, 8 Dec 2025 05:31:11 +0000 Subject: [PATCH 074/138] adds some initial examples --- examples/01-concurrent-tasks-and-sleeping.lua | 38 +++++ ...ng-a-channel-receive-against-a-timeout.lua | 46 ++++++ examples/03-child-scopes-and-fail-fast.lua | 47 ++++++ ...tured-concurrency-and-fail-fast-scopes.lua | 52 +++++++ ...05-subprocess-with-structured-shutdown.lua | 41 +++++ examples/06-cancel-subprocess.lua | 132 ++++++++++++++++ examples/1-basic-usage/1-fibers.lua | 23 --- examples/1-basic-usage/10-luacat.lua | 41 ----- examples/1-basic-usage/2-channels.lua | 31 ---- examples/1-basic-usage/3-queues.lua | 21 --- examples/1-basic-usage/4-choices.lua | 45 ------ examples/1-basic-usage/5-choices-alt.lua | 58 ------- examples/1-basic-usage/6-choices-adv.lua | 95 ------------ examples/1-basic-usage/7-exec.lua | 43 ------ examples/1-basic-usage/8-ipc-simple.lua | 54 ------- examples/1-basic-usage/9-ipc-server.lua | 79 ---------- examples/1-basic-usage/9a-ipc-client.lua | 28 ---- examples/1-basic-usage/9a-ipc-server.lua | 62 -------- examples/1-basic-usage/alarm-examples.lua | 45 ------ examples/1-basic-usage/context-examples.lua | 52 ------- examples/2-lua-http/cq-http-fiber-test.lua | 144 ------------------ examples/2-lua-http/index.html | 19 --- 22 files changed, 356 insertions(+), 840 deletions(-) create mode 100644 examples/01-concurrent-tasks-and-sleeping.lua create mode 100644 examples/02-racing-a-channel-receive-against-a-timeout.lua create mode 100644 examples/03-child-scopes-and-fail-fast.lua create mode 100644 examples/04-structured-concurrency-and-fail-fast-scopes.lua create mode 100644 examples/05-subprocess-with-structured-shutdown.lua create mode 100644 examples/06-cancel-subprocess.lua delete mode 100644 examples/1-basic-usage/1-fibers.lua delete mode 100644 examples/1-basic-usage/10-luacat.lua delete mode 100644 examples/1-basic-usage/2-channels.lua delete mode 100644 examples/1-basic-usage/3-queues.lua delete mode 100644 examples/1-basic-usage/4-choices.lua delete mode 100644 examples/1-basic-usage/5-choices-alt.lua delete mode 100644 examples/1-basic-usage/6-choices-adv.lua delete mode 100644 examples/1-basic-usage/7-exec.lua delete mode 100644 examples/1-basic-usage/8-ipc-simple.lua delete mode 100644 examples/1-basic-usage/9-ipc-server.lua delete mode 100644 examples/1-basic-usage/9a-ipc-client.lua delete mode 100644 examples/1-basic-usage/9a-ipc-server.lua delete mode 100644 examples/1-basic-usage/alarm-examples.lua delete mode 100644 examples/1-basic-usage/context-examples.lua delete mode 100644 examples/2-lua-http/cq-http-fiber-test.lua delete mode 100644 examples/2-lua-http/index.html diff --git a/examples/01-concurrent-tasks-and-sleeping.lua b/examples/01-concurrent-tasks-and-sleeping.lua new file mode 100644 index 0000000..a655512 --- /dev/null +++ b/examples/01-concurrent-tasks-and-sleeping.lua @@ -0,0 +1,38 @@ +-- Introduce multiple fibres and time-based suspension. +-- +-- fibers.spawn(fn, ...) attaches a new fibre to the current scope and +-- returns immediately. +-- sleep(dt) yields the current fibre for approximately dt seconds; other +-- fibers continue to run. +-- When all fibres in the root scope complete, the scheduler stops and +-- run(main) returns. + +package.path = "../src/?.lua;" .. package.path + +local fibers = require 'fibers' +local run = fibers.run +local spawn = fibers.spawn + +local sleep = require 'fibers.sleep'.sleep + +local function worker(name, delay, count) + for i = 1, count do + print(("[%s] tick %d"):format(name, i)) + sleep(delay) + end + print(("[%s] done"):format(name)) +end + +local function main() + -- Spawn three child fibers under the current scope. + spawn(worker, "fast", 0.2, 5) + spawn(worker, "medium", 0.5, 4) + spawn(worker, "slow", 1.0, 3) + + -- Unlike Go our main fibre will wait for its scope to finish. + -- Once all children complete, scope reaches status "ok" + -- and fibers.run() returns. + print("Main fibre returning; children will keep the scheduler busy") +end + +run(main) diff --git a/examples/02-racing-a-channel-receive-against-a-timeout.lua b/examples/02-racing-a-channel-receive-against-a-timeout.lua new file mode 100644 index 0000000..10a4351 --- /dev/null +++ b/examples/02-racing-a-channel-receive-against-a-timeout.lua @@ -0,0 +1,46 @@ +package.path = "../src/?.lua;" .. package.path + +local fibers = require 'fibers' +local sleep = require 'fibers.sleep' +local chan = require 'fibers.channel' + +local run = fibers.run +local spawn = fibers.spawn +local perform = fibers.perform +local choice = fibers.choice + +run(function() + -- Buffered channel with capacity 1 so a late send will not block. + local c = chan.new(1) + + -- Simulate a slow producer. + spawn(function() + sleep.sleep(3.0) + print("[producer] sending result") + c:put("result from producer") -- will complete even if nobody is reading + print("[producer] done") + end) + + -- Build two Ops: + -- 1. Wait for a value from the channel. + -- 2. Wait for a timeout (2 seconds). + local recv_op = c:get_op():wrap(function(v) + return "value", v + end) + + local timeout_op = sleep.sleep_op(2.0):wrap(function() + return "timeout", nil + end) + + local tag, payload = perform(choice(recv_op, timeout_op)) + + if tag == "value" then + print("[main] got channel value:", payload) + elseif tag == "timeout" then + print("[main] timed out before producer responded") + else + print("[main] unexpected tag:", tag, payload) + end + + print("[main] returning from Example 3") +end) diff --git a/examples/03-child-scopes-and-fail-fast.lua b/examples/03-child-scopes-and-fail-fast.lua new file mode 100644 index 0000000..6e1848a --- /dev/null +++ b/examples/03-child-scopes-and-fail-fast.lua @@ -0,0 +1,47 @@ +package.path = "../src/?.lua;" .. package.path + +local fibers = require 'fibers' +local sleep = require 'fibers.sleep' + +local run = fibers.run +local spawn = fibers.spawn +local run_scope = fibers.run_scope -- Scope.run +local current_scope = fibers.current_scope + +local function main(root_scope) + print("[root] starting; status:", root_scope:status()) + + -- Create a child scope under the root. + local status, err = run_scope(function() + local s = current_scope() + print("[child] scope created; status:", s:status()) + + -- Failing worker. + spawn(function() + print("[child/worker1] will fail after 0.5s") + sleep.sleep(0.5) + error("simulated failure in worker1") + end) + + -- Long-running worker; will be cancelled when sibling fails. + spawn(function() + local s2 = current_scope() + print("[child/worker2] started; waiting for cancellation...") + while true do + -- Check whether the scope has already failed/cancelled. + local st = s2:status() + print("[child/worker2] observed scope status:", st) + sleep.sleep(0.2) + end + end) + + -- This body returns once the child scope reaches a terminal state. + -- No explicit wait is needed; run_scope handles join/defers. + end) + + print("[root] child scope returned; status:", status, "error:", err) + + -- status will be "failed"; err will be the primary error from worker1. +end + +run(main) diff --git a/examples/04-structured-concurrency-and-fail-fast-scopes.lua b/examples/04-structured-concurrency-and-fail-fast-scopes.lua new file mode 100644 index 0000000..55deeec --- /dev/null +++ b/examples/04-structured-concurrency-and-fail-fast-scopes.lua @@ -0,0 +1,52 @@ +package.path = "../src/?.lua;" .. package.path + +local fibers = require 'fibers' +local run = fibers.run +local spawn = fibers.spawn +local run_scope = fibers.run_scope +local now = fibers.now + +local sleep = require 'fibers.sleep'.sleep + +local function sometimes_fails(name, delay, fail_after) + for _ = 1, fail_after - 1 do + print(("[%s] ok at t=%.2f"):format(name, now())) + sleep(delay) + end + error(("[%s] failed after %d iterations"):format(name, fail_after)) +end + +local function sibling(name, delay) + while true do + print(("[%s] still running at t=%.2f"):format(name, now())) + sleep(delay) + end +end + +local function main() + print("Main: starting child scope") + + local status, err = run_scope(function(child_scope) + -- All fibres spawned here are supervised by child_scope. + spawn(sometimes_fails, "flaky", 0.3, 4) + spawn(sibling, "sibling", 0.2) + + -- Block until child_scope terminates: + -- - if flaky fails, child_scope becomes "failed" and cancels siblings. + -- - if everything finishes, status is "ok". + local s, e = child_scope:status() + print("Child initial status:", s, e) + + -- wait for completion using the scope join op + local ok, join_err = child_scope:sync(child_scope:join_op()) + print("child_scope:join_op() ->", ok, join_err) + end) + + print("Main: child scope finished with:", status, err) + + if status ~= "ok" then + print("Main: treating non-ok child status as error") + end +end + +run(main) diff --git a/examples/05-subprocess-with-structured-shutdown.lua b/examples/05-subprocess-with-structured-shutdown.lua new file mode 100644 index 0000000..995b8af --- /dev/null +++ b/examples/05-subprocess-with-structured-shutdown.lua @@ -0,0 +1,41 @@ +package.path = "../src/?.lua;" .. package.path + +local fibers = require 'fibers' +local exec = require 'fibers.io.exec' + +local run = fibers.run +local perform = fibers.perform +local run_scope = fibers.run_scope + +local function main() + -- Put the subprocess in its own child scope so that any failure or + -- cancellation is neatly contained. + local status, code_or_sig, err = run_scope(function() + -- Simple shell pipeline: prints two lines with a pause. + local cmd = exec.command( + "sh", "-c", + "echo 'hello from child process'; " .. + "sleep 1; " .. + "echo 'goodbye from child process'" + ) + + -- output_op(): + -- returns an Op that, when performed, yields: + -- output : string (combined stdout) + -- status : "ok" | "failed" | "cancelled" | "exited" | "signalled" + -- code : exit code or signal number (depending on status) + -- signal : signal (if signalled) + -- err : string|nil backend error + local output, proc_status, code, signal, perr = perform(cmd:output_op()) + + print("[subprocess] status:", proc_status, "code:", code, "signal:", signal, "err:", perr) + print("[subprocess] output:") + io.stdout:write(output) + + return proc_status, code or signal, perr + end) + + print("[root] child exec scope finished with:", status, code_or_sig, err) +end + +run(main) diff --git a/examples/06-cancel-subprocess.lua b/examples/06-cancel-subprocess.lua new file mode 100644 index 0000000..a840b6b --- /dev/null +++ b/examples/06-cancel-subprocess.lua @@ -0,0 +1,132 @@ +-- Demonstrates: +-- * Running an external process with fibers.exec +-- * Capturing stdout via a pipe +-- * Using boolean_choice to race process completion vs timeout +-- * Cancelling a scope on timeout and letting structured +-- concurrency clean up the subprocess and helper fibres +-- +-- Style: +-- * fibers.run(main) exposes the root scope as an argument. +-- * fibers.spawn(fn, ...) uses the current scope implicitly. +-- * Timeout is expressed algebraically via boolean_choice, +-- rather than a separate watchdog fibre. + +package.path = "../src/?.lua;" .. package.path + +local fibers = require "fibers" +local exec = require "fibers.io.exec" +local sleep = require "fibers.sleep" + +local run = fibers.run +local spawn = fibers.spawn +local perform = fibers.perform +local boolean_choice = fibers.boolean_choice +local current_scope = fibers.current_scope +local sleep_op = sleep.sleep_op + +---------------------------------------------------------------------- +-- Main entry point +---------------------------------------------------------------------- + +run(function() + print("[root] starting subprocess example") + + -- Run the subprocess and its helper fibres inside a child scope. + -- We use run_scope so that we can interpret status and reason at + -- a clear supervision boundary. + local status, reason = fibers.run_scope(function() + print("[subscope] starting child process") + + ------------------------------------------------------------------ + -- 1. Construct the command + ------------------------------------------------------------------ + + local script = [[for i in 0 1 2 3 4 5 6 7 8 9; do echo "tick $i"; sleep 1; done]] + + local cmd = exec.command{ + "sh", "-c", script, + stdin = "null", -- no input + stdout = "pipe", -- capture output + stderr = "inherit", -- pass through + } + + ------------------------------------------------------------------ + -- 2. Reader fibre: drain stdout until EOF or error + ------------------------------------------------------------------ + + spawn(function() + local out, serr = cmd:stdout_stream() + if not out then + print("[reader] no stdout stream:", serr) + return + end + + while true do + local line, rerr = out:read("*l") + if not line then + if rerr then + print("[reader] read error:", rerr) + else + print("[reader] EOF on stdout") + end + break + end + print("[reader]", line) + end + end) + + ------------------------------------------------------------------ + -- 3. Race process completion against a timeout + ------------------------------------------------------------------ + -- + -- boolean_choice(opA, opB) returns: + -- * true + results from opA if A wins + -- * false + results from opB if B wins + -- + -- We wrap the two arms so that: + -- * The command arm returns: true, status, code, signal, err + -- * The timeout arm returns: false + -- + -- Note: + -- * If the timeout wins, we do not try to handle cancellation + -- inside the subscope. Instead we cancel the subscope from + -- here and let its defers (including Command’s defer) run. + -- + + local proc_won, status2, code, signal, err = perform(boolean_choice( + cmd:run_op():wrap(function(st, c, sig, e) + return true, st, c, sig, e + end), + sleep_op(3.0):wrap(function() + return false + end) + )) + + if proc_won then + -- Process finished before the timeout and the scope has not + -- yet been cancelled or failed. + print(("[subscope] command finished: status=%s code=%s signal=%s err=%s") + :format(tostring(status2), tostring(code), tostring(signal), tostring(err))) + return + end + + ------------------------------------------------------------------ + -- 4. Timeout: cancel the subscope + ------------------------------------------------------------------ + -- + -- This cancels: + -- * the reader fibre, + -- * any other children in this scope, and + -- * the Command’s scope defer will run _on_scope_exit(), which + -- calls shutdown_op and waits for the process to die. + -- + print("[subscope] timeout reached; cancelling subprocess scope") + current_scope():cancel("timeout") + end) + + -------------------------------------------------------------------- + -- 5. Supervision boundary: interpret the outcome + -------------------------------------------------------------------- + + print("[root] subprocess scope completed; status:", status, "reason:", reason) +end) diff --git a/examples/1-basic-usage/1-fibers.lua b/examples/1-basic-usage/1-fibers.lua deleted file mode 100644 index ca1c332..0000000 --- a/examples/1-basic-usage/1-fibers.lua +++ /dev/null @@ -1,23 +0,0 @@ ---- Fibers --- A fiber is a lightweight thread managed by fibers framework, similar to Go's --- goroutines. Fibers run in the same address space, so access to shared --- memory must be synchronised. --- --- Example ported from Go's Select https://go.dev/tour/concurrency/1 - -package.path = "../../src/?.lua;../?.lua;" .. package.path - -local fibers = require 'fibers' -local sleep = require 'fibers.sleep' - -local function say(string) - for _=1,5 do - sleep.sleep(0.1) - print(string) - end -end - -fibers.run(function() - fibers.spawn(function() say("world") end) - say("hello") -end) diff --git a/examples/1-basic-usage/10-luacat.lua b/examples/1-basic-usage/10-luacat.lua deleted file mode 100644 index f3f7038..0000000 --- a/examples/1-basic-usage/10-luacat.lua +++ /dev/null @@ -1,41 +0,0 @@ -package.path = "../../src/?.lua;../?.lua;" .. package.path - --- Importing the necessary modules from the fibers framework -local fibers = require 'fibers' -local file = require 'fibers.stream.file' -local socket = require 'fibers.stream.socket' -local sc = require 'fibers.utils.syscall' - -require("fibers.pollio").install_poll_io_handler() - --- Open stdin for reading -local stdin = assert(file.fdopen(sc.STDIN_FILENO, sc.O_RDONLY)) - --- Get the socket path from the first argument -local socketPath = arg[1] -if not socketPath then - error("Socket path not provided") -end - --- Connect to the Unix domain socket -local sock = socket.connect_unix(socketPath) - --- Fiber to read from stdin and write to the socket -local function main() - while true do - local line = stdin:read('*l') - if line then - sock:write(line .. "\n") - sock:flush_output() - else - -- End of input - break - end - end - -- Close the socket once done - stdin:close() - sock:close() -end - --- Start the main fiber loop -fibers.run(main) diff --git a/examples/1-basic-usage/2-channels.lua b/examples/1-basic-usage/2-channels.lua deleted file mode 100644 index 58ee760..0000000 --- a/examples/1-basic-usage/2-channels.lua +++ /dev/null @@ -1,31 +0,0 @@ ---- Channels --- Channels are a conduit through which you can send and receive values. --- By default, sends and receives block until the other side is ready. --- This allows goroutines to synchronize without explicit locks or condition --- variables. --- --- Example ported from Go's Select https://go.dev/tour/concurrency/2 - -package.path = "../../src/?.lua;../?.lua;" .. package.path - -local fibers = require 'fibers' -local channel = require 'fibers.channel' - -local function sum(array, chan) - local total = 0 - for _, j in ipairs(array) do - total = total + j - end - chan:put(total) -end - -local function main() - local s = {7, 2, 8, -9, 4, 0} - local chan = channel.new() - fibers.spawn(function() sum({unpack(s,1,#s/2)}, chan) end) - fibers.spawn(function() sum({unpack(s,#s/2+1,#s)}, chan) end) - local x, y = chan:get(), chan:get() - print(x, y, x+y) -end - -fibers.run(main) diff --git a/examples/1-basic-usage/3-queues.lua b/examples/1-basic-usage/3-queues.lua deleted file mode 100644 index df2a4c5..0000000 --- a/examples/1-basic-usage/3-queues.lua +++ /dev/null @@ -1,21 +0,0 @@ ---- Queue --- Queues can be thought of as buffered channels. Provide the buffer length --- as the argument to new. Puts to a buffered channel block only when the --- buffer is full. Gets block when the buffer is empty. --- --- Example ported from Go's Select https://go.dev/tour/concurrency/3 - -package.path = "../../src/?.lua;../?.lua;" .. package.path - -local fibers = require 'fibers' -local queue = require 'fibers.queue' - -local function main() - local q = queue.new(2) - q:put(1) - q:put(2) - print(q:get()) - print(q:get()) -end - -fibers.run(main) diff --git a/examples/1-basic-usage/4-choices.lua b/examples/1-basic-usage/4-choices.lua deleted file mode 100644 index b6989c1..0000000 --- a/examples/1-basic-usage/4-choices.lua +++ /dev/null @@ -1,45 +0,0 @@ ---- Choice --- The choice function lets a fiber wait on multiple operations. --- Choice blocks until one of its suboperations can run, then it executes --- that case. It chooses one at random if multiple are ready. --- --- Example ported from Go's Select https://go.dev/tour/concurrency/5 - - -package.path = "../../src/?.lua;../?.lua;" .. package.path - -local fibers = require 'fibers' -local channel = require 'fibers.channel' - -local perform, choice = fibers.perform, fibers.choice - -local function fibonacci(c, quit) - local x, y = 0, 1 - local done = false - repeat - local task = choice( - c:put_op(x):wrap(function() - x, y = y, x+y - end), - quit:get_op():wrap(function() - print("quit") - done = true - end) - ) - perform(task) - until done -end - -local function main() - local c = channel.new() - local quit = channel.new() - fibers.spawn(function() - for _=1, 10 do - print(c:get()) - end - quit:put(0) - end) - fibonacci(c, quit) -end - -fibers.run(main) diff --git a/examples/1-basic-usage/5-choices-alt.lua b/examples/1-basic-usage/5-choices-alt.lua deleted file mode 100644 index 95c9eb3..0000000 --- a/examples/1-basic-usage/5-choices-alt.lua +++ /dev/null @@ -1,58 +0,0 @@ ---- Choice alternative --- The perform_alt method for a choice operation runs a passed function if --- none of the operations can be run. This makes the choice non-blocking. --- --- Example ported from Go's Select https://go.dev/tour/concurrency/6 - -package.path = "../../src/?.lua;../?.lua;" .. package.path - -local fibers = require 'fibers' -local channel = require 'fibers.channel' -local sleep = require 'fibers.sleep' - -local perform, choice = fibers.perform, fibers.choice - --- time.After() is a Go library function -local function after(t) - local chan = channel.new() - fibers.spawn(function() - sleep.sleep(t) - chan:put(1) - end) - return chan -end - --- time.Tick() is a Go library function -local function tick(t) - local chan = channel.new() - fibers.spawn(function() - while true do - sleep.sleep(t) - chan:put(1) - end - end) - return chan -end - -local function main() - local ticker = tick(0.1) - local boom = after(0.5) - local done = false - repeat - local task = choice( - ticker:get_op():wrap(function() - print("tick.") - end), - boom:get_op():wrap(function() - print("BOOM!") - done = true - end) - ):or_else(function() - print(" .") - sleep.sleep(0.05) - end) - perform(task) - until done -end - -fibers.run(main) diff --git a/examples/1-basic-usage/6-choices-adv.lua b/examples/1-basic-usage/6-choices-adv.lua deleted file mode 100644 index c665c9a..0000000 --- a/examples/1-basic-usage/6-choices-adv.lua +++ /dev/null @@ -1,95 +0,0 @@ -package.path = "../../src/?.lua;../?.lua;" .. package.path - --- Importing the necessary modules -local fibers = require 'fibers' -local sleep = require 'fibers.sleep' -local channel = require 'fibers.channel' -local queues = require 'fibers.queue' -local socket = require 'fibers.stream.socket' -local file = require 'fibers.stream.file' -local cond = require 'fibers.cond' -local sc = require 'fibers.utils.syscall' - -local perform, choice = fibers.perform, fibers.choice - -require("fibers.pollio").install_poll_io_handler() - --- Set up the queues, channel and condition variable -local data_q = queues.new() -local notif_chan = channel.new() -local exit_cond = cond.new() - --- Data Producer fiber -local function producer() - while true do - -- sleep for some time to simulate work - sleep.sleep(math.random()) - -- Send data to the queue - data_q:put(os.date('%Y-%m-%d %H:%M:%S')) - end -end - --- Notifier fiber -local function notifier() - while true do - -- sleep for some time to simulate work - sleep.sleep(4 * math.random()) - -- Send data to the channel - notif_chan:put(1) - end -end - --- Exit signaller -local function exit() - while true do - -- sleep for some time to simulate work - sleep.sleep(30 * math.random()) - -- Signal the condition - exit_cond:signal() - end -end - --- Consumer fiber -local function consumer() - -- file to write data to - local filename = "/tmp/data.txt" - os.execute("rm "..filename) - local tempfile = assert(file.open(filename, 'w')) - -- socket setup - local sockname = '/tmp/test-socket' - os.execute("rm "..sockname) - socket.socket(sc.AF_UNIX, sc.SOCK_STREAM, 0):listen_unix(sockname) - local sock = socket.connect_unix(sockname) - while true do - -- Use choice to handle multiple potentially blocking actions - local task = choice( - data_q:get_op():wrap(function(value) - print("data received - writing to socket") - sock:write(value .. "\n") - sock:flush_output() - end), - notif_chan:get_op():wrap(function(value) - print("notification received - writing to file") - tempfile:write(value .. "\n") - tempfile:flush_output() - end), - exit_cond:wait_op():wrap(function() - print("EXIT SIGNAL RECEIVED") - os.exit() - end), - sleep.sleep_op(0.5):wrap(function() - print("yawn - nothing happening") - end) - ) - perform(task) - end -end - -local function main() - fibers.spawn(producer) - fibers.spawn(notifier) - fibers.spawn(exit) - consumer() -end - -fibers.run(main) diff --git a/examples/1-basic-usage/7-exec.lua b/examples/1-basic-usage/7-exec.lua deleted file mode 100644 index 1296d30..0000000 --- a/examples/1-basic-usage/7-exec.lua +++ /dev/null @@ -1,43 +0,0 @@ --- usage of the fibers.exec module - -package.path = "../../src/?.lua;../?.lua;" .. package.path - -local fibers = require 'fibers' -local sleep = require 'fibers.sleep' -local exec = require 'fibers.exec' -local pollio = require 'fibers.pollio' - -pollio.install_poll_io_handler() - -local function main() - fibers.spawn(function() -- long running process where we want to periodically deal with output - local cmd = exec.command('cat') - local stdin_pipe = assert(cmd:stdin_pipe()) - local stdout_pipe = assert(cmd:stdout_pipe()) - local err = cmd:start() - if err then error(err) end - fibers.spawn(function() - for _ = 1, 4 do - stdin_pipe:write('tick') - sleep.sleep(0.2) - end - stdin_pipe:write('BOOM!') - stdin_pipe:close() - end) - while true do - local received = stdout_pipe:read_some_chars() - if not received then break end - print(received) - end - err = cmd:wait() -- gets exit code, etc - if err then error(err) end - print("ticker exited with exit code:", cmd.process_state.ssi_status) - end) - -- simple command where we want to simply gather all output - print("starting combined command") - local output, err = exec.command('sh', '-c', 'sleep 1; echo hello world; exit 255'):combined_output() - assert(err, "expected error!") - print("output:", output) -end - -fibers.run(main) diff --git a/examples/1-basic-usage/8-ipc-simple.lua b/examples/1-basic-usage/8-ipc-simple.lua deleted file mode 100644 index 661c3ed..0000000 --- a/examples/1-basic-usage/8-ipc-simple.lua +++ /dev/null @@ -1,54 +0,0 @@ --- demonstrates IPC using exec and non-blocking sockets - -package.path = "../../src/?.lua;../?.lua;" .. package.path - -local fibers = require "fibers" -local exec = require "fibers.exec" -local sleep = require "fibers.sleep" -local socket = require 'fibers.stream.socket' -local sc = require 'fibers.utils.syscall' - -require("fibers.pollio").install_poll_io_handler() - -local sockname = '/tmp/test-sock' - -sc.unlink(sockname) -local server = assert(socket.listen_unix(sockname)) - -local function receiver() - sleep.sleep(2) -- to show that things don't block and are gracefully buffered by sockets - - while true do - local peer = assert(server:accept()) - local rec = peer:read_line() - peer:close() - print("received:", rec) - if rec == "exit!" then - print "shut down command received" - break - end - end - sc.unlink(sockname) -end - -local function sender() - local messages = { "apple", "pear", "exit!" } - for _, v in ipairs(messages) do - print("sending:", v) - -- use the netcat command `nc` to write to a unix domain socket - local command = 'echo "' .. v .. '" | nc -U ' .. sockname - local out, err = exec.command('sh', '-c', command):combined_output() - if err then error(out) end - end -end - -local function main() - fibers.spawn(sender) - fibers.spawn(receiver) - while true do - print("hb") - sleep.sleep(1) - end -end - -fibers.run(main) diff --git a/examples/1-basic-usage/9-ipc-server.lua b/examples/1-basic-usage/9-ipc-server.lua deleted file mode 100644 index 803c4b7..0000000 --- a/examples/1-basic-usage/9-ipc-server.lua +++ /dev/null @@ -1,79 +0,0 @@ ---[[ - This code demos how we can monitor sockets for IPC showing a server that - can handle multiple clients. -]] - -package.path = "../../src/?.lua;../?.lua;" .. package.path - --- Importing necessary modules -local fibers = require "fibers" -local sleep = require "fibers.sleep" -local socket = require 'fibers.stream.socket' -local sc = require 'fibers.utils.syscall' - --- Install a polling I/O handler from the fibers library -require("fibers.pollio").install_poll_io_handler() - --- Define the path for the Unix domain socket -local sockname = '/tmp/ntpd-sock' - --- Remove the socket file if it already exists to avoid 'address already in use' errors -sc.unlink(sockname) - --- Create and start listening on the Unix domain socket -local server = assert(socket.listen_unix(sockname)) - --- Spawn a fiber to handle incoming connections -local function start_server() - while true do - -- Accept a new connection - local peer, err = assert(server:accept()) - - if not peer then - print("Error accepting connection:", err) - break - end - - -- Spawn a new fiber for each connection to handle client communication - fibers.spawn(function() - while true do - -- Read a line from the connected client - local rec = peer:read_line() - - -- If a line is received, process it - if rec then - print("received:", rec) - -- Check for a specific command ('exit!') to shut down the server - if rec == "exit!" then - print("shut down command received") - break - end - else - -- If no data is received (client closed the connection), break the loop - break - end - end - -- Close the connection to the client - peer:close() - end) - end - -- After the server is stopped, remove the socket file and stop the fiber - sc.unlink(sockname) -end - --- Spawn another fiber to periodically print a heartbeat message -local function heartbeat() - while true do - print("hb") - -- Sleep for 1 second before printing the next heartbeat - sleep.sleep(1) - end -end - -local function main() - fibers.spawn(start_server) - fibers.spawn(heartbeat) -end - --- Start the main fiber loop -fibers.run(main) diff --git a/examples/1-basic-usage/9a-ipc-client.lua b/examples/1-basic-usage/9a-ipc-client.lua deleted file mode 100644 index 557dff8..0000000 --- a/examples/1-basic-usage/9a-ipc-client.lua +++ /dev/null @@ -1,28 +0,0 @@ -package.path = "../?.lua;" .. package.path .. ";/usr/lib/lua/?.lua;/usr/lib/lua/?/init.lua" - -package.path = "../../src/?.lua;../?.lua;" .. package.path - --- Importing necessary modules -local fibers = require "fibers" -local socket = require 'fibers.stream.socket' - --- Install a polling I/O handler from the fibers library -require("fibers.pollio").install_poll_io_handler() - --- The first argument is the JSON string -local json_str = arg[1] - --- Define the path for the Unix domain socket -local sockname = '/tmp/ntpd-sock' - -local function main() - local client = socket.connect_unix(sockname) - client:setvbuf('no') - - client:write(json_str) - - client:close() -end - --- Start the main fiber loop -fibers.run(main) diff --git a/examples/1-basic-usage/9a-ipc-server.lua b/examples/1-basic-usage/9a-ipc-server.lua deleted file mode 100644 index 369bb2d..0000000 --- a/examples/1-basic-usage/9a-ipc-server.lua +++ /dev/null @@ -1,62 +0,0 @@ ---[[ - This code demos how we can monitor sockets for IPC showing a server that - can handle multiple clients. -]] -package.path = "../?.lua;" .. package.path .. ";/usr/lib/lua/?.lua;/usr/lib/lua/?/init.lua" - -package.path = "../../src/?.lua;../?.lua;" .. package.path - --- Importing necessary modules -local fibers = require "fibers" -local socket = require 'fibers.stream.socket' -local sc = require 'fibers.utils.syscall' - --- Install a polling I/O handler from the fibers library -require("fibers.pollio").install_poll_io_handler() - --- Define the path for the Unix domain socket -local sockname = '/tmp/ntpd-sock' - --- Remove the socket file if it already exists to avoid 'address already in use' errors -sc.unlink(sockname) - --- Spawn a fiber to handle incoming connections -local function main() - - -- Create and start listening on the Unix domain socket - local server = assert(socket.listen_unix(sockname)) - - while true do - -- Accept a new connection - local peer, err = assert(server:accept()) - - if not peer then - print("Error accepting connection:", err) - break - end - - -- Spawn a new fiber for each connection to handle client communication - fibers.spawn(function() - while true do - -- Read a line from the connected client - local rec = peer:read_line() - - -- If a line is received, process it - if rec then - print("received:", rec) - else - -- If no data is received (client closed the connection), break the loop - print("exiting") - break - end - end - -- Close the connection to the client - peer:close() - end) - end - -- After the server is stopped, remove the socket file and stop the fiber - sc.unlink(sockname) -end - --- Start the main fiber loop -fibers.run(main) diff --git a/examples/1-basic-usage/alarm-examples.lua b/examples/1-basic-usage/alarm-examples.lua deleted file mode 100644 index b191dbb..0000000 --- a/examples/1-basic-usage/alarm-examples.lua +++ /dev/null @@ -1,45 +0,0 @@ -package.path = "../../src/?.lua;../?.lua;" .. package.path - -local fibers = require 'fibers' -local alarm = require 'fibers.alarm' -local sc = require 'fibers.utils.syscall' - --- like pollio, the handler needs to be installed as a task source for the scheduler -alarm.install_alarm_handler() - --- until the user calls realtime_achieved() no alarms will return. absolute --- alarms will return immediately if their time has elapsed, whereas next --- alarms will be scheduled for their next instance -alarm.realtime_achieved() - -local function set_alarm(t, number) - local epoch, t_tab = alarm.calculate_next(t, sc.realtime()) - print("alarm "..number, "set to:", os.date("%A %d %B %Y at %H:%M:", epoch)..os.date("%S", epoch) + t_tab.msec/1e3) - alarm.next(t) - local _, sec, nsec = sc.realtime() - print("alarm "..number, "fired at:", os.date("%A %d %B %Y at %H:%M:", sec)..os.date("%S", sec) + nsec/1e9) -end - -local function main() - local _, sec, nsec = sc.realtime() - print("Time now:", os.date("%A %d %B %Y at %H:%M:", sec)..os.date("%S", sec) + nsec/1e9, os.date("TZ: %z:", sec)) - - local alarm_times = { - {msec=233}, - {sec=12, msec=2}, - {min=41, sec=12}, - {hour=3, min=43}, - {day=17, hour=3, min=41, sec=12}, - {wday=1, hour=3, min=41}, - {yday=17, hour=3}, - {month=6}, - } - - for i, j in ipairs(alarm_times) do - fibers.spawn(function () - set_alarm(j, i) - end) - end -end - -fibers.run(main) diff --git a/examples/1-basic-usage/context-examples.lua b/examples/1-basic-usage/context-examples.lua deleted file mode 100644 index e849e63..0000000 --- a/examples/1-basic-usage/context-examples.lua +++ /dev/null @@ -1,52 +0,0 @@ -package.path = "../../?.lua;../?.lua;" .. package.path - -local fibers = require 'fibers' -local context = require 'fibers.context' -local sleep = require 'fibers.sleep' - -local perform = require 'fibers.performer'.perform - --- Simulated work function -local function do_work(task_name, duration) - print(task_name .. " started") - sleep.sleep(duration) - print(task_name .. " finished") -end - --- Sub-Task 1: Canceled by timeout -local function sub_task_1(ctx) - local deadline_ctx, cancel = context.with_timeout(ctx, 5) -- Timeout after 5 seconds - fibers.spawn(function() - do_work("Sub-Task 1", 1) -- Simulates work for 10 seconds - cancel("work_completed") -- Cancel the context (optional, as it will timeout) - end) - - perform(deadline_ctx:done_op()) -- Wait for the context to be done - print("Sub-Task 1 status: " .. (deadline_ctx:err() or "completed")) -end - --- Sub-Task 2: Waits for cancellation from the main task -local function sub_task_2(ctx) - local cancel_ctx, cancel = context.with_cancel(ctx) - fibers.spawn(function() - do_work("Sub-Task 2", 3) -- Simulates longer work - cancel("work_completed") -- Cancel the context when work is done - end) - - perform(cancel_ctx:done_op()) -- Wait for the context to be done - print("Sub-Task 2 status: " .. (cancel_ctx:err() or "completed")) -end - --- Main Task -local function main() - local root_ctx = context.background() -- Root context - local main_ctx, cancel = context.with_cancel(root_ctx) -- Root context - fibers.spawn(function() sub_task_1(main_ctx) end) - fibers.spawn(function() sub_task_2(main_ctx) end) - - sleep.sleep(2) -- Main task waits for 2 seconds before canceling Sub-Task 2 - cancel("main_canceled") -- This will propagate to Sub-Task 2 - sleep.sleep(1) -- Main task waits for 8 seconds before canceling Sub-Task 2 -end - -fibers.run(main) diff --git a/examples/2-lua-http/cq-http-fiber-test.lua b/examples/2-lua-http/cq-http-fiber-test.lua deleted file mode 100644 index 36f3182..0000000 --- a/examples/2-lua-http/cq-http-fiber-test.lua +++ /dev/null @@ -1,144 +0,0 @@ --- inspired by https://gist.github.com/daurnimator/f1c7965b47a5658b88300403645541aa - -print("starting lua-http test") - -package.path="./?.lua;/usr/share/lua/?.lua;/usr/share/lua/?/init.lua;/usr/lib/lua/?.lua;/usr/lib/lua/?/init.lua" - .. package.path -package.path = "../../src/?.lua;../?.lua;" .. package.path - -local fibers = require "fibers" -local op = require "fibers.op" -local pollio = require "fibers.pollio" -local sleep = require "fibers.sleep" -local cqueues = require "cqueues" -local exec = require "fibers.exec" - -local perform, choice = require 'fibers.performer'.perform, op.choice - -print("installing poll handler") -pollio.install_poll_io_handler() - -print("installing stream based IO library") -require 'fibers.stream.compat'.install() - -print("overriding cqueues step") -local old_step; old_step = cqueues.interpose("step", function(self, timeout) - if cqueues.running() then - sleep.sleep(0) - return old_step(self, timeout) - else - -- local t = self:timeout() or math.huge - -- if timeout then - -- t = math.min(t, timeout) - -- end - local events = self:events() - -- messy - if events == 'r' then - perform(pollio.fd_readable_op(self:pollfd())) - elseif events == 'w' then - perform(pollio.fd_writable_op(self:pollfd())) - elseif events == 'rw' then - perform( - choice( - pollio.fd_readable_op(self:pollfd()), - pollio.fd_writable_op(self:pollfd()) - )) - end - return old_step(self, 0.0) - end -end) - -local http_headers = require "http.headers" -local http_server = require "http.server" -local http_util = require "http.util" - -local function reply(myserver, stream) -- luacheck: ignore 212 - -- Read in headers - local req_headers = assert(stream:get_headers()) - local req_method = req_headers:get ":method" - - -- Log request to stdout - assert(io.stdout:write(string.format('[%s] "%s %s HTTP/%g" "%s" "%s"\n', - os.date("%d/%b/%Y:%H:%M:%S %z"), - req_method or "", - req_headers:get(":path") or "", - stream.connection.version, - req_headers:get("referer") or "-", - req_headers:get("user-agent") or "-" - ))) - - -- Build response headers - local res_headers = http_headers.new() - res_headers:append(":status", "200") - res_headers:append("content-type", "text/plain") - -- Send headers to client; end the stream immediately if this was a HEAD request - assert(stream:write_headers(res_headers, req_method == "HEAD")) - if req_method ~= "HEAD" then - -- Send body, ending the stream - assert(stream:write_chunk("Hello world!\n"..os.date().."\n", true)) - end -end - -print("defining server") -local myserver = assert(http_server.listen { - host = "127.0.0.1"; - port = 8000; - onstream = reply; - onerror = function(_, context, operation, err) - local msg = operation .. " on " .. tostring(context) .. " failed" - if err then - msg = msg .. ": " .. tostring(err) - end - assert(io.stderr:write(msg, "\n")) - end; -}) - --- Override :add_stream to call onstream in a new fiber (instead of new cqueues coroutine) -print("overriding server's 'add_stream' method") -function myserver:add_stream(stream) - fibers.spawn(function() - sleep.sleep(0) -- want to be called from main loop; not from :add_stream callee - local ok, err = http_util.yieldable_pcall(self.onstream, self, stream) - stream:shutdown() - if not ok then - self:onerror()(self, stream, "onstream", err) - end - end) -end - -local function main() - -- Run server in its own lua-fiber - print("spawning server") - fibers.spawn(function() - print("starting server") - assert(myserver:loop()) - end) - - -- Start another fiber that just prints+sleeps in a loop to show off non-blocking-ness of http server - print("spawning heartbeat") - fibers.spawn(function() - print("starting heartbeat") - while true do - print("slow heartbeat") - sleep.sleep(1) - end - end) - - -- And one more to show multiple epolls in action - print("spawning popen fiber") - fibers.spawn(function() - local cmd = exec.command('sh', '-c', 'while true; do echo "non-http fd input received!"; sleep 5; done') - local stdout_pipe = assert(cmd:stdout_pipe()) - local err = cmd:start() - if err then error(err) end - while true do - local received = stdout_pipe:read_line() - print(received) - end - - end) -end - - -print("starting fibers") -fibers.run(main) diff --git a/examples/2-lua-http/index.html b/examples/2-lua-http/index.html deleted file mode 100644 index 510be05..0000000 --- a/examples/2-lua-http/index.html +++ /dev/null @@ -1,19 +0,0 @@ -Google



 

Advanced search

© 2023 - Privacy - Terms

\ No newline at end of file From 04f3bd89687034b811e018e1d7a6785e902c3995 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Mon, 8 Dec 2025 10:51:39 +0000 Subject: [PATCH 075/138] minor spellings --- examples/01-concurrent-tasks-and-sleeping.lua | 12 ++++++------ ...-structured-concurrency-and-fail-fast-scopes.lua} | 2 +- examples/06-cancel-subprocess.lua | 10 +++++----- ...ua => 07-subprocess-with-structured-shutdown.lua} | 0 src/fibers/scope.lua | 2 +- 5 files changed, 13 insertions(+), 13 deletions(-) rename examples/{04-structured-concurrency-and-fail-fast-scopes.lua => 05-structured-concurrency-and-fail-fast-scopes.lua} (95%) rename examples/{05-subprocess-with-structured-shutdown.lua => 07-subprocess-with-structured-shutdown.lua} (100%) diff --git a/examples/01-concurrent-tasks-and-sleeping.lua b/examples/01-concurrent-tasks-and-sleeping.lua index a655512..0c917ee 100644 --- a/examples/01-concurrent-tasks-and-sleeping.lua +++ b/examples/01-concurrent-tasks-and-sleeping.lua @@ -1,10 +1,10 @@ --- Introduce multiple fibres and time-based suspension. +-- Introduce multiple fibers and time-based suspension. -- --- fibers.spawn(fn, ...) attaches a new fibre to the current scope and +-- fibers.spawn(fn, ...) attaches a new fiber to the current scope and -- returns immediately. --- sleep(dt) yields the current fibre for approximately dt seconds; other +-- sleep(dt) yields the current fiber for approximately dt seconds; other -- fibers continue to run. --- When all fibres in the root scope complete, the scheduler stops and +-- When all fibers in the root scope complete, the scheduler stops and -- run(main) returns. package.path = "../src/?.lua;" .. package.path @@ -29,10 +29,10 @@ local function main() spawn(worker, "medium", 0.5, 4) spawn(worker, "slow", 1.0, 3) - -- Unlike Go our main fibre will wait for its scope to finish. + -- Unlike Go our main fiber will wait for its scope to finish. -- Once all children complete, scope reaches status "ok" -- and fibers.run() returns. - print("Main fibre returning; children will keep the scheduler busy") + print("Main fiber returning; children will keep the scheduler busy") end run(main) diff --git a/examples/04-structured-concurrency-and-fail-fast-scopes.lua b/examples/05-structured-concurrency-and-fail-fast-scopes.lua similarity index 95% rename from examples/04-structured-concurrency-and-fail-fast-scopes.lua rename to examples/05-structured-concurrency-and-fail-fast-scopes.lua index 55deeec..aad7047 100644 --- a/examples/04-structured-concurrency-and-fail-fast-scopes.lua +++ b/examples/05-structured-concurrency-and-fail-fast-scopes.lua @@ -27,7 +27,7 @@ local function main() print("Main: starting child scope") local status, err = run_scope(function(child_scope) - -- All fibres spawned here are supervised by child_scope. + -- All fibers spawned here are supervised by child_scope. spawn(sometimes_fails, "flaky", 0.3, 4) spawn(sibling, "sibling", 0.2) diff --git a/examples/06-cancel-subprocess.lua b/examples/06-cancel-subprocess.lua index a840b6b..f99d4a6 100644 --- a/examples/06-cancel-subprocess.lua +++ b/examples/06-cancel-subprocess.lua @@ -3,13 +3,13 @@ -- * Capturing stdout via a pipe -- * Using boolean_choice to race process completion vs timeout -- * Cancelling a scope on timeout and letting structured --- concurrency clean up the subprocess and helper fibres +-- concurrency clean up the subprocess and helper fibers -- -- Style: -- * fibers.run(main) exposes the root scope as an argument. -- * fibers.spawn(fn, ...) uses the current scope implicitly. -- * Timeout is expressed algebraically via boolean_choice, --- rather than a separate watchdog fibre. +-- rather than a separate watchdog fiber. package.path = "../src/?.lua;" .. package.path @@ -31,7 +31,7 @@ local sleep_op = sleep.sleep_op run(function() print("[root] starting subprocess example") - -- Run the subprocess and its helper fibres inside a child scope. + -- Run the subprocess and its helper fibers inside a child scope. -- We use run_scope so that we can interpret status and reason at -- a clear supervision boundary. local status, reason = fibers.run_scope(function() @@ -51,7 +51,7 @@ run(function() } ------------------------------------------------------------------ - -- 2. Reader fibre: drain stdout until EOF or error + -- 2. Reader fiber: drain stdout until EOF or error ------------------------------------------------------------------ spawn(function() @@ -115,7 +115,7 @@ run(function() ------------------------------------------------------------------ -- -- This cancels: - -- * the reader fibre, + -- * the reader fiber, -- * any other children in this scope, and -- * the Command’s scope defer will run _on_scope_exit(), which -- calls shutdown_op and waits for the process to die. diff --git a/examples/05-subprocess-with-structured-shutdown.lua b/examples/07-subprocess-with-structured-shutdown.lua similarity index 100% rename from examples/05-subprocess-with-structured-shutdown.lua rename to examples/07-subprocess-with-structured-shutdown.lua diff --git a/src/fibers/scope.lua b/src/fibers/scope.lua index 8363920..c6596e8 100644 --- a/src/fibers/scope.lua +++ b/src/fibers/scope.lua @@ -398,7 +398,7 @@ end ---@param ev Op ---@return any ... function Scope:perform(ev) - -- sync does the fibre assertion and fail-fast logic + -- sync does the fiber assertion and fail-fast logic local results = pack(self:sync(ev)) local ok = results[1] From 3d86d67367f068a3f7980e6e4613db15b96b319c Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Mon, 8 Dec 2025 10:53:30 +0000 Subject: [PATCH 076/138] new examples, docs and cleanup --- DESIGN-NOTES.md | 725 +++++++++++++++++++++++++++++++++++ EVENTS-AND-OPS.md | 349 +++++++++++++++++ EXECUTION.md | 456 ++++++++++++++++++++++ README.md | 227 +++++------ STRUCTURED-CONCURRENCY.md | 394 +++++++++++++++++++ examples/04-scope-defers.lua | 36 ++ src/fibers.lua | 2 +- 7 files changed, 2065 insertions(+), 124 deletions(-) create mode 100644 DESIGN-NOTES.md create mode 100644 EVENTS-AND-OPS.md create mode 100644 EXECUTION.md create mode 100644 STRUCTURED-CONCURRENCY.md create mode 100644 examples/04-scope-defers.lua diff --git a/DESIGN-NOTES.md b/DESIGN-NOTES.md new file mode 100644 index 0000000..2a36324 --- /dev/null +++ b/DESIGN-NOTES.md @@ -0,0 +1,725 @@ +# DESIGN-NOTES + +This document records the main design choices behind the `fibers` library and the intended architectural boundaries for extension and porting. + +It is aimed at readers who are already comfortable with concurrent programming. + +--- + +## 1. Overview + +`fibers` provides: + +- a cooperative scheduler and lightweight fibers; +- an algebra of *operations* (“Ops”) for describing blocking, choice and cancellation; +- structured concurrency scopes with fail-fast supervision; +- a small set of high-level primitives (sleep, channels, I/O streams, processes). + +Key ideas: + +- treat “things that may block or complete in the future” as first-class values (Ops); +- treat lifetimes (of scopes, fibers, I/O, child processes) as events within the same algebra; +- use structured concurrency to constrain lifetimes to a tree of scopes, so work and resources terminate together. + +The same machinery is used for in-memory primitives (channels, timers), I/O, and child processes. + +--- + +## 2. Heritage and influences + +The design draws on several existing models: + +- **CSP**: + - synchronous channels and rendezvous; + - emphasis on message-passing and composition. + +- **Concurrent ML (CML)**: + - first-class events and an algebra for combining them; + - choice over multiple events with cancellation via negative acknowledgements. + +- **Structured concurrency** (e.g. Trio, Kotlin coroutines, OCaml Eio): + - scopes as supervision domains; + - fail-fast semantics and tree-shaped lifetime. + +- **Actor/supervision systems** (e.g. Erlang/OTP): + - failures organised along a supervision tree; + - emphasis on “let it fail” and restarts at boundaries. + +The library is not a direct port of any single system. It uses: + +- CML-style events as “Ops”; +- a Trio/Eio-like scope tree for lifetime and failure; +- CSP-style channels built as Ops; +- I/O and process execution expressed as further events. + +--- + +## 3. Core concurrency model + +### 3.1 Fibers and scheduler + +The scheduler (`fibers.runtime`) manages **fibers**: lightweight, cooperatively scheduled execution contexts. + +Fibers: + +- are spawned via `fibers.spawn(fn, ...)`, under the current scope; +- yield control only by *performing* Ops (directly or via helpers such as `sleep`, channels, I/O); +- do not run in parallel with each other within a single scheduler; concurrency is interleaving rather than pre-emptive multithreading. + +The scheduler works with *task sources* (such as the poller and timers) which reschedule fibers when external events occur. + +### 3.2 Ops and the event algebra + +Ops (`fibers.op`) represent deferred blocking operations. Each Op is a value describing: + +- what condition to wait on; +- how to register for that condition; +- how to resume the fiber when the condition is met. + +Primitive Ops are built with `op.new_primitive(wrap, try, block)`: + +- `try()` is a non-blocking check that returns either: + - a ready result; or + - “not ready, must block”; +- `block(suspension, wrap)` registers the suspension with some external mechanism (timer, poller, waitset, etc.). + +On top of this, the library provides an algebra of combinators: + +- `op.choice(a, b, ...)` – wait for the first ready event; +- `op.named_choice{ name = op, ... }`, `op.boolean_choice(...)`; +- `op.guard(f)` – lazy construction of an Op (for capturing scope at perform time and similar); +- `op.with_nack(f)` – support for negative acknowledgements in races; +- `op.bracket(acquire, release, use)` – ensure release is called once per acquisition; +- `op.always(...)` – immediately-ready event; +- `op.never()` – never-ready event; +- higher-level helpers: `race`, `first_ready`, `named_choice`, `boolean_choice`. + +Ops are passive until performed. + +#### Performing Ops + +There are three relevant ways to perform Ops: + +- `fibers.perform(ev)` – the primary synchronous entry point: + - implemented by `fibers.performer.perform`; + - when running inside a fiber, it uses the **current scope** and therefore honours structured cancellation and fail-fast semantics; + - when called outside a fiber, it falls back to a suitable default (such as a raw perform or a temporary scope). + +- `Scope:perform(ev)` / `Scope:sync(ev)`: + - `sync` returns `(ok, ...)` where `ok` is `false` if the scope is no longer running; + - `perform` raises on failure or cancellation. + +- `op.perform_raw(ev)`: + - low-level mechanism that directly executes the Op without consulting scopes; + - used by scheduler and scope internals, and in carefully controlled places where scope semantics would be inappropriate (for example, to finish shutdown after the owning scope has already reached a terminal state). + +Normal user-facing code is expected to use `fibers.perform` or `Scope:perform` rather than `op.perform_raw`. + +### 3.3 Structured concurrency scopes + +Scopes (`fibers.scope`) represent supervision domains and form a tree: + +- each fiber runs in a *current* scope; +- each scope has: + - a parent (except the root); + - a weak set of child scopes; + - a waitgroup tracking child fibers; + - a LIFO stack of defers to run on exit; + - a cancellation condition and a join condition; + - a status: `"running" | "ok" | "failed" | "cancelled"`; + - a primary error/cancellation reason plus a list of additional failures. + +Scopes are obtained via: + +- `Scope.root()` – process-wide root scope (created lazily); +- `Scope.current()` – current scope (per fiber or process-wide “global if not in a fiber”); +- `Scope.run(body_fn, ...)` – create a child scope and run `body_fn(child_scope, ...)` in its own fiber; +- `Scope.with_op(build_op)` – treat “run a child scope whose body is an Op” as an Op itself. + +The top-level entry point `fibers.run(main_fn, ...)`: + +- must be called outside any fiber; +- initialises the root scope and scheduler; +- runs `main_fn(scope, ...)` inside a child scope and stops the scheduler when that scope completes; +- returns `main_fn`’s results on `"ok"`, or re-raises the primary error/cancellation reason otherwise. + +#### Fail-fast behaviour and cancellation + +Scopes implement fail-fast supervision: + +- if any fiber in a scope raises an uncaught error: + - the scope status becomes `"failed"` (if it was `"running"`); + - the primary error is recorded (later failures go into the `_failures` list); + - cancellation is propagated to all child scopes. + +Cancellation is observable as an event: + +- `Scope:done_op()` – an Op that becomes ready when the scope is cancelled or fails (but not on success), returning the error or cancellation reason; +- `Scope:join_op()` – an Op that becomes ready once the scope reaches a *terminal* status (after all child fibers and defers have completed), returning `(status, err)`. + +When a scope exits: + +1. a join worker waits for the internal waitgroup (all child fibers finished); +2. if still `"running"`, status is updated to `"ok"`; +3. defers are run in LIFO order; +4. any errors in defers either: + - turn `"ok"` into `"failed"` and record a primary error; or + - are appended to `_failures` if the scope was already failed/cancelled; +5. the join condition is signalled, making `join_op` ready. + +#### Ops under scopes + +Scopes integrate Ops with cancellation: + +- `Scope:run_op(ev)` wraps an Op so that scope cancellation is an alternative completion path, using `op.choice(ev, cancel_op(self))`; +- `Scope:sync(ev)`: + - refuses to run the Op if the scope is not `"running"`; + - performs the wrapped Op (using `op.perform_raw` internally); + - checks the scope status again and returns `false, err` if it has since failed or been cancelled; + - otherwise returns `true, ...results...`. + +- `Scope:perform(ev)`: + - uses `sync`; + - raises on failure or cancellation. + +This arrangement ensures that: + +- scoped operations notice scope failure or cancellation promptly; +- results are only treated as successful when the scope has not failed in the meantime. + +### 3.4 Waitsets and `waitable` + +`fibers.wait` provides infrastructure for building blocking primitives. + +#### Waitset + +`Waitset` is a keyed set of scheduler tasks: + +- `add(key, task)` returns a token with `token:unlink()`; +- `take_one(key)` / `take_all(key)` remove and return waiters; +- `notify_one(key, sched)` / `notify_all(key, sched)` schedule waiting tasks; +- `clear_key`, `clear_all`, `is_empty`, `size`. + +It is used by: + +- poller backends (`rd`/`wr` waiters keyed by fd); +- the SIGCHLD backend (waiters keyed by PID). + +#### `waitable(register, step, wrap)` + +`waitable` builds an Op from: + +- a non-blocking `step()`: + - returns `(true, ...)` when ready; + - returns `(false)` when not ready yet; +- a `register(task, suspension, leaf_wrap)`: + - records the task; + - arranges for `task:run()` to be invoked when the external condition may have changed. + +It ensures that: + +- the operation participates correctly in `choice` and `with_nack`; +- registrations are cancelled when the Op loses a choice; +- repeated wake-ups re-use the same `step` and task. + +This pattern is used for: + +- channels; +- I/O streams; +- poller and exec backends; +- other primitives that need to integrate with the scheduler. + +--- + +## 4. I/O architecture + +The I/O stack is layered to separate platform-neutral abstractions from platform-specific backends. + +### 4.1 Streams and `StreamBackend` + +`fibers.io.stream` defines a buffered `Stream` over an abstract `StreamBackend`. + +A `StreamBackend` is expected to provide: + +- `read_string(max)` -> `data|nil, err|nil` (non-blocking, may indicate “would block”); +- `write_string(data)` -> `bytes_written|nil, err|nil`; +- `on_readable(task)` / `on_writable(task)` -> `WaitToken`: + - integrate with the scheduler (usually via the poller); +- `close()`, `seek(whence, offset)`; +- optional `nonblock`, `block`, `fileno`, `filename`. + +`Stream` provides: + +- internal ring and linear buffers; +- low-level Ops: + - `read_into_op(buf, opts)`; + - `read_string_op(opts)`; + - `write_string_op(str)`; + - `flush_output_op()` (currently a no-op for unbuffered output); +- derived Ops: + - `read_line_op(opts)` – line-oriented reading; + - `read_exactly_op(n)` – exact byte count or error; + - `read_all_op()` – read until EOF or error; +- Lua-compatibility Ops: + - `read_op(fmt)` – `*l`, `*L`, `*a`, numeric counts; + - `write_op(...)` – concatenated `tostring` of arguments; +- synchronous helpers: + - `read_string`, `read_all`, `read_exactly`, `write_string`, `flush_output`, `read`, `write`, `flush`. + +These synchronous helpers use `fibers.perform` (via `fibers.performer.perform`), so they respect the current scope’s fail-fast and cancellation semantics when called from inside a fiber. + +`Stream` itself does not depend on file descriptors or direct system calls. It only depends on: + +- the abstract `StreamBackend`; +- the event algebra (`op`, `wait`); +- the scope-aware performer for synchronous helpers. + +### 4.2 Poller and readiness + +`fibers.io.poller.core` defines a `Poller`: + +- holds two `Waitset`s: + - `rd` – tasks waiting for read readiness on an fd; + - `wr` – tasks waiting for write readiness on an fd; +- exposes `wait(fd, "rd"/"wr", task)`: + - adds the task to the appropriate waitset; + - calls backend `on_wait_change` if provided, so kernels can update interest sets. + +As a task source: + +- `Poller:schedule_tasks(sched, now, timeout)`: + - converts the timeout to milliseconds; + - calls backend `poll(backend_state, timeout_ms, rd_waitset, wr_waitset)`; + - for each fd with events, notifies `rd` and/or `wr` waiters; + - recomputes interest masks. + +The `Poller` module (`fibers.io.poller`) chooses a backend at load time from: + +- `fibers.io.poller.epoll` – Linux epoll backend via FFI; +- `fibers.io.poller.select` – luaposix-based poll/select backend. + +Both implement: + +- `new_backend()`; +- `poll(state, timeout_ms, rd_waitset, wr_waitset)`; +- optional `on_wait_change`, `close_backend`, `is_supported`. + +The scheduler registers the `Poller` singleton as a task source, and calls its `schedule_tasks` method when needed. + +### 4.3 FD backends, files and sockets + +`fibers.io.fd_backend.core` defines a generic `FdBackend`: + +- wraps a platform handle (typically a file descriptor); +- implements methods compatible with `StreamBackend`: + - `read_string(max)` / `write_string(str)`; + - `seek(whence, off)`; + - `on_readable(task)` / `on_writable(task)` via the poller; + - `close()`. + +It also defines module-level helpers: + +- file-level: + - `open_file(path, mode, perms)`; + - `pipe()`; + - `mktemp(prefix, perms)`; + - `fsync(fd)`; + - `rename(old, new)`; + - `unlink(path)`; + - `decode_access(flags)` (read/write from open flags); + - `ignore_sigpipe()`; + +- socket-level: + - `socket(domain, stype, protocol)`; + - `bind(fd, sa)`; + - `listen(fd)`; + - `accept(fd)` (non-blocking, with “would block” signalling); + - `connect_start(fd, sa)`; + - `connect_finish(fd)`. + +Two concrete backends are provided and selected by `fibers.io.fd_backend`: + +- `fd_backend.ffi`: + - uses FFI to call libc functions (`read`, `write`, `open`, `socket`, etc.); + - provides Unix-style modes and permissions; + - includes AF_UNIX socket support. + +- `fd_backend.posix`: + - uses luaposix (`posix.unistd`, `posix.fcntl`, `posix.sys.socket`, etc.); + - provides equivalent functionality without FFI. + +### 4.4 Top-level file and socket modules + +`fibers.io.file` builds on `Stream` and `FdBackend`: + +- `fdopen(fd, flags_or_mode, filename?)`: + - wraps a numeric fd and a mode (numeric flags, string, or table) into a `Stream`; + - uses `fd_backend.new(fd, { filename = filename })` as the `StreamBackend`; + - sets readable/writable according to the mode (via `mode_access` or backend `decode_access`). + +- `open(filename, mode?, perms?)`: + - uses backend `open_file`; + - wraps the resulting fd via `fdopen`. + +- `pipe()`: + - uses backend `pipe()` and wraps the read and write ends as separate streams. + +- `mktemp(prefix, perms?)`: + - uses backend `mktemp`. + +- `tmpfile(perms?, tmpdir?)`: + - creates a temporary file via `mktemp`; + - wraps it as a `"r+"` stream via `fdopen`; + - arranges for unlink-on-close by overriding backend `close`, with an opt-out via `stream:rename(newname)`. + +- `init_nonblocking(fd)`: + - delegates to backend `set_nonblock`. + +`fibers.io.socket` uses `FdBackend` sockets: + +- `socket(domain, stype, protocol?)` -> a `Socket` wrapper that: + - ensures the fd is non-blocking; + - exposes methods such as `listen_unix`, `accept_op`, `accept`, `connect_op`, `connect`, `connect_unix_op`, `connect_unix`, `close`. + +- `listen_unix(path, opts?)`: + - binds and listens on an AF_UNIX path; + - optionally provides ephemeral semantics (unlink on close). + +- `connect_unix(path, stype?, protocol?)`: + - creates a socket, connects, and wraps the connection as a full-duplex `Stream`. + +Underlying system calls are isolated in `FdBackend` implementations. The `file` and `socket` modules themselves see only: + +- `FdBackend` functions such as `open_file`, `pipe`, `socket`, `bind`, `listen`, `accept`; +- the `Stream` abstraction. + +They do not depend on any specific syscall API and do not call FFI or luaposix directly. + +### 4.5 Process execution and ExecBackend + +`fibers.exec` provides structured process execution with scope-bound lifetime. + +A `Command` encapsulates a process configuration and its lifecycle. It is constructed via: + +- `exec.command{ ...spec... }`; or +- `exec.command("prog", "arg1", "arg2", ...)` (positional argv). + +The spec includes: + +- `argv` – programme and arguments; +- `cwd` – working directory; +- `env` – environment overrides; +- `flags` – backend-defined flags (e.g. `setsid`); +- `stdin`, `stdout`, `stderr` – each of: + - `"inherit"` – use the current process’ stdio for that stream; + - `"null"` – redirect to/from `/dev/null`; + - `"pipe"` – create a pipe (child side wired to the process, parent side exposed as a `Stream`); + - for stderr only, `"stdout"` – share stdout’s configuration; + - a `Stream` – user-supplied stream (not owned by the Command). + +Internally, `fibers.exec` normalises these into `ExecStreamConfig` values and delegates to an `ExecBackend` selected by `fibers.io.exec_backend`: + +- `ExecBackend.start(spec)` returns: + - backend state (including `pid`); + - streams (parent ends for any pipes created). + +`Command` methods include: + +- configuration setters (`set_stdin`, `set_stdout`, `set_stderr`, `set_cwd`, `set_env`, `set_flags`, `set_shutdown_grace`); +- introspection: + - `status()` -> `CommandStatus`, code or signal, error; + - `pid()`, `argv()`; +- signalling: + - `kill(sig?)` – uses backend signalling; attempts a meaningful default if `sig` is nil; +- stream access: + - `stdin_stream()`, `stdout_stream()`, `stderr_stream()`: + - return `Stream`s for pipe-backed stdio; + - enforce that pipes are created lazily by calling `_ensure_started()` if necessary. + +Lifecycle Ops: + +- `run_op()`: + - ensures the process has been spawned; + - waits on `backend:wait_op()` and updates command status; + - returns `(status, code, signal, err)`. + +- `shutdown_op(grace?)`: + - attempts a polite termination (backend `terminate` or `send_signal`); + - runs a `boolean_choice` between: + - the process’ `run_op()`; and + - `sleep.sleep_op(grace)` (default `DEFAULT_SHUTDOWN_GRACE`); + - if the process does not exit in time: + - attempts a more forceful kill (backend `kill` or `send_signal`); + - performs `backend:wait_op()` to observe exit; + - returns `(status, code, signal, err)`. + +- `output_op()`: + - if stdout is currently inherited and the command has not started, switches stdout to `"pipe"`; + - ensures the process is started; + - reads all of stdout via `stream:read_all_op()`; + - waits for process completion via `run_op()`, using `perform_with_scope_or_raw` to respect scope semantics when appropriate; + - returns `(string_output, status, code, signal, err)`. + +- `combined_output_op()`: + - rewires stderr to `"stdout"` (if not already a pipe/stream); + - delegates to `output_op()`. + +Scope integration: + +- `exec.command` must be called from inside a fiber (it asserts `Runtime.current_fiber()`), and binds the `Command` to the current scope; +- the scope registers a `defer` that: + - on scope exit: + - performs a best-effort `shutdown_op`; + - closes any owned stdio streams; + - closes the backend state. + +Actual process management is delegated to `ExecBackend` implementations via `fibers.io.exec_backend.core.build_backend`. Two are provided: + +- `exec_backend.pidfd` (Linux pidfd-based, FFI): + - uses `fork`, `execvp`, `pidfd_open`, and non-blocking `waitpid(WNOHANG)`; + - uses the general poller (via pidfd) to integrate with the scheduler. + +- `exec_backend.sigchld` (portable POSIX, luaposix-based): + - uses `fork` and `execp`; + - installs a SIGCHLD handler writing to a self-pipe; + - runs a reaper task that: + - drains the self-pipe; + - uses `wait(WNOHANG)` to update per-pid state; + - notifies waiters via a `Waitset`. + +Both use the shared `exec_backend.stdio` module to: + +- map `ExecStreamConfig` values to child and parent file descriptors; +- close child-only fds in the parent; +- build parent-side `Stream`s via `file.fdopen`. + +As with files and sockets, `fibers.exec` itself does not call syscalls directly. It depends on: + +- the `ExecBackend` abstraction; +- the `Stream` abstraction and scope/event machinery. + +### 4.6 Portability and platform boundaries + +Current backends are Unix-like: + +- epoll and poll/select; +- POSIX-style file descriptors; +- fork/exec and signals; +- pidfd on Linux. + +However: + +- `fibers.io.stream`, `fibers.io.file`, `fibers.io.socket`, and `fibers.exec` are all written solely against internal abstract interfaces (`StreamBackend`, `FdBackend`, `ExecBackend`) and Ops; +- platform-specific details are confined to backend modules. + +The intention is that additional platforms (for example, Windows) can be supported by: + +- providing a `poller` backend using that platform’s event facilities; +- providing an `FdBackend` over native handles; +- providing an `ExecBackend` over the platform’s process APIs. + +Application code using the top-level I/O and exec APIs should not need to change. + +--- + +## 5. Error handling, cancellation and lifetimes + +### 5.1 Fail-fast supervision + +Error handling is organised around scopes: + +- an uncaught error in any fiber: + - is attributed to that fiber’s current scope; + - causes the scope to transition from `"running"` to `"failed"` (if not already terminal); + - triggers cancellation down the scope tree. + +Users can still catch and handle expected errors locally. Unexpected errors bubble to scope boundaries by default. + +### 5.2 Cancellation as an event + +Cancellation is expressed as part of the event algebra: + +- each scope has a cancellation condition; +- `Scope:done_op()` and the internal `cancel_op(scope)` both reuse this condition to build Ops that become ready on cancellation; +- `Scope:run_op(ev)` races a user Op against cancellation; +- `Scope:sync` and `Scope:perform` enforce that operations in failed or cancelled scopes do not silently proceed. + +The same pattern is used in the exec subsystem: + +- shutdown races process exit against a timer and then escalates; +- `perform_with_scope_or_raw` ensures that exec helpers honour scope cancellation where appropriate, but can still complete shutdown once the owning scope is no longer running. + +### 5.3 Defers and resource cleanup + +Each scope maintains LIFO defers via `scope:defer(fn)`: + +- run exactly once when the scope exits (after child fibers finish); +- can be used to: + - close streams; + - shut down child processes; + - release other resources tied to the scope’s lifetime. + +Errors in defers: + +- convert `"ok"` into `"failed"` and record an error; or +- are appended to `_failures` if the scope was already failed or cancelled. + +`fibers.exec` uses this mechanism extensively to ensure that child processes are not left running beyond their scope. + +--- + +## 6. Relationship to other models + +### 6.1 Futures and async/await + +In a typical futures/async model: + +- work is represented as futures attached to specific operations; +- lifetime and cancellation are usually per-future concerns; +- structured concurrency is often added on top. + +In this library: + +- the primary representation is the **Op**; +- lifetime and cancellation are attached to **scope trees**, not individual operations; +- the same event algebra is used for: + - channels and timers; + - I/O readiness; + - process completion; + - scope completion and cancellation. + +This avoids proliferating distinct abstractions for different kinds of waiting and simplifies expressing complex coordination patterns. + +### 6.2 Go-style goroutines and channels + +The combination of fibers and channels is similar in spirit to Go: + +- both provide lightweight concurrent contexts; +- both provide channels for synchronous (and optionally buffered) communication. + +However: + +- there is no built-in language `select`; instead, selection is expressed via the event algebra (`choice` etc.); +- structured concurrency scopes provide explicit, enforced lifetime for groups of fibers; +- synchronous helpers (`fibers.perform`, stream methods, etc.) are scope-aware and respect fail-fast semantics. + +### 6.3 Actor systems + +The scope tree resembles a supervision tree in actor systems: + +- failures propagate upwards and prompt cancellation of child scopes; +- work is grouped by lifetime rather than treated as isolated tasks. + +The primary difference is that communication is via channels and streams rather than actor mailboxes, and there is a single scheduler (rather than one thread per actor). + +--- + +## 7. Intended usage patterns + +The following patterns are intended to be typical. + +### 7.1 Application entry point + +From non-fiber code: + +- call `fibers.run(main_fn, ...)`. + +Inside `main_fn(scope, ...)`: + +- use `fibers.spawn(fn, ...)` to create child fibers under `scope`; +- use `fibers.run_scope`/`Scope.run` or `fibers.scope_op`/`Scope.with_op` to create further nested scopes where needed; +- perform Ops via: + - `fibers.perform(ev)` (uses the current scope); or + - `scope:perform(ev)` / `scope:sync(ev)`. + +The process terminates when the main child scope of the root completes and the scheduler stops. + +### 7.2 I/O services + +Use the top-level modules: + +- `fibers.io.file` for file streams and pipes; +- `fibers.io.socket` for AF_UNIX sockets (and any future socket types added by backends); +- `fibers.exec` for child processes; +- `fibers.channel` for in-memory communication; +- `fibers.sleep` for timers. + +Avoid depending directly on platform-specific backends (`fd_backend`, `exec_backend`, poller backends) in application code. This keeps code portable and keeps platform concerns confined to backends. + +### 7.3 Coordination and cancellation + +Express waits and coordination in terms of the event algebra and scopes: + +- use `op.choice` / `named_choice` / `boolean_choice` to race: + - I/O readiness vs timeouts; + - multiple channels or streams; + - child process exit vs cancellation; + +- bind logical units of work (requests, sessions, jobs) to their own scopes: + - cancel the scope to cancel all associated work and resources; + - use defers to tie external resources to that scope. + +--- + +## 8. Extension points and future work + +The architecture is intended to be open to new backends and platforms. + +### 8.1 Poller backends + +To integrate a new kernel event mechanism: + +- implement a backend module with: + - `new_backend()`; + - `poll(state, timeout_ms, rd_waitset, wr_waitset)`; + - optional `on_wait_change`, `close_backend`, `is_supported`; + +- add it to the candidate list in `fibers.io.poller`. + +### 8.2 FD and stream backends + +To support new handle types or platforms: + +- implement an `FdBackend` using `fd_backend.core.build_backend(ops)` with: + - `set_nonblock`, `read`, `write`, `seek`, `close`; + - file helpers (`open_file`, `pipe`, `mktemp`, `fsync`, `rename`, `unlink`, `decode_access`, `ignore_sigpipe`); + - optional socket helpers (`socket`, `bind`, `listen`, `accept`, `connect_start`, `connect_finish`); + - optional metadata (`modes`, `permissions`, `AF_UNIX`, `SOCK_STREAM`). + +Existing `file`, `socket`, and `stream` modules should operate unchanged. + +### 8.3 Exec backends + +To add process management on other platforms: + +- implement an `ExecBackend` using `exec_backend.core.build_backend(ops)` with: + - `spawn(spec)` -> backend state, parent streams, error; + - `poll(state)` -> `done, code, signal, err`; + - `register_wait(state, task, suspension, leaf_wrap)` -> `WaitToken`; + - optional `send_signal`, `terminate`, `kill`, `close`, `is_supported`. + +`fibers.exec` and `Command` then use this backend without modification. + +### 8.4 Cross-platform targets + +While current implementations target Unix-like platforms, the layering is designed so that: + +- the public APIs for I/O and exec are independent of any particular syscall interface; +- backends encapsulate the dependencies on `epoll`, `select`, signals, and so on. + +This is intentional to support future cross-platform work without changing the programming model. + +--- + +## 9. Summary + +The main design choices are: + +- adopt a CML-style event algebra (Ops) as the common representation for all blocking behaviour; +- treat lifetimes (scopes, processes, I/O) as first-class events within that algebra; +- organise concurrent work into a tree of scopes with fail-fast supervision and structured cancellation; +- express channels, timers, I/O and processes uniformly in terms of Ops and scopes; +- keep top-level I/O and exec modules free of direct system-call dependencies and delegate those concerns to pluggable backends; +- make the default synchronous entry points (`fibers.perform`, stream methods, etc.) scope-aware, so they always reflect structured concurrency semantics when used from within fibers. + +The intention is to provide a small, coherent foundation for building reliable concurrent systems, with clear lifetime and cancellation semantics and a clear separation between platform-neutral logic and platform-specific backends. diff --git a/EVENTS-AND-OPS.md b/EVENTS-AND-OPS.md new file mode 100644 index 0000000..a09aed9 --- /dev/null +++ b/EVENTS-AND-OPS.md @@ -0,0 +1,349 @@ +# Events and Ops + +This document describes the event algebra used by the library: how operations (`Op`s) are represented, how to compose them, and how to execute them through the top-level `fibers` module. + +The emphasis here is on the public entry points exported from: + +```lua +local fibers = require "fibers" +```` + +Most users will only need: + +* `fibers.perform(op)` – execute an operation in the current fiber and scope. +* `fibers.choice`, `fibers.race`, `fibers.first_ready`, + `fibers.named_choice`, `fibers.boolean_choice` – compose operations. +* `fibers.guard`, `fibers.with_nack`, `fibers.bracket` – build structured, cancellable operations. +* Operations provided by other modules (channels, sleep, streams, exec, etc.) which return `Op` values. + +Lower-level construction utilities live in `fibers.op` and `fibers.wait` and are primarily intended for implementers of new primitives. + +--- + +## 1. What is an `Op`? + +An `Op` is a *description* of a potentially blocking operation that can be: + +* Performed by a fiber (which may suspend and resume). +* Composed with other operations (races, timeouts, structured cleanup). +* Observed by scope cancellation (so that a cancelled scope can interrupt it). + +You do **not** perform raw I/O or sleep directly; instead you obtain an `Op` from a primitive and then pass it to the event algebra or to `fibers.perform`. + +Examples: + +* Channels: `ch:get_op()`, `ch:put_op(value)` +* Timers: `sleep.sleep_op(dt)` +* Streams: `stream:read_line_op()`, `stream:write_string_op("...")` +* Processes: `cmd:run_op()`, `cmd:output_op()` +* Scopes: `scope:join_op()`, `scope:done_op()` + +These all return `Op` values. + +--- + +## 2. Performing operations + +The usual way to execute an operation is via the top-level `perform` helper: + +```lua +local fibers = require "fibers" + +fibers.run(function(scope) + local ch = require "fibers.channel".new() + fibers.spawn(function(child_scope) + ch:put("hello from child") -- uses channel’s own perform() + end) + + local msg = fibers.perform(ch:get_op()) + print("got:", msg) +end) +``` + +Key points: + +* `fibers.perform(op)` must be called from inside a running fiber (for example, inside `fibers.run` or a function launched with `fibers.spawn`). +* It obeys the current scope’s cancellation rules: if the enclosing scope is cancelled or fails, the blocked `perform` will be interrupted according to the scope semantics (see `STRUCTURED-CONCURRENCY.md`). +* Most higher-level modules provide their own convenience wrappers (for example, `Channel:get()` calls `perform(self:get_op())` internally). + +In lower-level code you may see `op.perform_raw(op_value)` or `scope:perform(op_value)`; the intended public surface is `fibers.perform`. + +--- + +## 3. Core combinators (top-level API) + +The `fibers` module re-exports the main combinators from `fibers.op`: + +```lua +local fibers = require "fibers" + +-- Constructors and guards +fibers.always(...) +fibers.never() +fibers.guard(build_fn) +fibers.with_nack(build_fn) +fibers.bracket(acquire, release, use) + +-- Choices +fibers.choice(...) +fibers.race(...) +fibers.first_ready(...) +fibers.named_choice(table_of_ops) +fibers.boolean_choice(op1, op2) +``` + +These all work with `Op` values and return new composite `Op` values. + +### 3.1 `always` and `never` + +```lua +local ev1 = fibers.always(42, "ok") -- immediately ready +local ev2 = fibers.never() -- never becomes ready +``` + +* `fibers.always(...)` creates an `Op` that is immediately ready and, when performed, returns the given values without blocking. +* `fibers.never()` creates an `Op` that never completes. It is mainly useful in tests and as a placeholder. + +These are often used in wrappers and default behaviours, for example where a primitive needs to return a success result without waiting. + +### 3.2 `choice` + +```lua +local ev = + fibers.choice( + ch:get_op(), -- receive from a channel + sleep.sleep_op(5.0) -- or time out after 5 seconds + ) + +local result = fibers.perform(ev) +``` + +`fibers.choice(e1, e2, ...)` builds an operation that: + +* Waits until at least one of the argument operations can complete. +* Chooses one of the ready operations (if several are ready, the choice is implementation-defined). +* Completes with the chosen operation’s results. +* Ensures that the non-chosen operations are aborted, so that they can clean up any registrations or reservations. + +`choice` is the basic building block for races, timeouts, and multi-way wait patterns. + +### 3.3 `race` and `first_ready` + +`race` and `first_ready` are convenience wrappers around `choice`, tailored to common patterns: + +* `fibers.race(e1, e2, ...)` – race several events and get the winner’s result. It is a thin wrapper over `choice`, intended for readability when you only care about “whichever completes first”. +* `fibers.first_ready(e1, e2, ...)` – a helper for situations where you are polling a set of readiness operations and want the first that can make progress. It is also built on top of `choice`. + +In both cases the exact return shape follows the underlying events. They participate in cancellation and abort in the same way as `choice`. + +### 3.4 `named_choice` + +`named_choice` labels branches: + +```lua +local lines_op = fibers.named_choice{ + stdout = out_stream:read_line_op(), + stderr = err_stream:read_line_op(), +} + +local which, line, err = fibers.perform(lines_op) + +if which == "stdout" then + -- handle stdout line +elseif which == "stderr" then + -- handle stderr line +end +``` + +`fibers.named_choice{ name1 = ev1, name2 = ev2, ... }` builds an `Op` that, when performed: + +* Chooses one of the arms using the same rule as `choice`. +* Returns: + + * the key (`name`), and + * the chosen operation’s results. + +Unchosen arms are aborted as with `choice`. + +This pattern is used in `fibers.io.stream.merge_lines_op`. + +### 3.5 `boolean_choice` + +`boolean_choice` is a two-way choice that returns a Boolean indicating which arm completed: + +```lua +local ev = fibers.boolean_choice( + cmd:run_op(), -- process finished + sleep.sleep_op(5.0) -- timeout +) + +local is_exit, status, code_or_sig, err = fibers.perform(ev) + +if is_exit then + -- process finished; status/code_or_sig/err describe outcome +else + -- timeout; you might now send a termination signal or similar +end +``` + +`fibers.boolean_choice(ev1, ev2)` builds an `Op` that: + +* Races `ev1` against `ev2`. +* When performed, returns: + + * a Boolean flag `true` if `ev1` won, `false` if `ev2` won, followed by + * the chosen operation’s results. + +This is convenient for patterns such as “operation vs timeout”. + +--- + +## 4. Guard, bracket and nacks + +### 4.1 `guard` + +`guard` delays construction of an operation until the moment it is performed: + +```lua +local ev = fibers.guard(function() + -- Runs each time ev is performed, in the current fiber and scope. + return ch:get_op() +end) +``` + +This is used to: + +* Capture the *current* dynamic context (scope, fiber, cancellation state). +* Create fresh internal state for every synchronisation (for example, a token or registration handle). +* Ensure that composite operations remain reusable; you can compile an `Op` once and perform it many times, each time with fresh internal state. + +Internally, many primitives use `guard` to wrap their low-level logic. + +### 4.2 `bracket` + +`bracket` provides structured resource management at the level of operations: + +```lua +local ev = fibers.bracket( + function() -- acquire + local sock = connect_somewhere() + return sock + end, + function(sock, aborted) -- release + if aborted then + sock:close() + else + sock:shutdown() + end + end, + function(sock) -- use + return sock_stream(sock):read_line_op() + end +) + +local line = fibers.perform(ev) +``` + +`fibers.bracket(acquire, release, use)`: + +* Runs `acquire()` once the operation is actually committed to run. +* Passes the acquired resource to `use`, which must return an `Op` describing the body. +* Guarantees that `release(resource, aborted)` is called exactly once: + + * with `aborted == false` if the body completes normally; + * with `aborted == true` if the operation is aborted (for example, loses a `choice`) or if the enclosing scope is cancelled. + +This is the event-level equivalent of `pcall`/`xpcall` with `finally` blocks and is heavily used in internal modules for safe registration and cleanup. + +### 4.3 `with_nack` + +`with_nack` is a specialised combinator for building abortable operations that need early notification when they *lose* a race. + +In outline: + +```lua +local ev = fibers.with_nack(function(nack_op) + -- Construct and return an Op. + -- If this Op participates in a choice and *loses*, nack_op will + -- be performed so you can cancel any outstanding work. +end) +``` + +Typical uses include: + +* Registering with an external system (for example, an I/O multiplexer or a remote service) with a way to cancel that registration if the operation is abandoned. +* Building higher-level protocols where a losing branch needs to send a “never mind” message. + +Most users will not need `with_nack` directly; it is primarily an implementation tool for robust primitives. + +--- + +## 5. Interaction with scopes and cancellation + +Scopes (see `STRUCTURED-CONCURRENCY.md`) represent supervised lifetime domains. Operations and scopes interact as follows: + +* When you call `fibers.perform(op)` inside a fiber, the library ensures that: + + * The operation runs under the current scope. + * If the scope is cancelled or fails, the operation is aborted (for example, choices are resolved in favour of the scope’s cancellation `Op`). +* Many primitives expose scope-aware wrappers: + + * `scope:run_op(ev)` wraps an operation so that it observes that scope’s cancellation. + * `scope:perform(ev)` executes an operation under that scope and turns cancellation into a Lua error. + +A common pattern is “operation or cancellation, whichever first”: + +```lua +local scope = fibers.current_scope() +local body = ch:get_op() +local cancel = scope:done_op() + +local ev = fibers.choice(body, cancel) + +local ok, value_or_reason = pcall(function() + return fibers.perform(ev) +end) +``` + +Internally, scope cancellation is itself represented as an `Op` which competes in the same event algebra as other operations. + +--- + +## 6. Implementing new primitives (overview) + +Most application code only needs the top-level `fibers` entry points. If you are implementing new primitives, you will usually work with: + +* `fibers.op` – low-level event constructors (`new_primitive`, `on_abort`, etc.). +* `fibers.wait.waitable` – helper for building operations driven by non-blocking `step` functions and registration callbacks. + +The standard pattern is: + +```lua +local op = require "fibers.op" +local wait = require "fibers.wait" + +local function my_primitive_op(...) + local function step() + -- Non-blocking check: + -- return true, ...results... if ready; + -- return false if not yet ready. + end + + local function register(task, suspension, leaf_wrap) + -- Arrange for task:run() to be called when progress may have been made. + -- Return a token with an optional token:unlink() for cancellation. + end + + return wait.waitable(register, step) +end +``` + +By building on `waitable` and the combinators described above, your new primitive will automatically: + +* Participate correctly in `choice`, `race`, `named_choice`, etc. +* Respond properly to aborts (losing a race) and scope cancellation. +* Integrate with the scheduler and poller without blocking the event loop. + +For most users, the important point is that every blocking building block in this library exposes its behaviour as an `Op`. You can then reason about and combine these operations declaratively using the small algebra described above, rather than manually managing callbacks, state machines or ad-hoc flags. + +--- diff --git a/EXECUTION.md b/EXECUTION.md new file mode 100644 index 0000000..b6fe6ae --- /dev/null +++ b/EXECUTION.md @@ -0,0 +1,456 @@ +# Process execution (`fibers.exec`) + +This document describes how to start and manage external processes using this library. + +It focuses on usage from the top-level `fibers.lua` entry points: + +- `fibers.run` – to initialise the scheduler and root scope. +- `fibers.spawn` – to start fibers inside the current scope. + +The process execution API itself is provided by `fibers.exec`. + +--- + +## Overview + +The execution subsystem provides: + +- A `Command` abstraction for a single external process. +- Structured lifetime management: each `Command` is owned by the scope in which it is created. +- Configurable stdin/stdout/stderr: + - inherit from the parent process + - connect to `/dev/null` + - pipe via `Stream` + - reuse another stream (including `stdout` for `stderr`) +- Operations (`Op`s) for: + - waiting for process completion + - graceful shutdown with a timeout and forced kill + - capturing stdout (and optionally stderr) as a string + +The underlying implementation uses platform-specific backends: + +- Linux `pidfd` when available. +- A portable `SIGCHLD` + self-pipe backend otherwise. + +These details are hidden behind the `fibers.exec` interface. + +--- + +## Basic usage from `fibers.run` + +`exec.command` must be called from inside a fiber. In practice this means inside the function passed to `fibers.run` or a fiber spawned from it. + +Typical pattern: + +```lua +local fibers = require 'fibers' +local exec = require 'fibers.exec' + +fibers.run(function(scope) + -- Build a command: argv[1] is the programme, argv[2..n] are arguments. + local cmd = exec.command("ls", "-1") + + -- Capture stdout as a string and wait for the process to exit. + local out, status, code_or_sig, err = + scope:perform(cmd:output_op()) + + if status == "ok" or status == "exited" and code_or_sig == 0 then + print("ls output:\n" .. out) + else + print("ls failed:", status, code_or_sig, err) + end +end) +```` + +Key points: + +* Use `fibers.run(function(scope) ... end)` as the entry point. +* Use `scope:perform(op)` to run `Op`s (including the ones returned by `Command` methods) in a way that respects scope cancellation and fail-fast semantics. +* The command’s lifetime is bound to `scope`: when `scope` finishes, the process will be shut down and its resources cleaned up. + +--- + +## Constructing commands + +The main entry point is: + +```lua +local exec = require 'fibers.exec' + +-- Table form +local cmd = exec.command{ + "sh", "-c", "echo hello", + cwd = "/tmp", + env = { FOO = "bar" }, + stdin = "null", + stdout = "pipe", + stderr = "stdout", + shutdown_grace = 2.0, +} + +-- Positional form (argv only, default options) +local cmd2 = exec.command("ls", "-l", "/") +``` + +Both forms create a `Command` object. In table form the fields are: + +* `spec[1]`, `spec[2]`, … – argv elements +* `cwd` – working directory (string or `nil`) +* `env` – table of environment variables (`string -> string|nil`) +* `flags` – table; currently used for options such as `setsid` +* `stdin`, `stdout`, `stderr` – stdio configuration (see below) +* `shutdown_grace` – timeout in seconds for graceful shutdown, default `1.0` + +There is also a set of setter methods which can be used before the command is started: + +```lua +cmd:set_cwd("/var/log") + :set_env{ LANG = "C" } + :set_stdin("null") + :set_stdout("pipe") + :set_stderr("stdout") + :set_shutdown_grace(5.0) +``` + +All setters return `self` and will raise if the command has already been started. + +--- + +## Stdio configuration + +Each of `stdin`, `stdout`, `stderr` can be configured using either: + +* a string mode; or +* an existing `Stream` instance. + +Allowed string modes: + +* `"inherit"` – use the parent process’s file descriptor (default). +* `"null"` – connect to `/dev/null` (read-only or write-only as appropriate). +* `"pipe"` – create a new pipe connected to the parent. +* `"stdout"` – for `stderr` only; share the same destination as `stdout`. + +Passing a `Stream` instance (from `fibers.io.stream` or `fibers.io.file`) uses the underlying file descriptor for that stream. In this case the `Command` does not own the stream; it will not be closed automatically. + +Example configurations: + +```lua +local file = require 'fibers.io.file' + +-- Discard all output, no input +local cmd1 = exec.command{ + "my-tool", + stdin = "null", + stdout = "null", + stderr = "null", +} + +-- Pipe stdout to a stream, inherit stderr +local out_file = assert(file.open("out.log", "w")) +local cmd2 = exec.command{ + "my-tool", + stdout = out_file, -- user-supplied stream +} + +-- Capture stdout and stderr together +local cmd3 = exec.command{ + "my-tool", + stdout = "pipe", + stderr = "stdout", -- merged with stdout +} +``` + +If you set `stdout = "pipe"` or `stderr = "pipe"`, the backend will create `Stream`s for you, and the `Command` will own and close them during cleanup. + +--- + +## Inspecting command state + +The main introspection methods are: + +```lua +local status, code_or_sig, err = cmd:status() +local pid = cmd:pid() +local argv_copy = cmd:argv() +``` + +The status is one of: + +* `"pending"` – created but not yet started. +* `"running"` – process has been started and is still running. +* `"exited"` – process exited normally. +* `"signalled"` – process terminated due to a signal. +* `"failed"` – failure to start or manage the process (e.g. `exec` error). + +For `"exited"` and `"signalled"` the second result is: + +* exit code (integer) for `"exited"`; +* signal number (integer) for `"signalled"`. + +For `"failed"` the second result is `nil` and the third result is an error string. + +Note that the process is only started when you first use one of the operations below (`run_op`, `shutdown_op`, `output_op`, etc), or when you explicitly request a piped stream. + +--- + +## Accessing stdio streams + +You can obtain `Stream`s for the child’s stdio via: + +```lua +local stdin_stream, serr1 = cmd:stdin_stream() +local stdout_stream, serr2 = cmd:stdout_stream() +local stderr_stream, serr3 = cmd:stderr_stream() +``` + +Behaviour: + +* If the mode is `"inherit"` or `"null"`, these functions return `nil`. +* If the mode is `"stream"`, you get back the user-supplied stream. +* If the mode is `"pipe"`, the process is started if necessary and you get a new `Stream` instance on the parent side. +* For `stderr_stream`, if mode is `"stdout"`, it delegates to `stdout_stream`. + +Example: streaming a child’s output line by line while it runs: + +```lua +fibers.run(function(scope) + local exec = require 'fibers.exec' + + local cmd = exec.command{ + "ping", "-n", "5", "example.org", + stdout = "pipe", + stderr = "stdout", + } + + local out_stream, err = cmd:stdout_stream() + assert(out_stream, err) + + scope:spawn(function(child_scope) + while true do + local line, lerr = child_scope:perform(out_stream:read_line_op()) + if not line then + break + end + print("[child]", line) + end + end) + + local status, code_or_sig, cerr = + scope:perform(cmd:run_op()) + + print("child finished:", status, code_or_sig, cerr) +end) +``` + +--- + +## Waiting for completion + +To wait for a process to finish, use `run_op`: + +```lua +local status, code, signal, err = + scope:perform(cmd:run_op()) +``` + +Semantics: + +* If the process has not yet been started, `run_op` will start it. +* If the process is already complete, `run_op` resolves immediately. +* The results are: + + * `status` – `"exited"`, `"signalled"` or `"failed"`. + * `code` – exit code (for `"exited"`) or `nil`. + * `signal` – signal number (for `"signalled"`) or `nil`. + * `err` – error string for `"failed"`, or `nil` otherwise. + +Because `run_op` is an `Op`, it participates in `choice` and respects scope cancellation when used with `scope:perform`. + +--- + +## Graceful shutdown + +To request a graceful shutdown, use `shutdown_op`: + +```lua +-- Optional override of the grace period (seconds) +local status, code, signal, err = + scope:perform(cmd:shutdown_op(5.0)) +``` + +Behaviour: + +1. Ensure the process has been started. +2. Send a polite termination request: + + * `terminate()` if the backend implements it; or + * a default signal via `send_signal()` (commonly `SIGTERM`). +3. Race: + + * `cmd:run_op()` (process exits), and + * a timer (`sleep_op(grace)`). +4. If the process exits within the grace period, return that status. +5. If not, attempt a more forceful kill: + + * `kill()` if provided; otherwise fall back to `send_signal()` again. +6. Wait for the process to complete, then return the final status. + +`shutdown_op` is suitable to call during normal control flow (e.g. when you decide to stop a worker) and is also used automatically when the owning scope exits (see below). + +--- + +## Capturing output + +### `output_op` – stdout only + +`output_op` runs the process to completion while capturing all data from stdout into a single string: + +```lua +local out, status, code, signal, err = + scope:perform(cmd:output_op()) +``` + +Semantics: + +* If `stdout` would otherwise be inherited, `output_op` arranges for it to be a pipe instead. +* The process is started if necessary. +* All data from the stdout stream is read using `read_all_op`. +* Once reading completes, the operation waits for the process to exit. +* It returns: + + * `out` – captured stdout as a string (possibly empty). + * `status`, `code`, `signal`, `err` – as for `run_op`. + +Standard usage pattern: + +```lua +local cmd = exec.command("sh", "-c", "echo hello; exit 0") +local out, status, code, signal, err = + scope:perform(cmd:output_op()) + +if status == "exited" and code == 0 then + print("child said:", out) +else + print("child failed:", status, code or signal, err) +end +``` + +### `combined_output_op` – stdout and stderr together + +If you want stdout and stderr merged, use `combined_output_op`: + +```lua +local out, status, code, signal, err = + scope:perform(cmd:combined_output_op()) +``` + +Preconditions: + +* `stderr` must not already be configured as `"pipe"` or a direct `Stream`: + + * `combined_output_op` sets `stderr` to `"stdout"` if it was `"inherit"`. + +The behaviour is otherwise the same as `output_op`, but with stderr directed to stdout before capture. + +--- + +## Sending signals directly + +If you want to send a signal yourself, use `kill`: + +```lua +local ok, err = cmd:kill() -- default signal (backend-chosen) +local ok2, err2 = cmd:kill("TERM") -- if the backend supports named signals +``` + +High-level behaviour: + +* If the command has not started, `kill` fails with an error. +* If the command has already completed, `kill` returns success. +* Otherwise it forwards the request to the backend, using: + + * an explicit `send_signal(sig)` if passed; or + * a forceful `kill()` method if available; or + * `terminate()`; or + * a default `send_signal()`. + +Signal naming and exact behaviour are backend dependent; backends typically accept: + +* a numeric signal; and/or +* a symbolic identifier such as `"TERM"`. + +--- + +## Scope-bound lifetime + +Each `Command` is created with reference to the current scope: + +* `exec.command` asserts that it is called from inside a fiber. +* The `Scope` returned by `Scope.current()` at that point is recorded. +* A `defer` handler is registered on that scope which: + + * performs a best-effort shutdown of the process; and + * closes any streams owned by the command; and + * closes the backend handle. + +This has two important consequences: + +1. If the scope finishes normally or fails, processes started from it are not left running unintentionally. +2. Cleanup runs even when the scope is already in a terminal state; `perform_with_scope_or_raw` uses raw `op.perform_raw` when scope-based cancellation is no longer applicable, so shutdown can complete. + +In general: + +* create commands in the scope that *owns* their lifetime; +* use `scope:perform(cmd:run_op())`, `scope:perform(cmd:shutdown_op())`, and so on; +* avoid keeping `Command` instances beyond the lifetime of their owning scope. + +--- + +## Summary of typical patterns + +### Fire-and-wait with captured output + +```lua +fibers.run(function(scope) + local exec = require 'fibers.exec' + + local cmd = exec.command("uname", "-a") + local out, status, code, signal, err = + scope:perform(cmd:output_op()) + + if status == "exited" and code == 0 then + print(out) + else + print("uname failed:", status, code or signal, err) + end +end) +``` + +### Long-lived worker terminated with the scope + +```lua +fibers.run(function(scope) + local exec = require 'fibers.exec' + + local cmd = exec.command("some-daemon", "--foreground", { + stdout = "inherit", + stderr = "inherit", + }) + + -- Run worker in a child scope. + local status, err = Scope.run(function(child_scope) + -- Wait until the worker dies or the scope is cancelled. + local st, code, sig, cerr = + child_scope:perform(cmd:run_op()) + return st, code, sig, cerr + end) + + print("worker finished:", status, err) +end) +``` + +Here, even if the outer `scope` fails due to some other fiber error, the `Command`’s defer handler will shut down the process and release its resources. + +--- + +This concludes the overview of process execution in this library. The API is designed so that all interactions with external processes are expressed as `Op`s, and their lifetimes are governed by the same structured concurrency rules as fibers and other resources. diff --git a/README.md b/README.md index 99a6f5a..345e52b 100644 --- a/README.md +++ b/README.md @@ -1,149 +1,130 @@ -# lua-fibers +# fibers -Lightweight Go-like concurrency and non-blocking IO for Lua (5.1-5.4) and LuaJIT. +`fibers` is a small library for running many concurrent tasks in one Lua process, with structured lifetimes, cancellable operations, and integrated I/O and subprocess handling. -```lua -local function fibonacci(c, quit) - local x, y = 0, 1 - local done = false - repeat - perform( - choice( - c:put_op(x):wrap(function(value) - x, y = y, x+y - end), - quit:get_op():wrap(function(value) - print("quit") - done = true - end) - ) - ) - until done -end - -fiber.spawn(function() - local c = channel.new() - local quit = channel.new() - fiber.spawn(function() - for i=1, 10 do - print(c:get()) - end - quit:put(0) - end) - fibonacci(c, quit) - fiber.stop() -end) - -fiber.main() -``` - -Ported from the Snabb Project's [`fibers`](https://github.com/snabbco/snabb/tree/master/src/lib/fibers) and [`streams`](https://github.com/snabbco/snabb/tree/master/src/lib/stream) libraries, written by -Andy Wingo as an implementation of Reppy et al's Concurrent ML(CML), and a fibers-based reimplementation of Lua's streams enabling smooth non-blocking access to files and sockets. +It is aimed at “do a lot of things at once, shut them down cleanly, and know what failed”. -Inspired by Andy's [blog post](https://wingolog.org/archives/2018/05/16/lightweight-concurrency-in-lua) introducing fibers on Lua +--- -## Usage +## Examples -You can find examples in the `/examples` directory. +### Run several tasks and fail fast on error -Much of the (excellent) documentation from the [Guile manual on -fibers](https://github.com/wingo/fibers/wiki/Manual) is directly relevant here, -with the following points to bear in mind: - - Guile's implementation runs X fibers across Y cores, with one work stealing - scheduler per core. The Lua port is single threaded, running in a single Lua - process. In the future we may well implement true parallelism perhaps using - a Lanes/Lindas approach - -## Installation - -This is a pure Lua (with FFI) module with the following dependencies: - - lua-posix (for micro/nano timing and sleeping options, for forking and - other syscall operations) - - libffi and lua-cffi (if not using LuaJIT) - - lua-bit32 (if not using LuaJIT) - -These dependencies will be installed in a VScode devcontainer automatically. To install manually follow the following steps: - -1. Copy the `fibers` directory somewhere lua can find it -1. Choose between Lua and LuaJIT: - 1. If using LuaJIT: - 1. Install LuaJIT (tested with 2.1.0-beta 3) according to your platform instructions - 1. If using Lua: - 1. Install Lua (5.1 to 5.4) according to your platform instructions - 1. bit32 with `luarocks install bit32` - 1. Install `libffi` - 1. Install `cffi-lua` with `luarocks install cffi` -1. Install dependencies: - 1. luaposix with `luarocks install luaposix` +```lua +local fibers = require 'fibers' +local sleep = require 'fibers.sleep' + +fibers.run(function(scope) + -- Start two child tasks under the main scope. + fibers.spawn(function(s) + sleep.sleep(0.5) + print("first: finished ok") + end) + + fibers.spawn(function(s) + sleep.sleep(0.2) + error("second: something went wrong") + end) + + -- Block until the main scope’s child scope has finished. + local status, err = fibers.run_scope(function(child) + -- Do more work here if you like. + end) + + print("main scope status:", status, err) +end) +``` -### Installation with LuaJIT on OpenWRT +If any child fails, its scope records the error, cancels its siblings and runs defers to clean up resources. The top-level `fibers.run` then re-raises the primary failure. -This is the simplest set up for running on OpenWRT. +--- -`opkg update; opkg install luajit; opkg install luaposix` +### Use channels and timeouts with the event algebra -Note that `luaposix` should be installed with `opkg` and not with `luarocks` in this context. +```lua +local fibers = require 'fibers' +local chan = require 'fibers.channel' +local sleep = require 'fibers.sleep' + +fibers.run(function(scope) + local c = chan.new() + + -- Producer + fibers.spawn(function(s) + sleep.sleep(0.1) + c:put("hello") + end) + + -- Consumer with timeout: either get a value, or time out after 1s. + local function read_with_timeout() + local read_op = c:get_op() + local timeout_op = sleep.sleep_op(1.0) + + -- Race the two operations. + local which, value = fibers.named_choice{ + data = read_op, + timeout = timeout_op, + } + + if which == "data" then + return true, value + else + return false, "timed out" + end + end + + local ok, value_or_err = read_with_timeout() + print("result:", ok, value_or_err) +end) +``` -That's it! +Here the channel read and the timer are both first-class operations. They participate in `choice`, support cancellation from their scope, and can be composed in the same way as other primitives. -## Why Fibers? +--- -There are many possible advantages of fibers, in comparison to other async models such as callbacks, async/await, and promises. This is especially in highly complex programs with lots of semi-independent code doing async IO (such as in-process microservices). +### Run a subprocess bound to a scope -| | **Callbacks** | **Promises** | **Async/Await** | **Fibers** | -|---|---|---|---|---| -| **Readability** | Less readable due to "callback hell", a situation where callbacks are nested within callbacks, making the code hard to read and debug. | More readable than callbacks, but still requires then-catch chains for error handling, which can become cumbersome. | Most readable, as it makes asynchronous code look synchronous, improving comprehension and maintainability. | Very readable, since fibers can be used to write asynchronous code in a synchronous style without the need for callbacks or promises. | -| **Stack Traces** | Less clear, as each callback function creates a new stack frame, so errors can be hard to trace back to their origin. | Better than callbacks, but still might have challenges because Promises swallow errors if not handled correctly. | Good, because async/await allows to use traditional try-catch error handling which provides clear stack traces. | Best, because fibers maintain their own stack, providing a clean and comprehensive trace. | -| [**'Coloured Function' Problem**](https://journal.stuffwithstuff.com/2015/02/01/what-color-is-your-function/) | Highly affected. If a function is asynchronous (uses callbacks), all of its callers must be too. | Less affected, but still present to some extent. | Still present but significantly reduced as compared to callbacks and promises. | Not affected. Fibers can pause and resume execution, meaning asynchronous functions can be called as if they were synchronous, without requiring callers to be async. | -| **Debugging** | Difficult, because the asynchronous nature of callbacks can make it hard to step through the code or maintain a consistent state for inspection. | Easier than callbacks, but may still be challenging due to the chaining of promises. | Easier, because async/await can be stepped through like synchronous code. | Easiest, because fibers allow you to write code that's structurally synchronous and therefore easier to debug. | -| **Performance** | Callbacks are generally faster as there is no extra abstraction layer. But the complexity may increase with the number of operations. | Promises have extra abstraction which may have some performance impact. | Similar performance to promises as it's built on top of them. | Fibers may introduce some performance overhead as each fiber has its own stack, but this is small and outweighed by benefits in terms of readability, error handling, and simplicity. | +```lua +local fibers = require 'fibers' +local exec = require 'fibers.exec' + +fibers.run(function(scope) + local cmd = exec.command{ + "ls", "-l", + stdout = "pipe", + } + + local out, status, code, signal, err = + fibers.perform(cmd:output_op()) + + if status == "exited" and code == 0 then + print(out) + else + print("command failed:", status, code, signal, err) + end +end) +``` +The `Command` is attached to the current scope. On scope exit it is given a grace period to shut down, then killed if still running, and any associated streams are closed. -## Beyond Go's concurrency +--- -This library implements a simple version of Concurrent ML(CML), and provides primitives very similar to those offered by Go. However, while both Go and CML offer powerful models for concurrent programming, the flexibility and expressibility of Concurrent ML's first-class synchronisable events (here called 'operations') provide more advanced mechanisms to handle complex synchronisation scenarios. +## Concepts in brief -| | Go | Concurrent ML (CML) | -|---|---|---| -| Basic Mechanism | Uses goroutines and channels as the primary concurrency mechanisms. | Uses threads and synchronous message-passing channels, but also introduces the concept of 'synchronisable events'. | -| Choice | Provides non-deterministic choice over multiple channel operations using the `select` statement. However, it can only work with channels and the options must be statically declared at compile-time. | Supports non-deterministic choice among a dynamic set of 'synchronisable events' that can be constructed at runtime. These events can represent a wider variety of actions beyond just message-passing. | -| Timeouts | Achievable with `select` and timer channels. However, the syntax can be more verbose. | Timeouts are easily created as an event and composed with other events using the choice combinator. | -| User-Defined Concurrency Primitives | Limited. The primary concurrency primitive is the channel, and `select` can't be easily extended by users. | Highly flexible. CML's first-class events and combinators allow users to build their own high-level concurrency primitives (like barriers, semaphores, or read/write locks). These user-defined primitives can be composed and manipulated just like built-in ones. | -| Overall Flexibility | Go's model is relatively simple and straightforward, but may not have the necessary flexibility for more complex synchronisation patterns. | CML's model is extremely flexible, allowing for the expression of complex synchronisation patterns in a direct and elegant manner. | +A **fiber** is a lightweight task scheduled by the runtime. `fibers.run` starts the scheduler and a root supervision scope; `fibers.spawn` creates more fibers under the current scope. -## Progress +An **operation (`Op`)** represents something that may block: reading from a channel, waiting for a timeout, waiting for a process to exit, and so on. Operations can be composed with `choice`, guarded, bracketed with acquire/release logic, or wrapped to add behaviour. They are not tied to a particular task until performed. -All of the Snabb 'fibers' modules the following have so far been ported and -tested. All of Snabb's 'stream' module has also been ported, which can do -non-blocking reads and writes from file descriptors using familiar `line` -and `all` approaches. +A **scope** is a supervision domain with a tree structure and fail-fast semantics. Each fiber runs within some scope. When a scope fails or is cancelled, child scopes are cancelled and their work is unwound using registered defers. Scopes expose operations to wait for completion or cancellation, and wrappers to run other operations under their cancellation policy. -We use the `cffi` module to port Wingo's `luajit` C ffi based -buffers implementation in a way that will work across multiple architectures, as -Lua versions of these buffers would be inefficient and lead to allocation and -garbage collection without a substantial investment of time. +The **I/O layer** wraps non-blocking file descriptors in buffered `Stream` objects, integrates readiness with the poller (epoll or poll/select), and provides helpers for files, pipes and UNIX sockets. The **exec layer** builds on this to start subprocesses with configurable stdio, expose their lifetime as an `Op`, and ensure they are shut down with their owning scope. -## Structured concurrency +The intent is that “something might block” is always expressed as an `Op`, and “something might live for a while” is always owned by a scope. The runtime, poller and backends supply the mechanics; application code mostly works in terms of scopes, operations and normal Lua functions. -While it's fun spinning off fibers/goroutines like popcorn, it comes with a [cost](https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/). Structured concurrency is a possible enhancement, basically the idea that no fiber/goroutine should outlive its parent: -https://about.sourcegraph.com/blog/building-conc-better-structured-concurrency-for-go +--- +## Requirements and installation -## Fibers module map +The library targets Lua 5.1–5.4 and LuaJIT on POSIX-like systems. It prefers FFI-based backends (epoll, pidfd) where available, and falls back to luaposix-based poll/select and SIGCHLD backends otherwise. -```mermaid -graph TD; - timer.lua-->sched.lua; - sched.lua-->fiber.lua; - fiber.lua-->op.lua; - fiber.lua-->sleep.lua; - op.lua-->sleep.lua; - epoll.lua-->file.lua; - fiber.lua-->file.lua; - op.lua-->file.lua; - op.lua-->cond.lua; - op.lua-->channel.lua; - fiber.lua-->queue.lua; - op.lua-->queue.lua; - channel.lua-->queue.lua; -``` +Add the repository to your `package.path` so that modules such as `fibers`, `fibers.channel`, `fibers.sleep` and `fibers.io.file` can be `require`d. diff --git a/STRUCTURED-CONCURRENCY.md b/STRUCTURED-CONCURRENCY.md new file mode 100644 index 0000000..243b21c --- /dev/null +++ b/STRUCTURED-CONCURRENCY.md @@ -0,0 +1,394 @@ +# Structured concurrency + +This document describes how the library organises concurrent work using *scopes*, and how to use the top-level API in `fibers.lua` to manage lifetimes, failure and cancellation. + +The focus here is on the high-level entry points: + +- `fibers.run` +- `fibers.spawn` +- `fibers.run_scope` +- `fibers.scope_op` +- `fibers.current_scope` +- `fibers.perform` (for running operations under the current scope) + +Lower-level details of the scheduler and event algebra are covered elsewhere. + +--- + +## 1. Overview + +The library uses *structured concurrency*: + +- Every fiber runs inside a *scope*. +- Scopes form a tree. A scope may have child scopes. +- Failures are tracked per scope and cause *fail-fast* cancellation of that scope and its children. +- Resources (streams, processes, etc.) are attached to scopes and cleaned up via defers when a scope finishes. + +A useful way to think about this is: + +> A scope is a supervision context. It owns a set of fibers and resources, and it finishes only when all of them have finished or been cancelled. + +The top-level `fibers` module provides a convenient interface to this model. + +--- + +## 2. Top-level API + +### 2.1 `fibers.run(main_fn, ...)` + +```lua +local fibers = require 'fibers' + +fibers.run(function(scope, ...) + -- scope is the top-level scope for this run +end) +```` + +* Creates the scheduler and a root supervision scope. +* Runs `main_fn(scope, ...)` inside a child scope of that root. +* Drives the scheduler until `main_fn`’s scope completes. +* On success, returns the values returned by `main_fn`. +* If the main scope fails or is cancelled, re-raises the primary error / reason in the calling thread. + +`fibers.run` must be called from outside any fiber. + +### 2.2 `fibers.spawn(fn, ...)` + +```lua +fibers.run(function(scope) + fibers.spawn(function(child_scope) + -- child_scope is the same scope as scope here + end) +end) +``` + +* Spawns a new fiber *under the current scope*. +* The function is called as `fn(scope, ...)`, where `scope` is the current scope at the point of the call. +* Returns immediately; there is no handle. Lifetime is managed via the scope. + +This is the primary way to introduce concurrency under the current scope. + +### 2.3 `fibers.run_scope(body_fn, ...)` + +```lua +local status, err, result1, result2 = fibers.run_scope(function(child_scope, arg) + -- child_scope is a new child of the current scope + return "ok:" .. arg, 42 +end, "value") +``` + +`fibers.run_scope` is a re-export of `Scope.run`: + +* Must be called from inside a fiber. +* Creates a new *child scope* of the current scope. +* Spawns a fiber in that child scope to run `body_fn(child_scope, ...)`. +* Waits until the child scope reaches a terminal state. +* Returns: + + ```lua + status :: "ok" | "failed" | "cancelled" + err :: primary error or cancellation reason (nil when status == "ok") + ... :: results from body_fn (only when status == "ok") + ``` + +This gives a way to treat a block of concurrent work as a value-returning operation, with explicit success/failure information. + +### 2.4 `fibers.scope_op(build_op)` + +```lua +local fibers = require 'fibers' + +local scope_block_op = fibers.scope_op(function(child_scope) + -- build and return an Op that uses child_scope +end) +``` + +`fibers.scope_op` is a re-export of `Scope.with_op`: + +* Creates an `Op` that, when performed, runs a child scope as part of the operation. +* While the `Op` is running, that child scope is the current scope. +* When the `Op` completes or is aborted, the child scope is cancelled (if still running) and joined. + +This allows “a block of structured concurrent work” to participate directly in the event algebra (for example in a `choice`). + +### 2.5 `fibers.current_scope()` + +```lua +local scope = fibers.current_scope() +``` + +Returns the current scope: + +* Inside a fiber: the scope associated with that fiber (or the root if none is set). +* Outside a fiber: the current global scope (used mainly by the runtime). + +In normal use you mostly receive scopes as parameters to `fibers.run`, `fibers.spawn`, and `fibers.run_scope`. `fibers.current_scope()` is useful when you need to access the scope without threading it through arguments. + +### 2.6 `fibers.perform(op)` + +```lua +local fibers = require 'fibers' +local sleep = require 'fibers.sleep' + +fibers.run(function(scope) + fibers.perform(sleep.sleep_op(0.5)) +end) +``` + +`fibers.perform` executes an `Op` in the current fiber, observing the current scope’s cancellation rules. Conceptually, it is equivalent to calling `scope:perform(op)` on the current scope. + +Use `fibers.perform` for most operation execution. + +--- + +## 3. Scope lifecycle and failure semantics + +Each scope has a status: + +* `"running"` – initial state. +* `"ok"` – scope completed successfully. +* `"failed"` – a fiber in the scope raised an error, or a defer handler failed. +* `"cancelled"` – scope was cancelled explicitly or by a parent’s failure. + +Internally, a scope also tracks: + +* a primary error or cancellation reason (`_error`), and +* any additional failures in a list (`_failures`). + +### 3.1 How failures are recorded + +If a fiber running in a scope raises a Lua error: + +* The scope records a failure (if it is still `"running"`), sets its status to `"failed"`, and stores the first error as the primary error. +* The scope then **propagates cancellation** to all child scopes. +* Further errors in the same scope are appended to the failures list. + +If the scope is already `"failed"` or `"cancelled"`, new errors are just recorded in the list. + +### 3.2 How cancellation works + +When a scope is cancelled: + +* The scope status becomes `"cancelled"` (unless it is already `"ok"`). +* A cancellation reason is recorded (if none is present). +* Cancellation is propagated to child scopes. +* Any operations run under the scope via `fibers.perform` or `scope:perform` will complete with a cancellation error. + +Cancellation can arise from: + +* A direct call to `scope:cancel(reason)`. +* A failure in that scope or any ancestor scope. +* Aborting a scoped `Op` built with `fibers.scope_op`. + +### 3.3 Observing scope status + +Scopes passed into your functions support: + +```lua +local status, err = scope:status() +local additional = scope:failures() +local parent = scope:parent() +local children = scope:children() +``` + +These methods are mainly useful for diagnostics or building higher-level abstractions; most user code interacts via `fibers.run_scope` and `fibers.perform`. + +--- + +## 4. Resource management with defers + +Each scope maintains a LIFO list of deferred handlers, registered with: + +```lua +scope:defer(function(s) + -- cleanup work; s is the same scope +end) +``` + +Defers run when the scope transitions from `"running"` to a terminal state (`"ok"`, `"failed"`, `"cancelled"`): + +* They run in reverse registration order (LIFO). +* If a defer raises an error: + + * If the scope was `"ok"`, it becomes `"failed"` and the defer’s error becomes the primary error. + * Otherwise the error is added to the failures list. + +A typical pattern is to attach resources to the current scope: + +```lua +local fibers = require 'fibers' +local file = require 'fibers.io.file' + +fibers.run(function(scope) + local f, err = file.open("output.log", "w") + if not f then error(err) end + + scope:defer(function() + local ok, cerr = f:close() + if not ok then + error(cerr or "close failed") + end + end) + + fibers.perform(f:write_op("hello\n")) +end) +``` + +Many library components, such as process `Command` objects, register their own defers against the owning scope so that processes and pipes are cleaned up automatically when the scope ends. + +--- + +## 5. Cancellation and operations + +Operations (`Op`s) integrate with scopes through `fibers.perform` and, more directly, `scope:perform` / `scope:sync`: + +* Before running an operation, the scope checks whether it is still `"running"`. +* While the operation is pending, a *cancellation operation* for the scope competes with the main operation in a `choice`. +* After the operation completes, the scope status is checked again; if the scope has failed or been cancelled, the call is treated as failed. + +The common interface is: + +```lua +local ok, result_or_err = scope:sync(op) +-- or +local result = scope:perform(op) -- raises on failure/cancellation +-- or +local result = fibers.perform(op) -- uses the current scope +``` + +This means: + +* Cancellations propagate predictably: when a parent scope fails, in-flight operations in child scopes will start to complete with cancellation errors. +* Code performing operations does not need to check the scope status manually; failure and cancellation are surfaced as return values or errors in a uniform way. + +--- + +## 6. Running structured sub-tasks with `fibers.run_scope` + +`fibers.run_scope` is useful when a piece of code needs to run a block of concurrent work and *decide what to do based on whether it succeeded, failed or was cancelled*. + +Example: run a set of workers and treat failure as data rather than an exception at the top level. + +```lua +local fibers = require 'fibers' +local sleep = require 'fibers.sleep' + +local function run_workers(parent_scope, n) + return fibers.run_scope(function(scope) + for i = 1, n do + fibers.spawn(function(child_scope, idx) + -- Each worker has the same parent child-scope. + fibers.perform(sleep.sleep_op(0.1 * idx)) + if idx == 3 then + error("worker " .. idx .. " failed") + end + end, i) + end + end) +end + +fibers.run(function(scope) + local status, err = run_workers(scope, 5) + + if status == "ok" then + print("all workers completed successfully") + elseif status == "failed" then + print("workers failed with:", err) + elseif status == "cancelled" then + print("workers were cancelled:", err) + end +end) +``` + +In this example: + +* All workers share the same sub-scope created by `run_scope`. +* When worker 3 fails, that sub-scope becomes `"failed"` and cancels the others. +* `run_scope` returns `"failed", "worker 3 failed"` to the caller, which can handle it explicitly. +* The top-level `fibers.run` still sees the parent scope as `"ok"` because the failure was contained within the sub-scope. + +--- + +## 7. Using scopes inside the event algebra with `fibers.scope_op` + +Sometimes it is useful to treat “run this block of structured work” itself as an `Op`, for example to race it against a timeout. + +`fibers.scope_op` supports this pattern: + +```lua +local fibers = require 'fibers' +local op = fibers -- Op combinators re-exported here +local sleep = require 'fibers.sleep' + +local function timed_task_op(timeout_s) + return op.race( + -- Arm 1: run some work in its own scope + fibers.scope_op(function(scope) + return sleep.sleep_op(2.0) -- placeholder for real work + end), + + -- Arm 2: timeout + sleep.sleep_op(timeout_s) + ) +end + +fibers.run(function(scope) + -- Race: either the scoped work completes or the timeout fires first. + local which = fibers.perform(timed_task_op(0.5)) + print("winner arm:", which) +end) +``` + +Key points: + +* `scope_op(build_op)`: + + * Captures the *current* scope as the parent. + * When performed, creates a new child scope. + * Temporarily sets the child scope as the current scope. + * Runs the `Op` returned by `build_op(child_scope)` within that scope. + * On completion or abort, cancels the child scope if still running and waits for it to finish. + +* Because the child scope is integrated into the event algebra, structured work can be combined with other events (timeouts, channel operations, process waits) using `choice`, `race`, and related combinators. + +--- + +## 8. Interaction with other modules + +Structured concurrency underpins the rest of the library: + +* **Channels and other primitives** expose operations (`*_op`) that you run using `fibers.perform`. The current scope’s cancellation rules apply to all of them. +* **I/O** (`fibers.io.stream`, `fibers.io.file`, `fibers.io.socket`) creates streams and sockets that are typically tied to the lifecycle of a scope via `scope:defer`. +* **Process execution** (`fibers.exec`) registers a defer on the owning scope to ensure processes are shut down and associated streams are closed when the scope exits. + +As a result, an application built around scopes can use “scope ends” as the primary signal for cleaning up everything associated with that logical unit of work. + +--- + +## 9. Unscoped errors + +All normal user code should run in scopes created by `fibers.run`, `fibers.spawn`, and `fibers.run_scope`. Internally, the runtime may create fibers that are not initially associated with a scope. Errors from those fibers are attributed to the root scope by default. + +The behaviour for *unscoped* fiber errors can be customised via: + +```lua +fibers.set_unscoped_error_handler(function(fib, err) + -- fib is the runtime fiber object + -- err is the error value +end) +``` + +In most applications this is not required; errors in user code should normally be handled via scope failures and the return values from `fibers.run_scope` or the exception from `fibers.run`. + +--- + +## 10. Summary + +* Use `fibers.run` once at the top level to establish a supervising scope and scheduler. +* Use `fibers.spawn` to start fibers under the current scope. +* Use `fibers.run_scope` when you want a sub-task with its own scope and a `(status, err, ...)` result. +* Use `fibers.scope_op` when you need a scoped block to participate directly in `choice` and other event combinators. +* Use `scope:defer` to attach resource cleanup to the scope lifetime. +* Use `fibers.perform` to run operations so that cancellation and failure follow the scope tree. + +This provides a disciplined way to structure concurrent programs so that lifetimes, failures and cleanup are explicit, bounded, and predictable. diff --git a/examples/04-scope-defers.lua b/examples/04-scope-defers.lua new file mode 100644 index 0000000..147cb45 --- /dev/null +++ b/examples/04-scope-defers.lua @@ -0,0 +1,36 @@ +package.path = "../src/?.lua;" .. package.path + +local fibers = require 'fibers' +local sleep = require 'fibers.sleep' + +fibers.run(function() + -- worker(s) runs inside a fresh child scope s. + local function worker() + local s = fibers.current_scope() + s:defer(function() + print("defer 1 (outer)") + end) + + s:defer(function() + print("defer 2 (inner)") + -- This error is recorded as an additional failure. + error("defer 2 failed") + end) + + print("worker body starting") + sleep.sleep(0.1) + print("worker body raising error") + error("worker body failed") + end + + local status, err = fibers.run_scope(worker) + + print("worker scope status:", status) + print("worker scope primary error:", err) + + local extra = fibers.current_scope():failures() + print("worker scope extra failures:", #extra) + for i, e in ipairs(extra) do + print((" [%d] %s"):format(i, tostring(e))) + end +end) diff --git a/src/fibers.lua b/src/fibers.lua index 8442d7f..687f76d 100644 --- a/src/fibers.lua +++ b/src/fibers.lua @@ -43,7 +43,7 @@ local function run(main_fn, ...) local root = Scope.root() local args = pack(...) - -- Outcome container populated by the child fibre. + -- Outcome container populated by the child fiber. local outcome = { status = nil, -- "ok" | "failed" | "cancelled" err = nil, -- primary error / reason From 9746eeda6b88bd04ff401fa339cfc78f5fb4a2c2 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Mon, 8 Dec 2025 19:42:48 +0000 Subject: [PATCH 077/138] removes extraneous debug traceback wrap from coxpcall.pcall --- src/coxpcall.lua | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/coxpcall.lua b/src/coxpcall.lua index 466d817..bd85b13 100644 --- a/src/coxpcall.lua +++ b/src/coxpcall.lua @@ -36,10 +36,24 @@ end local running = coroutine.running local coromap = setmetatable({}, { __mode = "k" }) +local function id(trace) + return trace +end + function handleReturnValue(err, co, status, ...) if not status then - return false, err(debug.traceback(co, (...)), ...) + -- Error path from coroutine.resume(co, ...) + if err == id then + -- pcall semantics: propagate the original error object unchanged + -- coroutine.resume returns (false, errmsg, ...), so just + -- pass those “...” through as pcall would. + return false, ... + else + -- xpcall semantics: run the error handler on a traceback + return false, err(debug.traceback(co, (...)), ...) + end end + if coroutine.status(co) == 'suspended' then return performResume(err, co, coroutine.yield(...)) else @@ -51,10 +65,6 @@ function performResume(err, co, ...) return handleReturnValue(err, co, coroutine.resume(co, ...)) end -local function id(trace) - return trace -end - local function coxpcall(f, err, ...) local current = running() if not current then From e024fdc5d168a20c3054abcce0d9ec70f23f50a1 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Mon, 8 Dec 2025 19:47:46 +0000 Subject: [PATCH 078/138] updates shapes of fibers.spawn() and fibers.run_scope()/scope.run(), docs, examples and tests --- DESIGN-NOTES.md | 9 +- EVENTS-AND-OPS.md | 2 +- EXECUTION.md | 107 +++++++++++------- README.md | 29 +++-- STRUCTURED-CONCURRENCY.md | 53 +++++---- ...ng-a-channel-receive-against-a-timeout.lua | 6 +- examples/03-child-scopes-and-fail-fast.lua | 6 +- examples/04-scope-defers.lua | 8 +- examples/06-cancel-subprocess.lua | 10 +- src/fibers.lua | 6 +- src/fibers/scope.lua | 37 +++--- tests/test_io-file.lua | 4 +- tests/test_io-stream.lua | 2 +- tests/test_scope.lua | 10 +- 14 files changed, 175 insertions(+), 114 deletions(-) diff --git a/DESIGN-NOTES.md b/DESIGN-NOTES.md index 2a36324..24dc0ff 100644 --- a/DESIGN-NOTES.md +++ b/DESIGN-NOTES.md @@ -127,7 +127,7 @@ Scopes (`fibers.scope`) represent supervision domains and form a tree: - a LIFO stack of defers to run on exit; - a cancellation condition and a join condition; - a status: `"running" | "ok" | "failed" | "cancelled"`; - - a primary error/cancellation reason plus a list of additional failures. + - a primary error or cancellation reason, plus a list of additional failures from deferred cleanup handlers. Scopes are obtained via: @@ -149,7 +149,8 @@ Scopes implement fail-fast supervision: - if any fiber in a scope raises an uncaught error: - the scope status becomes `"failed"` (if it was `"running"`); - - the primary error is recorded (later failures go into the `_failures` list); + - the primary error is recorded; + - further uncaught errors from sibling fibers are treated as cancellation noise rather than changing the primary error; - cancellation is propagated to all child scopes. Cancellation is observable as an event: @@ -163,8 +164,8 @@ When a scope exits: 2. if still `"running"`, status is updated to `"ok"`; 3. defers are run in LIFO order; 4. any errors in defers either: - - turn `"ok"` into `"failed"` and record a primary error; or - - are appended to `_failures` if the scope was already failed/cancelled; + - if the scope would otherwise have been `"ok"`, turn it into `"failed"` and record the first defer error as the primary error; subsequent defer errors (if any) are recorded separately; or + - if the scope is already `"failed"` or `"cancelled"`, are recorded in the scope’s _defer_failures list without changing the primary error;5. the join condition is signalled, making `join_op` ready. 5. the join condition is signalled, making `join_op` ready. #### Ops under scopes diff --git a/EVENTS-AND-OPS.md b/EVENTS-AND-OPS.md index a09aed9..16cdd5e 100644 --- a/EVENTS-AND-OPS.md +++ b/EVENTS-AND-OPS.md @@ -51,7 +51,7 @@ local fibers = require "fibers" fibers.run(function(scope) local ch = require "fibers.channel".new() - fibers.spawn(function(child_scope) + fibers.spawn(function() ch:put("hello from child") -- uses channel’s own perform() end) diff --git a/EXECUTION.md b/EXECUTION.md index b6fe6ae..1dc15d2 100644 --- a/EXECUTION.md +++ b/EXECUTION.md @@ -4,33 +4,42 @@ This document describes how to start and manage external processes using this li It focuses on usage from the top-level `fibers.lua` entry points: -- `fibers.run` – to initialise the scheduler and root scope. -- `fibers.spawn` – to start fibers inside the current scope. +- `fibers.run` – initialises the scheduler and root scope. +- `fibers.spawn(fn, ...)` – starts a new fiber in the *current* scope. The process execution API itself is provided by `fibers.exec`. +Typical imports: + +```lua +local fibers = require 'fibers' +local exec = require 'fibers.exec' +```` + --- ## Overview The execution subsystem provides: -- A `Command` abstraction for a single external process. -- Structured lifetime management: each `Command` is owned by the scope in which it is created. -- Configurable stdin/stdout/stderr: - - inherit from the parent process - - connect to `/dev/null` - - pipe via `Stream` - - reuse another stream (including `stdout` for `stderr`) -- Operations (`Op`s) for: - - waiting for process completion - - graceful shutdown with a timeout and forced kill - - capturing stdout (and optionally stderr) as a string +* A `Command` abstraction for a single external process. +* Structured lifetime management: each `Command` is owned by the scope in which it is created. +* Configurable stdin/stdout/stderr: + + * inherit from the parent process + * connect to `/dev/null` + * pipe via `Stream` + * reuse another stream (including `stdout` for `stderr`) +* Operations (`Op`s) for: + + * waiting for process completion + * graceful shutdown with a timeout and forced kill + * capturing stdout (and optionally stderr) as a string The underlying implementation uses platform-specific backends: -- Linux `pidfd` when available. -- A portable `SIGCHLD` + self-pipe backend otherwise. +* Linux `pidfd` when available. +* A portable `SIGCHLD` + self-pipe backend otherwise. These details are hidden behind the `fibers.exec` interface. @@ -54,13 +63,13 @@ fibers.run(function(scope) local out, status, code_or_sig, err = scope:perform(cmd:output_op()) - if status == "ok" or status == "exited" and code_or_sig == 0 then + if status == "ok" or (status == "exited" and code_or_sig == 0) then print("ls output:\n" .. out) else print("ls failed:", status, code_or_sig, err) end end) -```` +``` Key points: @@ -180,7 +189,7 @@ The status is one of: * `"running"` – process has been started and is still running. * `"exited"` – process exited normally. * `"signalled"` – process terminated due to a signal. -* `"failed"` – failure to start or manage the process (e.g. `exec` error). +* `"failed"` – failure to start or manage the process (for example `exec` error). For `"exited"` and `"signalled"` the second result is: @@ -189,7 +198,7 @@ For `"exited"` and `"signalled"` the second result is: For `"failed"` the second result is `nil` and the third result is an error string. -Note that the process is only started when you first use one of the operations below (`run_op`, `shutdown_op`, `output_op`, etc), or when you explicitly request a piped stream. +Note that the process is only started when you first use one of the operations below (`run_op`, `shutdown_op`, `output_op`, and so on), or when you explicitly request a piped stream. --- @@ -213,9 +222,10 @@ Behaviour: Example: streaming a child’s output line by line while it runs: ```lua -fibers.run(function(scope) - local exec = require 'fibers.exec' +local fibers = require 'fibers' +local exec = require 'fibers.exec' +fibers.run(function(scope) local cmd = exec.command{ "ping", "-n", "5", "example.org", stdout = "pipe", @@ -225,6 +235,7 @@ fibers.run(function(scope) local out_stream, err = cmd:stdout_stream() assert(out_stream, err) + -- Stream output in a child scope of the main scope. scope:spawn(function(child_scope) while true do local line, lerr = child_scope:perform(out_stream:read_line_op()) @@ -295,7 +306,7 @@ Behaviour: * `kill()` if provided; otherwise fall back to `send_signal()` again. 6. Wait for the process to complete, then return the final status. -`shutdown_op` is suitable to call during normal control flow (e.g. when you decide to stop a worker) and is also used automatically when the owning scope exits (see below). +`shutdown_op` is suitable to call during normal control flow (for example when you decide to stop a worker) and is also used automatically when the owning scope exits (see below). --- @@ -359,7 +370,7 @@ The behaviour is otherwise the same as `output_op`, but with stderr directed to If you want to send a signal yourself, use `kill`: ```lua -local ok, err = cmd:kill() -- default signal (backend-chosen) +local ok, err = cmd:kill() -- default signal (backend-chosen) local ok2, err2 = cmd:kill("TERM") -- if the backend supports named signals ``` @@ -411,9 +422,10 @@ In general: ### Fire-and-wait with captured output ```lua -fibers.run(function(scope) - local exec = require 'fibers.exec' +local fibers = require 'fibers' +local exec = require 'fibers.exec' +fibers.run(function(scope) local cmd = exec.command("uname", "-a") local out, status, code, signal, err = scope:perform(cmd:output_op()) @@ -428,24 +440,41 @@ end) ### Long-lived worker terminated with the scope +Here we run a long-lived process in its own child scope using `scope.run`. The updated `scope.run` returns: + ```lua -fibers.run(function(scope) - local exec = require 'fibers.exec' +status, err, defer_failures, ...body_results +``` + +In many cases only `status` and `err` are required. + +```lua +local fibers = require 'fibers' +local exec = require 'fibers.exec' +local scope_mod = require 'fibers.scope' - local cmd = exec.command("some-daemon", "--foreground", { +fibers.run(function(scope) + local cmd = exec.command{ + "some-daemon", "--foreground", stdout = "inherit", stderr = "inherit", - }) - - -- Run worker in a child scope. - local status, err = Scope.run(function(child_scope) - -- Wait until the worker dies or the scope is cancelled. - local st, code, sig, cerr = - child_scope:perform(cmd:run_op()) - return st, code, sig, cerr - end) + } + + -- Run worker in a child scope for clearer lifetime boundaries. + local status, err, defer_failures, child_status, code, sig, cerr = + scope_mod.run(function(child_scope) + -- Wait until the worker dies or the child scope is cancelled. + return child_scope:perform(cmd:run_op()) + end) + + print("worker scope finished:", status, err) + + -- Optional: record any errors from deferred cleanup in the worker scope. + for i, derr in ipairs(defer_failures) do + print("worker defer failure[" .. i .. "]:", derr) + end - print("worker finished:", status, err) + -- child_status/code/sig/cerr are the results from cmd:run_op(), if needed. end) ``` @@ -453,4 +482,4 @@ Here, even if the outer `scope` fails due to some other fiber error, the `Comman --- -This concludes the overview of process execution in this library. The API is designed so that all interactions with external processes are expressed as `Op`s, and their lifetimes are governed by the same structured concurrency rules as fibers and other resources. +This concludes the overview of process execution in this library. All interactions with external processes are expressed as `Op`s, and their lifetimes are governed by the same structured concurrency rules as fibers and other resources. diff --git a/README.md b/README.md index 345e52b..290f76e 100644 --- a/README.md +++ b/README.md @@ -16,27 +16,36 @@ local sleep = require 'fibers.sleep' fibers.run(function(scope) -- Start two child tasks under the main scope. - fibers.spawn(function(s) + fibers.spawn(function() sleep.sleep(0.5) print("first: finished ok") end) - fibers.spawn(function(s) + fibers.spawn(function() sleep.sleep(0.2) error("second: something went wrong") end) - -- Block until the main scope’s child scope has finished. - local status, err = fibers.run_scope(function(child) - -- Do more work here if you like. + -- Run some work in a nested child scope and observe its outcome. + local status, err, defer_errors = fibers.run_scope(function(child) + -- child is a Scope; if you want to associate work explicitly with it: + -- child:spawn(function(s) ... end) + -- + -- or just use ambient spawn: + -- fibers.spawn(function() ... end) end) - print("main scope status:", status, err) + print("child scope status:", status, err) + if #defer_errors > 0 then + print("child scope defer failures:") + for i, e in ipairs(defer_errors) do + print(" [" .. i .. "]", e) + end + end end) ``` -If any child fails, its scope records the error, cancels its siblings and runs defers to clean up resources. The top-level `fibers.run` then re-raises the primary failure. - +If any child fails, its scope records the error, cancels its siblings and runs defers to clean up resources. The top-level `fibers.run` re-raises the primary failure for the outermost run, while `fibers.run_scope` returns the status, primary error and any additional defer-time failures. --- ### Use channels and timeouts with the event algebra @@ -50,7 +59,7 @@ fibers.run(function(scope) local c = chan.new() -- Producer - fibers.spawn(function(s) + fibers.spawn(function() sleep.sleep(0.1) c:put("hello") end) @@ -111,7 +120,7 @@ The `Command` is attached to the current scope. On scope exit it is given a grac ## Concepts in brief -A **fiber** is a lightweight task scheduled by the runtime. `fibers.run` starts the scheduler and a root supervision scope; `fibers.spawn` creates more fibers under the current scope. +A **fiber** is a lightweight task scheduled by the runtime. `fibers.run` starts the scheduler and a root supervision scope; `fibers.spawn` creates more fibers under the current scope; if you need the scope explicitly you can obtain it via `fibers.current_scope()` or by using `fibers.run_scope`, which passes the child scope to its body. An **operation (`Op`)** represents something that may block: reading from a channel, waiting for a timeout, waiting for a process to exit, and so on. Operations can be composed with `choice`, guarded, bracketed with acquire/release logic, or wrapped to add behaviour. They are not tied to a particular task until performed. diff --git a/STRUCTURED-CONCURRENCY.md b/STRUCTURED-CONCURRENCY.md index 243b21c..332a7ca 100644 --- a/STRUCTURED-CONCURRENCY.md +++ b/STRUCTURED-CONCURRENCY.md @@ -56,14 +56,17 @@ end) ```lua fibers.run(function(scope) - fibers.spawn(function(child_scope) - -- child_scope is the same scope as scope here + fibers.spawn(function() + -- This runs under the same current scope as 'scope' + local this_scope = fibers.current_scope() + -- ... end) end) ``` * Spawns a new fiber *under the current scope*. -* The function is called as `fn(scope, ...)`, where `scope` is the current scope at the point of the call. +* The function is called as `fn(...)`. +* If you need the scope inside the spawned fiber, call `fibers.current_scope()`. * Returns immediately; there is no handle. Lifetime is managed via the scope. This is the primary way to introduce concurrency under the current scope. @@ -71,7 +74,7 @@ This is the primary way to introduce concurrency under the current scope. ### 2.3 `fibers.run_scope(body_fn, ...)` ```lua -local status, err, result1, result2 = fibers.run_scope(function(child_scope, arg) +local status, err, defer_failures, result1, result2 = fibers.run_scope(function(child_scope, arg) -- child_scope is a new child of the current scope return "ok:" .. arg, 42 end, "value") @@ -86,12 +89,13 @@ end, "value") * Returns: ```lua - status :: "ok" | "failed" | "cancelled" - err :: primary error or cancellation reason (nil when status == "ok") - ... :: results from body_fn (only when status == "ok") + status :: "ok" | "failed" | "cancelled" + err :: primary error or cancellation reason (nil when status == "ok") + defer_failures :: array of additional errors from deferred handlers + ... :: results from body_fn (only when status == "ok") ``` -This gives a way to treat a block of concurrent work as a value-returning operation, with explicit success/failure information. +This gives a way to treat a block of concurrent work as a value-returning operation, with explicit success/failure information. If the scope would otherwise have completed successfully, the first failing defer promotes the scope to `"failed"` and becomes the primary `err`; only subsequent defer errors are recorded in `defer_failures`. ### 2.4 `fibers.scope_op(build_op)` @@ -153,17 +157,20 @@ Each scope has a status: Internally, a scope also tracks: * a primary error or cancellation reason (`_error`), and -* any additional failures in a list (`_failures`). +* any additional errors from deferred handlers in a list (`_defer_failures`). ### 3.1 How failures are recorded If a fiber running in a scope raises a Lua error: -* The scope records a failure (if it is still `"running"`), sets its status to `"failed"`, and stores the first error as the primary error. -* The scope then **propagates cancellation** to all child scopes. -* Further errors in the same scope are appended to the failures list. +If a fiber running in a scope raises a Lua error while the scope is `"running"`: -If the scope is already `"failed"` or `"cancelled"`, new errors are just recorded in the list. +* The scope records a failure, sets its status to `"failed"`, and stores that error as the primary error (if none is recorded yet). +* The scope then propagates cancellation to all child scopes. + +Subsequent errors from other fibers in the same scope are treated as cancellation noise and are not accumulated. + +Errors raised by deferred handlers are handled separately and are described in section 4. ### 3.2 How cancellation works @@ -185,10 +192,10 @@ Cancellation can arise from: Scopes passed into your functions support: ```lua -local status, err = scope:status() -local additional = scope:failures() -local parent = scope:parent() -local children = scope:children() +local status, err = scope:status() +local defer_errors = scope:defer_failures() +local parent = scope:parent() +local children = scope:children() ``` These methods are mainly useful for diagnostics or building higher-level abstractions; most user code interacts via `fibers.run_scope` and `fibers.perform`. @@ -211,7 +218,7 @@ Defers run when the scope transitions from `"running"` to a terminal state (`"ok * If a defer raises an error: * If the scope was `"ok"`, it becomes `"failed"` and the defer’s error becomes the primary error. - * Otherwise the error is added to the failures list. + * Otherwise the error is added to the scope’s `defer_failures` list. A typical pattern is to attach resources to the current scope: @@ -273,11 +280,13 @@ Example: run a set of workers and treat failure as data rather than an exception local fibers = require 'fibers' local sleep = require 'fibers.sleep' -local function run_workers(parent_scope, n) +local function run_workers(n) return fibers.run_scope(function(scope) for i = 1, n do - fibers.spawn(function(child_scope, idx) - -- Each worker has the same parent child-scope. + fibers.spawn(function(idx) + -- Each worker runs under the same child scope created by run_scope. + local child_scope = fibers.current_scope() + fibers.perform(sleep.sleep_op(0.1 * idx)) if idx == 3 then error("worker " .. idx .. " failed") @@ -288,7 +297,7 @@ local function run_workers(parent_scope, n) end fibers.run(function(scope) - local status, err = run_workers(scope, 5) + local status, err, defer_failures = run_workers(5) if status == "ok" then print("all workers completed successfully") diff --git a/examples/02-racing-a-channel-receive-against-a-timeout.lua b/examples/02-racing-a-channel-receive-against-a-timeout.lua index 10a4351..9a4f1c7 100644 --- a/examples/02-racing-a-channel-receive-against-a-timeout.lua +++ b/examples/02-racing-a-channel-receive-against-a-timeout.lua @@ -9,7 +9,7 @@ local spawn = fibers.spawn local perform = fibers.perform local choice = fibers.choice -run(function() +local function main() -- Buffered channel with capacity 1 so a late send will not block. local c = chan.new(1) @@ -43,4 +43,6 @@ run(function() end print("[main] returning from Example 3") -end) +end + +run(main) diff --git a/examples/03-child-scopes-and-fail-fast.lua b/examples/03-child-scopes-and-fail-fast.lua index 6e1848a..9093096 100644 --- a/examples/03-child-scopes-and-fail-fast.lua +++ b/examples/03-child-scopes-and-fail-fast.lua @@ -12,7 +12,7 @@ local function main(root_scope) print("[root] starting; status:", root_scope:status()) -- Create a child scope under the root. - local status, err = run_scope(function() + local status, err, extra_errs = run_scope(function() local s = current_scope() print("[child] scope created; status:", s:status()) @@ -39,8 +39,8 @@ local function main(root_scope) -- No explicit wait is needed; run_scope handles join/defers. end) - print("[root] child scope returned; status:", status, "error:", err) - + print("[root] child scope returned; status:", status, "error:", err, "extra errors:", #extra_errs) + for _, v in ipairs(extra_errs) do print(v) end -- status will be "failed"; err will be the primary error from worker1. end diff --git a/examples/04-scope-defers.lua b/examples/04-scope-defers.lua index 147cb45..9d0f5be 100644 --- a/examples/04-scope-defers.lua +++ b/examples/04-scope-defers.lua @@ -7,6 +7,7 @@ fibers.run(function() -- worker(s) runs inside a fresh child scope s. local function worker() local s = fibers.current_scope() + s:defer(function() print("defer 1 (outer)") end) @@ -23,14 +24,13 @@ fibers.run(function() error("worker body failed") end - local status, err = fibers.run_scope(worker) + local status, err, def_errs = fibers.run_scope(worker) print("worker scope status:", status) print("worker scope primary error:", err) - local extra = fibers.current_scope():failures() - print("worker scope extra failures:", #extra) - for i, e in ipairs(extra) do + print("worker scope extra failures:", #def_errs) + for i, e in ipairs(def_errs) do print((" [%d] %s"):format(i, tostring(e))) end end) diff --git a/examples/06-cancel-subprocess.lua b/examples/06-cancel-subprocess.lua index f99d4a6..797dd46 100644 --- a/examples/06-cancel-subprocess.lua +++ b/examples/06-cancel-subprocess.lua @@ -1,5 +1,5 @@ -- Demonstrates: --- * Running an external process with fibers.exec +-- * Running an external process with fibers.io.exec -- * Capturing stdout via a pipe -- * Using boolean_choice to race process completion vs timeout -- * Cancelling a scope on timeout and letting structured @@ -28,13 +28,13 @@ local sleep_op = sleep.sleep_op -- Main entry point ---------------------------------------------------------------------- -run(function() +local function main() print("[root] starting subprocess example") -- Run the subprocess and its helper fibers inside a child scope. -- We use run_scope so that we can interpret status and reason at -- a clear supervision boundary. - local status, reason = fibers.run_scope(function() + local status, reason, _ = fibers.run_scope(function() print("[subscope] starting child process") ------------------------------------------------------------------ @@ -129,4 +129,6 @@ run(function() -------------------------------------------------------------------- print("[root] subprocess scope completed; status:", status, "reason:", reason) -end) +end + +run(main) diff --git a/src/fibers.lua b/src/fibers.lua index 687f76d..255e7cb 100644 --- a/src/fibers.lua +++ b/src/fibers.lua @@ -57,10 +57,10 @@ local function run(main_fn, ...) outcome.status = packed[1] outcome.err = packed[2] - if packed.n > 2 and outcome.status == "ok" then - local out = { n = packed.n - 2 } + if packed.n > 3 and outcome.status == "ok" then + local out = { n = packed.n - 3 } local j = 1 - for i = 3, packed.n do + for i = 4, packed.n do out[j] = packed[i] j = j + 1 end diff --git a/src/fibers/scope.lua b/src/fibers/scope.lua index c6596e8..5833036 100644 --- a/src/fibers/scope.lua +++ b/src/fibers/scope.lua @@ -25,7 +25,7 @@ end ---@field _children table # weak-key set of child scopes ---@field _status ScopeStatus ---@field _error any ----@field _failures any[] +---@field _defer_failures any[] ---@field failure_mode string # e.g. "fail_fast" ---@field _wg Waitgroup ---@field _defers fun(self: Scope)[] # LIFO defers @@ -87,7 +87,7 @@ local function new_scope(parent) _status = "running", _error = nil, - _failures = {}, + _defer_failures = {}, failure_mode = "fail_fast", _wg = waitgroup.new(), @@ -152,12 +152,13 @@ function Scope:_record_failure(err) self._status = "failed" self._error = self._error or err self:_propagate_cancel(self._error) - else - local failures = self._failures - failures[#failures + 1] = err end + -- Ignore subsequent fiber failures; we treat them as + -- cancellation noise. Defer failures are recorded in + -- the join worker logic. end + --- Create a new child scope of this scope (no body, no current() change). ---@return Scope function Scope:new_child() @@ -260,11 +261,11 @@ function Scope:status() return self._status, self._error end ---- Return a shallow copy of additional failures recorded on this scope. +--- Return a shallow copy of non-primary defer failures recorded on this scope. ---@return any[] -function Scope:failures() +function Scope:defer_failures() local out = {} - local f = self._failures or {} + local f = self._defer_failures or {} for i, v in ipairs(f) do out[i] = v end @@ -306,7 +307,7 @@ function Scope:_start_join_worker() self._status = "failed" self._error = self._error or err else - local failures = self._failures + local failures = self._defer_failures failures[#failures + 1] = err end end @@ -470,14 +471,17 @@ end --- Run a function inside a fresh child scope of the current scope. --- --- The body runs as body_fn(child_scope, ...). +--- --- Returns: ---- status :: "ok" | "failed" | "cancelled" ---- err :: primary error or cancellation reason (nil on "ok") ---- ... :: any results returned from body_fn +--- status :: "ok" | "failed" | "cancelled" +--- err :: primary error or cancellation reason (nil on "ok") +--- defer_failures :: array of non-primary errors from deferred function recorded on the child scope +--- ... :: any results returned from body_fn (only present on "ok") ---@param body_fn fun(s: Scope, ...): ... ---@param ... any ---@return ScopeStatus status ---@return any err +---@return any[] defer_failures ---@return any ... local function run(body_fn, ...) assert(runtime.current_fiber(), "scope.run must be called from inside a fiber") @@ -494,11 +498,16 @@ local function run(body_fn, ...) local status, err = op.perform_raw(child:join_op()) + -- Shallow copy of non-primary defer failures; will be {} when there are none. + local extra = child:defer_failures() + local res = child._result if res then - return status, err, unpack(res, 1, res.n) + -- status, primary_err, defer_failures, ...body results... + return status, err, extra, unpack(res, 1, res.n) else - return status, err + -- status, primary_err, defer_failures + return status, err, extra end end diff --git a/tests/test_io-file.lua b/tests/test_io-file.lua index 64d5234..93c8f5a 100644 --- a/tests/test_io-file.lua +++ b/tests/test_io-file.lua @@ -357,14 +357,14 @@ local function test_merge_lines_op() } -- Spawn writers under the top-level scope; ignore the scope argument. - fibers.spawn(function(_, w) + fibers.spawn(function(w) local _, err = w:write("line-a\n") assert(err == nil, "writer a write error: " .. tostring(err)) local ok, cerr = w:close() assert(ok, "writer a close error: " .. tostring(cerr)) end, w1) - fibers.spawn(function(_, w) + fibers.spawn(function(w) local _, err = w:write("line-b\n") assert(err == nil, "writer b write error: " .. tostring(err)) local ok, cerr = w:close() diff --git a/tests/test_io-stream.lua b/tests/test_io-stream.lua index fcefa96..a75fb75 100644 --- a/tests/test_io-stream.lua +++ b/tests/test_io-stream.lua @@ -94,7 +94,7 @@ local function test() local message = "hello, world\n" - -- Writer runs in a fibre, so the first read will block and use on_readable. + -- Writer runs in a fiber, so the first read will block and use on_readable. fibers.spawn(function() sleep.sleep(0.01) local n, err = wr:write(message) diff --git a/tests/test_scope.lua b/tests/test_scope.lua index 41833a5..24d1bd6 100644 --- a/tests/test_scope.lua +++ b/tests/test_scope.lua @@ -217,7 +217,7 @@ local function test_with_op_abort_on_choice() local outer_scope local child_scope - local st, serr, winner = scope.run(function(s) + local st, serr, _, winner = scope.run(function(s) outer_scope = s local ev_with = scope.with_op(function(child) @@ -321,7 +321,7 @@ local function test_run_success_and_failure() -- Success case: scope.run returns status ok and body results. local success_scope - local st, err, a, b = scope.run(function(s) + local st, err, _, a, b = scope.run(function(s) success_scope = s local st0, err0 = s:status() assert(st0 == "running" and err0 == nil, "inside body, status should be running") @@ -398,7 +398,7 @@ end local function test_defer_failure_marks_scope_failed() local scope_ref - local st, serr, body_res = scope.run(function(s) + local st, serr, _, body_res = scope.run(function(s) scope_ref = s s:defer(function() error("defer failure") @@ -456,7 +456,7 @@ local function test_sync_respects_cancellation() local ev = op.never() local cancelled_scope - local st, serr, ok_op, reason_op = scope.run(function(s) + local st, serr, _, ok_op, reason_op = scope.run(function(s) cancelled_scope = s s:cancel("cancel before sync") @@ -484,7 +484,7 @@ end local function test_sync_cancellation_race() local race_scope - local st, serr, ok_op, reason_op = scope.run(function(s) + local st, serr, _, ok_op, reason_op = scope.run(function(s) race_scope = s local cond = cond_mod.new() From 57f359508dc8131edc93926678c129941b2ccb22 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Mon, 8 Dec 2025 19:48:24 +0000 Subject: [PATCH 079/138] reinstates all io backends --- src/fibers/io/exec_backend.lua | 4 ++-- src/fibers/io/fd_backend.lua | 4 ++-- src/fibers/io/poller.lua | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/fibers/io/exec_backend.lua b/src/fibers/io/exec_backend.lua index 6ef23c4..0f56eb8 100644 --- a/src/fibers/io/exec_backend.lua +++ b/src/fibers/io/exec_backend.lua @@ -20,8 +20,8 @@ ---@field stderr Stream|nil local candidates = { - -- 'fibers.io.exec_backend.pidfd', -- Linux pidfd backend - -- 'fibers.io.exec_backend.sigchld', -- Portable SIGCHLD + self-pipe backend (luaposix) + 'fibers.io.exec_backend.pidfd', -- Linux pidfd backend + 'fibers.io.exec_backend.sigchld', -- Portable SIGCHLD + self-pipe backend (luaposix) 'fibers.io.exec_backend.nixio', -- Portable SIGCHLD + self-pipe backend (luaposix) } diff --git a/src/fibers/io/fd_backend.lua b/src/fibers/io/fd_backend.lua index 4d84b32..b48c1b4 100644 --- a/src/fibers/io/fd_backend.lua +++ b/src/fibers/io/fd_backend.lua @@ -8,8 +8,8 @@ ---@module 'fibers.io.fd_backend' local candidates = { - -- 'fibers.io.fd_backend.ffi', -- FFI / libc - -- 'fibers.io.fd_backend.posix', -- luaposix + 'fibers.io.fd_backend.ffi', -- FFI / libc + 'fibers.io.fd_backend.posix', -- luaposix 'fibers.io.fd_backend.nixio', -- nixio } diff --git a/src/fibers/io/poller.lua b/src/fibers/io/poller.lua index f6a14b8..27da2ef 100644 --- a/src/fibers/io/poller.lua +++ b/src/fibers/io/poller.lua @@ -5,8 +5,8 @@ -- to a pure-luaposix select/poll implementation. local candidates = { - -- 'fibers.io.poller.epoll', -- Linux + FFI/epoll - -- 'fibers.io.poller.select', -- luaposix poll/select + 'fibers.io.poller.epoll', -- Linux + FFI/epoll + 'fibers.io.poller.select', -- luaposix poll/select 'fibers.io.poller.nixio', -- nixio poll/select } From 5087d928f44b0fb1148c3861c5cbbb689d213ff9 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Mon, 8 Dec 2025 20:00:14 +0000 Subject: [PATCH 080/138] goodbye syscall --- src/fibers/utils/helper.lua | 46 ------ src/fibers/utils/syscall.lua | 281 ----------------------------------- tests/test.lua | 1 - tests/test_cond.lua | 15 +- tests/test_epoll.lua | 15 +- tests/test_io-poller.lua | 16 +- tests/test_queue.lua | 17 ++- tests/test_runtime.lua | 16 +- tests/test_stream-compat.lua | 22 --- tests/test_stream-file.lua | 240 ------------------------------ tests/test_stream-mem.lua | 35 ----- tests/test_stream-socket.lua | 42 ------ 12 files changed, 73 insertions(+), 673 deletions(-) delete mode 100644 src/fibers/utils/helper.lua delete mode 100644 src/fibers/utils/syscall.lua delete mode 100644 tests/test_stream-compat.lua delete mode 100644 tests/test_stream-file.lua delete mode 100644 tests/test_stream-mem.lua delete mode 100644 tests/test_stream-socket.lua diff --git a/src/fibers/utils/helper.lua b/src/fibers/utils/helper.lua deleted file mode 100644 index 4549ac5..0000000 --- a/src/fibers/utils/helper.lua +++ /dev/null @@ -1,46 +0,0 @@ --- Copyright Snabb --- Copyright Jangala - -local sc = require 'fibers.utils.syscall' -local ffi = sc.is_LuaJIT and require 'ffi' or require 'cffi' -ffi.type = ffi.type or type - --- Returns true if x and y are structurally similar (isomorphic). -local function equal(x, y) - if type(x) ~= type(y) then return false end - if type(x) == 'table' then - for k, v in pairs(x) do - if not equal(v, y[k]) then return false end - end - for k, _ in pairs(y) do - if x[k] == nil then return false end - end - return true - elseif ffi.type(x) == 'cdata' then - if x == y then return true end - if ffi.typeof(x) ~= ffi.typeof(y) then return false end - local size = ffi.sizeof(x) - if ffi.sizeof(y) ~= size then return false end - return sc.ffi.memcmp(x, y, size) == 0 - else - return x == y - end -end - -local function dump(o) - if type(o) == 'table' then - local s = '{ ' - for k, v in pairs(o) do - if type(k) ~= 'number' then k = '"' .. k .. '"' end - s = s .. '[' .. k .. '] = ' .. dump(v) .. ',' - end - return s .. '} ' - else - return tostring(o) - end -end - -return { - equal = equal, - dump = dump -} diff --git a/src/fibers/utils/syscall.lua b/src/fibers/utils/syscall.lua deleted file mode 100644 index d355c5f..0000000 --- a/src/fibers/utils/syscall.lua +++ /dev/null @@ -1,281 +0,0 @@ ----@diagnostic disable: inject-field --- Copyright Jangala - -local p_fcntl = require 'posix.fcntl' -local p_unistd = require 'posix.unistd' -local p_stdio = require 'posix.stdio' -local p_wait = require 'posix.sys.wait' -local p_stat = require 'posix.sys.stat' -local p_signal = require 'posix.signal' -local p_socket = require 'posix.sys.socket' -local p_errno = require 'posix.errno' -local p_time = require 'posix.time' -local p_stdlib = require 'posix.stdlib' -local bit = rawget(_G, "bit") or require 'bit32' - -local M = { ffi = {} } -- used this module format due to large number of exported functions - ---detect LuaJIT -M.is_LuaJIT = rawget(_G, "jit") and true or false - -local ffi = M.is_LuaJIT and require 'ffi' or require 'cffi' -ffi.tonumber = ffi.tonumber or tonumber -ffi.type = ffi.type or type - -------------------------------------------------------------------------------- --- Compatibility functions -table.pack = table.pack or function(...) -- luacheck: ignore -- Compatibility fallback - return { n = select("#", ...), ... } -end - - -------------------------------------------------------------------------------- --- Local functions (for efficiency) - -local band, bor, bnot, _ = bit.band, bit.bor, bit.bnot, bit.lshift - - -------------------------------------------------------------------------------- --- Syscall constants - -M.SEEK_CUR = p_unistd.SEEK_CUR -M.SEEK_END = p_unistd.SEEK_END -M.SEEK_SET = p_unistd.SEEK_SET - -M.O_ACCMODE = 3 -M.O_RDONLY = p_fcntl.O_RDONLY -M.O_WRONLY = p_fcntl.O_WRONLY -M.O_RDWR = p_fcntl.O_RDWR -M.O_CREAT = p_fcntl.O_CREAT -M.O_TRUNC = p_fcntl.O_TRUNC -M.O_APPEND = p_fcntl.O_APPEND -M.O_EXCL = p_fcntl.O_EXCL -M.O_NONBLOCK = p_fcntl.O_NONBLOCK -M.O_LARGEFILE = ffi.abi('32bit') and 32768 or 0 - -M.F_GETFL = p_fcntl.F_GETFL -M.F_SETFL = p_fcntl.F_SETFL -M.F_GETFD = p_fcntl.F_GETFD -M.F_SETFD = p_fcntl.F_SETFD -M.FD_CLOEXEC = p_fcntl.FD_CLOEXEC - -M.EAGAIN = p_errno.EAGAIN -M.EWOULDBLOCK = p_errno.EWOULDBLOCK -M.EINTR = p_errno.EINTR -M.EINPROGRESS = p_errno.EINPROGRESS -M.ESRCH = p_errno.ESRCH -M.EPIPE = p_errno.EPIPE -M.ETIMEDOUT = p_errno.ETIMEDOUT -M.ECONNRESET = p_errno.ECONNRESET -M.ECONNREFUSED = p_errno.ECONNREFUSED -M.ENETUNREACH = p_errno.ENETUNREACH -M.EHOSTUNREACH = p_errno.EHOSTUNREACH -M.EBADF = p_errno.EBADF -M.ENOENT = p_errno.ENOENT - -M.SIGKILL = p_signal.SIGKILL -M.SIGTERM = p_signal.SIGTERM - -M.S_IRUSR = p_stat.S_IRUSR -M.S_IWUSR = p_stat.S_IWUSR -M.S_IXUSR = p_stat.S_IXUSR -M.S_IRGRP = p_stat.S_IRGRP -M.S_IWGRP = p_stat.S_IWGRP -M.S_IXGRP = p_stat.S_IXGRP -M.S_IROTH = p_stat.S_IROTH -M.S_IWOTH = p_stat.S_IWOTH -M.S_IXOTH = p_stat.S_IXOTH - -M.STDIN_FILENO = p_unistd.STDIN_FILENO -M.STDOUT_FILENO = p_unistd.STDOUT_FILENO -M.STDERR_FILENO = p_unistd.STDERR_FILENO - -M.SIGPIPE = p_signal.SIGPIPE -M.SIG_IGN = p_signal.SIG_IGN -M.SIGCHLD = p_signal.SIGCHLD - -M.CLOCK_REALTIME = p_time.CLOCK_REALTIME -M.CLOCK_MONOTONIC = p_time.CLOCK_MONOTONIC - -M.AF_INET = p_socket.AF_INET -M.AF_INET6 = p_socket.AF_INET6 -M.AF_NETLINK = p_socket.AF_NETLINK -M.AF_PACKET = p_socket.AF_PACKET -M.AF_UNIX = p_socket.AF_UNIX -M.AF_UNSPEC = p_socket.AF_UNSPEC -M.SO_ERROR = p_socket.SO_ERROR -M.SOCK_DGRAM = p_socket.SOCK_DGRAM -M.SOCK_RAW = p_socket.SOCK_RAW -M.SOCK_STREAM = p_socket.SOCK_STREAM -M.SOL_SOCKET = p_socket.SOL_SOCKET -M.SOMAXCONN = p_socket.SOMAXCONN - -M.WNOHANG = p_wait.WNOHANG - -M.PR_SET_PDEATHSIG = 1 -------------------------------------------------------------------------------- --- Luafied stdlib syscalls - -function M.fcntl(fd, ...) return p_fcntl.fcntl(fd, ...) end - -function M.open(path, mode, perm) return p_fcntl.open(path, mode, perm) end - -function M.strerror(err) return p_errno.errno(err) end - -function M.stat(path) return p_stat.stat(path) end - -function M.fstat(file, ...) return p_stat.fstat(file, ...) end - -function M.signal(signum, handler) return p_signal.signal(signum, handler) end - -function M.kill(pid, options) return p_signal.kill(pid, options) end - -function M.killpg(pgid, sig) return p_signal.kill(pgid, sig) end - -function M.accept(fd) return p_socket.accept(fd) end - -function M.bind(file, sockaddr) return p_socket.bind(file, sockaddr) end - -function M.connect(fd, addr) return p_socket.connect(fd, addr) end - -function M.getpeername(sockfd) return p_socket.getpeername(sockfd) end - -function M.getsockname(sockfd) return p_socket.getsockname(sockfd) end - -function M.getsockopt(fd, level, name) return p_socket.getsockopt(fd, level, name) end - -function M.listen(fd, backlog) return p_socket.listen(fd, backlog or M.SOMAXCONN) end - -function M.socket(family, socktype, protocol) return p_socket.socket(family, socktype, protocol) end - -function M.fileno(file) return p_stdio.fileno(file) end - -function M.rename(from, to) return p_stdio.rename(from, to) end - -function M.clock_gettime(id) return p_time.clock_gettime(id) end - -function M.access(path, mode) return p_unistd.access(path, mode) end - -function M.close(fd) return p_unistd.close(fd) end - -function M.dup2(fd1, fd2) return p_unistd.dup2(fd1, fd2) end - -function M.exec(path, argt) return p_unistd.exec(path, argt) end - -function M.execp(path, argt) return p_unistd.execp(path, argt) end - -function M.execve(path, argv, _) return p_unistd.exec(path, argv) end - -function M.fork() return p_unistd.fork() end - -function M.fsync(fd) return p_unistd.fsync(fd) end - -function M.getpgrp() return p_unistd.getpgrp() end - -function M.getpid() return p_unistd.getpid() end - -function M.isatty(fd) return p_unistd.isatty(fd) end - -function M.lseek(file, offset, whence) return p_unistd.lseek(file, offset, whence) end - -function M.pipe() return p_unistd.pipe() end - -function M.read(fd, count) return p_unistd.read(fd, count) end - -function M.setpid(what, id, gid) return p_unistd.setpid(what, id, gid) end - -function M.unlink(path) return p_unistd.unlink(path) end - -function M.write(fd, buf) return p_unistd.write(fd, buf) end - -function M.wait(pid, options) return p_wait.wait(pid, options) end - -function M.exit(status) return os.exit(status) end - -function M.getenv(name) return p_stdlib.getenv(name) end - -function M.setenv(name, value, overwrite) return p_stdlib.setenv(name, value, overwrite) end - -function M._exit(status) return p_unistd._exit(status) end - -------------------------------------------------------------------------------- --- Convenience functions - -function M.set_nonblock(fd) - local flags = assert(M.fcntl(fd, M.F_GETFL)) - return assert(M.fcntl(fd, M.F_SETFL, bor(flags, M.O_NONBLOCK))) -end - -function M.set_block(fd) - local flags = assert(M.fcntl(fd, M.F_GETFL)) - return assert(M.fcntl(fd, M.F_SETFL, band(flags, bnot(M.O_NONBLOCK)))) -end - -function M.set_cloexec(fd) - local flags = assert(M.fcntl(fd, M.F_GETFD)) - return assert(M.fcntl(fd, M.F_SETFD, bor(flags, M.FD_CLOEXEC))) -end - -function M.monotime() - local time = M.clock_gettime(M.CLOCK_MONOTONIC) - return time.tv_sec + time.tv_nsec / 1e9, time.tv_sec, time.tv_nsec -end - -function M.realtime() - local time = M.clock_gettime(M.CLOCK_REALTIME) - return time.tv_sec + time.tv_nsec / 1e9, time.tv_sec, time.tv_nsec -end - -function M.floatsleep(t) - local sec = t - t % 1 - local nsec = t % 1 * 1e9 - local _, _, _, remaining = p_time.nanosleep({ tv_sec = sec, tv_nsec = nsec }) - while remaining do - p_time.nanosleep(remaining) - end -end - -------------------------------------------------------------------------------- --- FFI C structure functions (for efficiency) - -M.ffi.typeof = ffi.typeof -M.ffi.sizeof = ffi.sizeof - -ffi.cdef [[ - //ssize_t write(int fildes, const void *buf, size_t nbytes); - //ssize_t read(int fildes, void *buf, size_t nbytes); - int memcmp(const void *s1, const void *s2, size_t n); -]] - --- function M.ffi.write(fildes, buf, nbytes) --- return wrap_error(ffi.tonumber(ffi.C.write(fildes, buf, nbytes))) --- end - --- function M.ffi.read(fildes, buf, nbytes) --- return wrap_error(ffi.tonumber(ffi.C.read(fildes, buf, nbytes))) --- end - -function M.ffi.memcmp(obj1, obj2, nbytes) - return ffi.tonumber(ffi.C.memcmp(obj1, obj2, nbytes)) -end - --- -- Define syscall and pid_t --- ffi.cdef [[ --- long syscall(long number, ...); --- typedef int pid_t; --- typedef unsigned int uint; --- ]] - --- local SYS_pidfd_open = 434 -- Good for (almost) all our platforms --- if ARCH == "mips" or ARCH == "mipsel" then --- SYS_pidfd_open = 4000 + 434 -- See https://www.linux-mips.org/wiki/Syscall --- end - --- -- Function to open a pidfd --- function M.pidfd_open(pid, flags) --- pid = ffi.new("pid_t", pid) -- Explicitly cast pid to pid_t --- flags = ffi.new("uint", flags) -- Explicitly cast flgas to uint --- return wrap_error(ffi.tonumber(ffi.C.syscall(SYS_pidfd_open, pid, flags))) --- end - -return M diff --git a/tests/test.lua b/tests/test.lua index c0d7696..4cfff15 100644 --- a/tests/test.lua +++ b/tests/test.lua @@ -19,7 +19,6 @@ local modules = { { 'queue' }, { 'cond' }, { 'sleep' }, - -- { 'epoll' }, { 'waitgroup' }, -- { 'alarm' }, { 'scope' }, diff --git a/tests/test_cond.lua b/tests/test_cond.lua index 61a2997..80f0584 100644 --- a/tests/test_cond.lua +++ b/tests/test_cond.lua @@ -9,7 +9,20 @@ local runtime = require 'fibers.runtime' local sleep = require 'fibers.sleep' local time = require 'fibers.utils.time' -local equal = require 'fibers.utils.helper'.equal +local function equal(x, y) + if type(x) ~= type(y) then return false end + if type(x) == 'table' then + for k, v in pairs(x) do + if not equal(v, y[k]) then return false end + end + for k, _ in pairs(y) do + if x[k] == nil then return false end + end + return true + else + return x == y + end +end local c, log = cond.new(), {} local function record(x) table.insert(log, x) end diff --git a/tests/test_epoll.lua b/tests/test_epoll.lua index 9db90d7..2e0195a 100644 --- a/tests/test_epoll.lua +++ b/tests/test_epoll.lua @@ -8,7 +8,20 @@ local epoll = require 'fibers.io.epoll' local sc = require 'fibers.utils.syscall' local bit = rawget(_G, "bit") or require 'bit32' -local equal = require 'fibers.utils.helper'.equal +local function equal(x, y) + if type(x) ~= type(y) then return false end + if type(x) == 'table' then + for k, v in pairs(x) do + if not equal(v, y[k]) then return false end + end + for k, _ in pairs(y) do + if x[k] == nil then return false end + end + return true + else + return x == y + end +end local myepoll = assert(epoll.new()) local function poll(timeout) diff --git a/tests/test_io-poller.lua b/tests/test_io-poller.lua index 79d30d8..12fc316 100644 --- a/tests/test_io-poller.lua +++ b/tests/test_io-poller.lua @@ -7,7 +7,21 @@ package.path = "../src/?.lua;" .. package.path local file_stream = require 'fibers.io.file' local runtime = require 'fibers.runtime' -local equal = require 'fibers.utils.helper'.equal +local function equal(x, y) + if type(x) ~= type(y) then return false end + if type(x) == 'table' then + for k, v in pairs(x) do + if not equal(v, y[k]) then return false end + end + for k, _ in pairs(y) do + if x[k] == nil then return false end + end + return true + else + return x == y + end +end + local log = {} local function record(x) table.insert(log, x) end diff --git a/tests/test_queue.lua b/tests/test_queue.lua index 69158ae..beec056 100644 --- a/tests/test_queue.lua +++ b/tests/test_queue.lua @@ -6,8 +6,21 @@ package.path = "../src/?.lua;" .. package.path local queue = require 'fibers.queue' local runtime = require 'fibers.runtime' -local helper = require 'fibers.utils.helper' -local equal = helper.equal + +local function equal(x, y) + if type(x) ~= type(y) then return false end + if type(x) == 'table' then + for k, v in pairs(x) do + if not equal(v, y[k]) then return false end + end + for k, _ in pairs(y) do + if x[k] == nil then return false end + end + return true + else + return x == y + end +end local log = {} local function record(x) table.insert(log, x) end diff --git a/tests/test_runtime.lua b/tests/test_runtime.lua index 5b4b2e1..c0e37de 100644 --- a/tests/test_runtime.lua +++ b/tests/test_runtime.lua @@ -6,7 +6,21 @@ package.path = "../src/?.lua;" .. package.path local runtime = require 'fibers.runtime' local time = require 'fibers.utils.time' -local equal = require 'fibers.utils.helper'.equal + +local function equal(x, y) + if type(x) ~= type(y) then return false end + if type(x) == 'table' then + for k, v in pairs(x) do + if not equal(v, y[k]) then return false end + end + for k, _ in pairs(y) do + if x[k] == nil then return false end + end + return true + else + return x == y + end +end local log = {} local function record(x) table.insert(log, x) end diff --git a/tests/test_stream-compat.lua b/tests/test_stream-compat.lua deleted file mode 100644 index 46ee52c..0000000 --- a/tests/test_stream-compat.lua +++ /dev/null @@ -1,22 +0,0 @@ ---- Tests the Stream Compat implementation. -print('testing: fibers.stream.compat') - --- look one level up -package.path = "../src/?.lua;" .. package.path - -local fibers = require 'fibers' -local compat = require 'fibers.stream.compat' - -print('selftest: lib.stream.compat') - -local function main() - _G.io.write('before\n') - compat.install() - _G.io.write('after\n') - assert(_G.io == io) - compat.uninstall() - - print('test: ok') -end - -fibers.run(main) diff --git a/tests/test_stream-file.lua b/tests/test_stream-file.lua deleted file mode 100644 index 0680e04..0000000 --- a/tests/test_stream-file.lua +++ /dev/null @@ -1,240 +0,0 @@ ---- Tests the Stream File implementation. -print('testing: fibers.stream.file') - --- look one level up -package.path = "../src/?.lua;" .. package.path - -local fibers = require 'fibers' -local waitgroup = require "fibers.waitgroup" -local sleep = require 'fibers.sleep' -local channel = require 'fibers.channel' -local file = require 'fibers.stream.file' -local compat = require 'fibers.stream.compat' - -compat.install() - -local perform, choice = fibers.perform, fibers.choice - -local function test() - local rd, wr = file.pipe() - local message = "hello, world\n" - - wr:setvbuf('line') - wr:write(message) - local message2 = rd:read_some_chars() - assert(message == message2) - wr:close() - assert(rd:read_some_chars() == nil) - rd:close() - - local subprocess = assert(io.popen('echo "hello"; echo "world"', 'r')) - local lines = {} - while true do - local line, err = subprocess:read_line() - assert(not err) - if line then table.insert(lines, line) else break end - end - local res, exit_type, code = subprocess:close() - assert(res) - assert(exit_type == "exit") - assert(code == 0) - assert(#lines == 2) - assert(lines[1] == 'hello') - assert(lines[2] == 'world') -end - -local function test_long_read_first() - local rd, wr = file.pipe() - local message = string.rep("a", 2^24) - - fibers.spawn(function () - wr:write_chars(message) - wr:close() - end) - - local message2 = rd:read_all_chars() - assert(#message2 == #message) - assert(message2 == message) - - rd:close() -end - -local function test_read_op() - local msg1 = "hello\n" - local msg2 = "world\n" - local rd, wr = file.pipe() - wr:setvbuf('line') -- new lines will automatically be flushed - - local wg = waitgroup.new() - wg:add(1) - - fibers.spawn(function () - local count, err = wr:write_chars(msg1) - assert(count==#msg1 and not err) - sleep.sleep(0.05) - count, err = wr:write_chars(msg2) - assert(count==#msg2 and not err) - wr:close() - wg:done() - end) - - local task = choice( - rd:read_all_chars_op(), - sleep.sleep_op(0.01):wrap(function () return nil, 'timeout' end) - ) - local chars, err = perform(task) - - assert(not chars and err == 'timeout') - - local did_read, read_string = rd:partial_read() - assert(did_read == #msg1 and read_string == msg1) - - rd:reset_partial_read() - assert(not rd._part_read) - - chars, err = rd:read_all_chars() - assert(not err and chars==msg2) - - assert(not rd._part_read) - - rd:close() - wg:wait() -end - -local function test_write_op() - local msg = string.rep("a", 2^16) -- needed because Linux has a 64KB buffer for a pipe - local msg2 = "hello world\n" - local rd, wr = file.pipe() - wr:setvbuf('no') -- no output buffering - - local wg = waitgroup.new() - wg:add(1) - - fibers.spawn(function () - sleep.sleep(0.1) - local chars, err = rd:read_chars(2^16) - assert(chars==string.rep("a", 2^16) and not err) - chars, err = rd:read_all_chars() - assert(chars==msg2 and not err) - rd:close() - wg:done() - end) - - local task = choice( - wr:write_chars_op(msg..msg2), - sleep.sleep_op(0.01):wrap(function () return nil, 'timeout' end) - ) - local written, err = perform(task) - - assert(not written and err=='timeout') - - local did_write = wr:partial_write() - assert(did_write == 2^16) - - wr:reset_partial_write() - assert(not wr._part_write) - - did_write, err = wr:write_chars(msg2) - assert(not err and did_write==#msg2) - - assert(not wr._part_write) - - wr:close() - wg:wait() -end - -local function test_long_write_first() - local rd, wr = file.pipe() - local chan = channel.new() - local message = string.rep("a", 2^24) - - local message2 - - fibers.spawn(function () - message2 = rd:read_all_chars() - rd:close() - chan:put(1) - end) - - wr:write_chars(message) - wr:close() - - chan:get() - - assert(#message2 == #message) - assert(message2 == message) -end - -local function test_tiny_writes() - local rd, wr = file.pipe() - local message = string.rep("a", 2^16) - - fibers.spawn(function () - for c in message:gmatch"." do - wr:write(c) - end - wr:close() - end) - - local message2 = rd:read_all_chars() - assert(#message2 == #message) - assert(message2 == message) - - rd:close() -end - -local function test_single() - local rd, wr = file.pipe() - local message = "aa" - - fibers.spawn(function () - wr:write_chars(message) - wr:close() - end) - - assert(string.byte(rd:read_char()) == 97) - assert(rd:read_char() == "a") - assert(rd:read_char() == nil) - - rd:close() -end - -local function test_lua() - local msg1 = "It\n" - local msg2 = "was\n" - local msg3 = "the\n" - local msg4 = "best\n" - local msg5 = "of\n" - local msg6 = "times" - - local rd, wr = file.pipe() - wr:setvbuf('no') - - assert(wr:write(msg1, msg2, msg3, msg4, msg5, msg6)) - wr:close() - - assert(rd:read("*l")==string.sub(msg1,1,#msg1-1)) - assert(rd:read("*L")==msg2) - assert(rd:read(#msg3)==msg3) - assert(rd:read(#msg4)==msg4) - assert(rd:read("*a")==msg5..msg6) - assert(rd:read("*a")=="") -- on EOF read("*a") returns the empty string - assert(rd:read("*l")==nil) -- on EOF read("*l") returns nil - - rd:close() -end - -local function main() - test() - test_read_op() - test_write_op() - test_long_read_first() - test_long_write_first() - test_tiny_writes() - test_single() - test_lua() -end - -fibers.run(main) - -print('test: ok') diff --git a/tests/test_stream-mem.lua b/tests/test_stream-mem.lua deleted file mode 100644 index fb62c73..0000000 --- a/tests/test_stream-mem.lua +++ /dev/null @@ -1,35 +0,0 @@ ---- Tests the Stream Mem implementation. -print('testing: fibers.stream.mem') - --- look one level up -package.path = "../src/?.lua;" .. package.path - -local fibers = require 'fibers' -local mem = require 'fibers.stream.mem' -local sc = require 'fibers.utils.syscall' - -local function main() - local str = "hello, world!" - local stream = mem.open_input_string(str) - assert(stream:seek() == 0) - assert(stream:seek(sc.SEEK_END) == #str) - assert(stream:seek() == #str) - assert(stream:seek(sc.SEEK_SET) == 0) - assert(stream:read_all_chars() == str) - assert(not pcall(stream.write_chars, stream, "more chars")) - assert(stream:seek() == #str) - stream:close() - - stream = mem.tmpfile() - assert(stream:seek() == 0) - assert(stream:seek(sc.SEEK_END) == 0) - stream:write_chars(str) - stream:flush() - assert(stream:seek() == #str) - assert(stream:seek(sc.SEEK_SET) == 0) - assert(stream:read_all_chars() == str) - stream:close() - print('selftest: ok') -end - -fibers.run(main) diff --git a/tests/test_stream-socket.lua b/tests/test_stream-socket.lua deleted file mode 100644 index 11dee3a..0000000 --- a/tests/test_stream-socket.lua +++ /dev/null @@ -1,42 +0,0 @@ ---- Tests the Stream Socket implementation. -print('testing: fibers.stream.socket') - --- look one level up -package.path = "../src/?.lua;" .. package.path - -local fibers = require 'fibers' -local socket = require 'fibers.stream.socket' -local sc = require 'fibers.utils.syscall' - -local function test() - local sockname = '/tmp/test-socket' - sc.unlink(sockname) - - local server = socket.listen_unix(sockname) - local client = socket.connect_unix(sockname) - local peer = server:accept() - - local messages = { "hello\n", "world\n" } - for _, msg in ipairs(messages) do - client:write(msg) - client:flush_output() - local res = peer:read_some_chars() - assert(msg == res) - end - client:close() - assert(peer:read_some_chars() == nil) - peer:close() - - server:close() - - sc.unlink(sockname) -end - - -local function main() - test() -end - -fibers.run(main) - -print('test: ok') From 907c9aae15bbfd95a76b026f088709d15581fc8f Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Tue, 9 Dec 2025 09:10:12 +0000 Subject: [PATCH 081/138] updates exec with function annotation and updates tests --- examples/03-child-scopes-and-fail-fast.lua | 47 ------ ...4-scope-defers.lua => 03-scope-defers.lua} | 0 ...ured-concurrency-and-fail-fast-scopes.lua} | 0 ...ubprocess.lua => 05-cancel-subprocess.lua} | 8 +- examples/06-scope-command-shared-pipe.lua | 93 ++++++++++++ src/fibers/io/exec.lua | 138 ++++++++++++++++-- src/fibers/io/exec_backend.lua | 13 +- 7 files changed, 235 insertions(+), 64 deletions(-) delete mode 100644 examples/03-child-scopes-and-fail-fast.lua rename examples/{04-scope-defers.lua => 03-scope-defers.lua} (100%) rename examples/{05-structured-concurrency-and-fail-fast-scopes.lua => 04-structured-concurrency-and-fail-fast-scopes.lua} (100%) rename examples/{06-cancel-subprocess.lua => 05-cancel-subprocess.lua} (96%) create mode 100644 examples/06-scope-command-shared-pipe.lua diff --git a/examples/03-child-scopes-and-fail-fast.lua b/examples/03-child-scopes-and-fail-fast.lua deleted file mode 100644 index 9093096..0000000 --- a/examples/03-child-scopes-and-fail-fast.lua +++ /dev/null @@ -1,47 +0,0 @@ -package.path = "../src/?.lua;" .. package.path - -local fibers = require 'fibers' -local sleep = require 'fibers.sleep' - -local run = fibers.run -local spawn = fibers.spawn -local run_scope = fibers.run_scope -- Scope.run -local current_scope = fibers.current_scope - -local function main(root_scope) - print("[root] starting; status:", root_scope:status()) - - -- Create a child scope under the root. - local status, err, extra_errs = run_scope(function() - local s = current_scope() - print("[child] scope created; status:", s:status()) - - -- Failing worker. - spawn(function() - print("[child/worker1] will fail after 0.5s") - sleep.sleep(0.5) - error("simulated failure in worker1") - end) - - -- Long-running worker; will be cancelled when sibling fails. - spawn(function() - local s2 = current_scope() - print("[child/worker2] started; waiting for cancellation...") - while true do - -- Check whether the scope has already failed/cancelled. - local st = s2:status() - print("[child/worker2] observed scope status:", st) - sleep.sleep(0.2) - end - end) - - -- This body returns once the child scope reaches a terminal state. - -- No explicit wait is needed; run_scope handles join/defers. - end) - - print("[root] child scope returned; status:", status, "error:", err, "extra errors:", #extra_errs) - for _, v in ipairs(extra_errs) do print(v) end - -- status will be "failed"; err will be the primary error from worker1. -end - -run(main) diff --git a/examples/04-scope-defers.lua b/examples/03-scope-defers.lua similarity index 100% rename from examples/04-scope-defers.lua rename to examples/03-scope-defers.lua diff --git a/examples/05-structured-concurrency-and-fail-fast-scopes.lua b/examples/04-structured-concurrency-and-fail-fast-scopes.lua similarity index 100% rename from examples/05-structured-concurrency-and-fail-fast-scopes.lua rename to examples/04-structured-concurrency-and-fail-fast-scopes.lua diff --git a/examples/06-cancel-subprocess.lua b/examples/05-cancel-subprocess.lua similarity index 96% rename from examples/06-cancel-subprocess.lua rename to examples/05-cancel-subprocess.lua index 797dd46..ef565c9 100644 --- a/examples/06-cancel-subprocess.lua +++ b/examples/05-cancel-subprocess.lua @@ -94,12 +94,8 @@ local function main() -- local proc_won, status2, code, signal, err = perform(boolean_choice( - cmd:run_op():wrap(function(st, c, sig, e) - return true, st, c, sig, e - end), - sleep_op(3.0):wrap(function() - return false - end) + cmd:run_op(), + sleep_op(3.0) )) if proc_won then diff --git a/examples/06-scope-command-shared-pipe.lua b/examples/06-scope-command-shared-pipe.lua new file mode 100644 index 0000000..7818112 --- /dev/null +++ b/examples/06-scope-command-shared-pipe.lua @@ -0,0 +1,93 @@ +package.path = "../src/?.lua;" .. package.path + +local fibers = require "fibers" +local sleep = require "fibers.sleep" +local exec = require "fibers.io.exec" +local file = require "fibers.io.file" +local cond_mod = require "fibers.cond" + +local scope_op = fibers.scope_op +local named_choice = fibers.named_choice +local perform = fibers.perform + +local function main(parent_scope) + ---------------------------------------------------------------------- + -- Shared stream owned by the parent scope + ---------------------------------------------------------------------- + local r_stream, w_stream = file.pipe() + + -- Condition used to signal "reader has finished" + local reader_done = cond_mod.new() + + -- Ensure streams are closed even if something goes wrong. + parent_scope:defer(function() + print("[parent] defer: closing shared streams") + assert(r_stream:close()); assert(w_stream:close()) + end) + + ---------------------------------------------------------------------- + -- Reader fibre in the parent scope + ---------------------------------------------------------------------- + fibers.spawn(function() + print("[parent-reader] started") + while true do + local line, err = perform(r_stream:read_line_op()) + + if not line then + print("[parent-reader] done, err:", err) + break + end + + print("[parent-reader] got:", line) + end + + -- Signal that the reader is finished. + reader_done:signal() + end) + + ---------------------------------------------------------------------- + -- Child scope as an Op: command writes ticks to the shared stream + ---------------------------------------------------------------------- + local child_scope_op = scope_op(function() + print("[child] building child scope op") + + local script = [[for i in 0 1 2 3 4 5 6 7 8 9; do echo "tick $i"; sleep 1; done]] + + local cmd = exec.command{ + "sh", "-c", script, + stdout = w_stream, -- shared stream from parent scope + } + + print("[child] starting ticking command") + -- Return an Op that completes when the process exits + return cmd:run_op() + end) + + ---------------------------------------------------------------------- + -- Race the child scope against a timeout + ---------------------------------------------------------------------- + local ev = named_choice{ + child_scope_done = child_scope_op, -- long-running command + timeout = sleep.sleep_op(3), -- after ~3 seconds + } + + local which, status, code_or_sig, err = perform(ev) + print("[parent] choice result:", which, status, code_or_sig, err) + + ---------------------------------------------------------------------- + -- Tear-down ordering: + -- 1. The choice has returned, so the child scope_op has either: + -- - completed (if child_scope_done won), or + -- - been cancelled and fully joined (if timeout won). + -- In both cases, the child process is no longer running. + -- 2. We now close the writer end so the reader sees EOF. + -- 3. We wait on reader_done to know the reader has finished. + ---------------------------------------------------------------------- + assert(w_stream:close()) + + -- Wait until the reader fibre has drained the stream and signalled completion. + perform(reader_done:wait_op()) + print("[parent] reader has signalled completion") +end + +fibers.run(main) diff --git a/src/fibers/io/exec.lua b/src/fibers/io/exec.lua index 8bd42e5..c8e2823 100644 --- a/src/fibers/io/exec.lua +++ b/src/fibers/io/exec.lua @@ -33,6 +33,23 @@ local DEFAULT_SHUTDOWN_GRACE = 1.0 ---@alias CommandStatus "pending"|"running"|"exited"|"signalled"|"failed" +--- Backend handle for a running process. +---@class ExecBackend +---@field pid integer|nil +---@field wait_op fun(self: ExecBackend): Op +---@field terminate fun(self: ExecBackend)|nil +---@field kill fun(self: ExecBackend): boolean, string|nil|nil +---@field send_signal fun(self: ExecBackend, signal?: any): boolean, string|nil|nil +---@field close fun(self: ExecBackend): boolean, string|nil + +--- Process handle returned by the backend. +---@class ProcHandle +---@field backend ExecBackend +---@field stdin Stream|nil +---@field stdout Stream|nil +---@field stderr Stream|nil + +--- Structured process command bound to a scope. ---@class Command ---@field _scope Scope ---@field _argv string[] @@ -54,7 +71,14 @@ local DEFAULT_SHUTDOWN_GRACE = 1.0 local Command = {} Command.__index = Command --- Normalise a user-facing stdio configuration into an ExecStreamConfig. +---------------------------------------------------------------------- +-- Helpers +---------------------------------------------------------------------- + +--- Normalise a user-facing stdio configuration into an ExecStreamConfig. +---@param value ExecStdin|ExecStdout|ExecStderr|Stream|nil +---@param is_stderr boolean +---@return ExecStreamConfig local function norm_stream(value, is_stderr) if value == nil then return { mode = "inherit", stream = nil, owned = true } @@ -79,16 +103,19 @@ local function norm_stream(value, is_stderr) error("invalid stdio configuration: " .. tostring(value)) end +---@param self Command local function assert_not_started(self) if self._started then error("command already started") end end --- Perform an op using the current scope when it is still running, --- otherwise fall back to a raw perform. This lets normal calls to --- exec ops honour scope cancellation, while scope defers can still --- run cleanup after the scope has reached a terminal state. +--- Perform an op using the current scope when it is still running, +--- otherwise fall back to a raw perform. This lets normal calls to +--- exec ops honour scope cancellation, while scope defers can still +--- run cleanup after the scope has reached a terminal state. +---@param ev Op +---@return any ... local function perform_with_scope_or_raw(ev) local s = Scope.current() if s and s.perform then @@ -104,6 +131,10 @@ end -- Internal: process lifecycle bookkeeping ---------------------------------------------------------------------- +--- Record a final exit status for this command, if not already done. +---@param code integer|nil +---@param signal integer|nil +---@param err string|nil function Command:_record_exit(code, signal, err) if self._done then return end self._done = true @@ -184,42 +215,63 @@ end -- Configuration setters ---------------------------------------------------------------------- +--- Set the stdin configuration for this command. +---@param v ExecStdin|nil +---@return Command function Command:set_stdin(v) assert_not_started(self) self._stdin = norm_stream(v, false) return self end +--- Set the stdout configuration for this command. +---@param v ExecStdout|nil +---@return Command function Command:set_stdout(v) assert_not_started(self) self._stdout = norm_stream(v, false) return self end +--- Set the stderr configuration for this command. +---@param v ExecStderr|nil +---@return Command function Command:set_stderr(v) assert_not_started(self) self._stderr = norm_stream(v, true) return self end +--- Set the working directory for this command. +---@param v string|nil +---@return Command function Command:set_cwd(v) assert_not_started(self) self._cwd = v return self end +--- Set the environment for this command. +---@param v table|nil +---@return Command function Command:set_env(v) assert_not_started(self) self._env = v return self end +--- Set backend-specific flags for this command. +---@param v table|nil +---@return Command function Command:set_flags(v) assert_not_started(self) - self._flags = v + self._flags = v or {} return self end +--- Set the shutdown grace period in seconds. +---@param v number +---@return Command function Command:set_shutdown_grace(v) assert_not_started(self) self._shutdown_grace = v @@ -251,10 +303,14 @@ function Command:status() return st, nil, self._err end +--- Return the process ID, if known. +---@return integer|nil pid function Command:pid() return self._pid end +--- Return a shallow copy of the argv array. +---@return string[] function Command:argv() local out = {} for i, v in ipairs(self._argv) do @@ -267,10 +323,14 @@ end -- Signalling ---------------------------------------------------------------------- +--- Send a signal or termination request to the process. +---@param sig any|nil +---@return boolean ok +---@return string|nil err function Command:kill(sig) - if self._done then - return true, nil - end + if self._done then + return true, nil + end if not self._started then return false, "command not started" end @@ -304,6 +364,9 @@ end -- Stream accessors ---------------------------------------------------------------------- +--- Return a readable stdin stream for this command, if configured. +---@return Stream|nil stream +---@return string|nil err function Command:stdin_stream() local cfg = self._stdin if cfg.mode == "inherit" or cfg.mode == "null" then @@ -319,6 +382,9 @@ function Command:stdin_stream() return nil end +--- Return a readable stdout stream for this command, if configured. +---@return Stream|nil stream +---@return string|nil err function Command:stdout_stream() local cfg = self._stdout if cfg.mode == "inherit" or cfg.mode == "null" then @@ -334,6 +400,9 @@ function Command:stdout_stream() return nil end +--- Return a readable stderr stream for this command, if configured. +---@return Stream|nil stream +---@return string|nil err function Command:stderr_stream() local cfg = self._stderr if cfg.mode == "inherit" or cfg.mode == "null" then @@ -356,6 +425,14 @@ end -- Ops: wait/run/shutdown/output ---------------------------------------------------------------------- +--- Op that completes when the process exits. +--- +--- When performed, returns: +--- status : CommandStatus +--- code : integer|nil +--- signal : integer|nil +--- err : string|nil +---@return Op function Command:run_op() return op.guard(function() local ok, proc, err = self:_ensure_started() @@ -373,6 +450,15 @@ function Command:run_op() end) end +--- Op that attempts graceful shutdown, then forceful kill after a grace period. +--- +--- When performed, returns: +--- status : CommandStatus +--- code : integer|nil +--- signal : integer|nil +--- err : string|nil +---@param grace number|nil +---@return Op function Command:shutdown_op(grace) return op.guard(function() local ok, proc, err = self:_ensure_started() @@ -435,6 +521,15 @@ function Command:shutdown_op(grace) end) end +--- Op that collects stdout and waits for the process to exit. +--- +--- When performed, returns: +--- output : string +--- status : CommandStatus +--- code : integer|nil +--- signal : integer|nil +--- err : string|nil +---@return Op function Command:output_op() return op.guard(function() -- If stdout is currently inherited, default to piping for this helper. @@ -460,6 +555,15 @@ function Command:output_op() end) end +--- Op that collects combined stdout+stderr and waits for exit. +--- +--- When performed, returns: +--- output : string +--- status : CommandStatus +--- code : integer|nil +--- signal : integer|nil +--- err : string|nil +---@return Op function Command:combined_output_op() if self._stderr.mode == "pipe" or self._stderr.mode == "stream" then error("combined_output_op: stderr must not already be a pipe or stream") @@ -474,6 +578,7 @@ end -- Scope cleanup ---------------------------------------------------------------------- +--- Scope defer handler: best-effort shutdown and resource cleanup. function Command:_on_scope_exit() if self._started and not self._done then -- Best-effort shutdown. Any error here will be caught by the @@ -505,6 +610,9 @@ end -- Command construction ---------------------------------------------------------------------- +--- Construct a Command from an ExecSpec. +---@param spec ExecSpec +---@return Command local function command_from_spec(spec) assert(Runtime.current_fiber(), "exec.command must be called from inside a fiber") local scope = Scope.current() @@ -550,6 +658,18 @@ end local exec = {} +---@class ExecBackendModule +---@field start fun(spec: ExecSpec): ProcHandle|nil, string|nil + +--- Create a new Command. +--- +--- Overloads: +--- exec.command(spec: ExecSpec) -> Command +--- exec.command(argv1: string, ...) -> Command +--- +---@overload fun(spec: ExecSpec): Command +---@param ... any +---@return Command function exec.command(...) local n = select("#", ...) if n == 1 and type((...)) == "table" then diff --git a/src/fibers/io/exec_backend.lua b/src/fibers/io/exec_backend.lua index 0f56eb8..ffca2e1 100644 --- a/src/fibers/io/exec_backend.lua +++ b/src/fibers/io/exec_backend.lua @@ -1,8 +1,8 @@ --- fibers/io/exec_backend.lua -- -- Backend selector for process management. -- Prefers pidfd backend where available, falls back to SIGCHLD/self-pipe. -- +---@module 'fibers.io.exec_backend' ---@class ExecProcSpec ---@field argv string[] @@ -19,17 +19,25 @@ ---@field stdout Stream|nil ---@field stderr Stream|nil +--- Backend module interface. +---@class ExecBackendModule +---@field is_supported fun(): boolean +---@field start fun(spec: ExecProcSpec): ProcHandle|nil, string|nil + +---@type string[] local candidates = { 'fibers.io.exec_backend.pidfd', -- Linux pidfd backend 'fibers.io.exec_backend.sigchld', -- Portable SIGCHLD + self-pipe backend (luaposix) - 'fibers.io.exec_backend.nixio', -- Portable SIGCHLD + self-pipe backend (luaposix) + 'fibers.io.exec_backend.nixio', -- Portable SIGCHLD + self-pipe backend (nixio) } +---@type ExecBackendModule|nil local chosen for _, name in ipairs(candidates) do local ok, mod = pcall(require, name) if ok and type(mod) == "table" and mod.is_supported and mod.is_supported() then + ---@cast mod ExecBackendModule chosen = mod break end @@ -39,4 +47,5 @@ if not chosen then error("fibers.io.exec_backend: no suitable process backend available on this platform") end +---@return ExecBackendModule return chosen From 899131a0d134996bda633b2ddb05dc89ae5f97fb Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Wed, 10 Dec 2025 10:18:12 +0000 Subject: [PATCH 082/138] typos --- examples/06-scope-command-shared-pipe.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/06-scope-command-shared-pipe.lua b/examples/06-scope-command-shared-pipe.lua index 7818112..e5de830 100644 --- a/examples/06-scope-command-shared-pipe.lua +++ b/examples/06-scope-command-shared-pipe.lua @@ -26,7 +26,7 @@ local function main(parent_scope) end) ---------------------------------------------------------------------- - -- Reader fibre in the parent scope + -- Reader fiber in the parent scope ---------------------------------------------------------------------- fibers.spawn(function() print("[parent-reader] started") @@ -85,7 +85,7 @@ local function main(parent_scope) ---------------------------------------------------------------------- assert(w_stream:close()) - -- Wait until the reader fibre has drained the stream and signalled completion. + -- Wait until the reader fiber has drained the stream and signalled completion. perform(reader_done:wait_op()) print("[parent] reader has signalled completion") end From f9378552f6619c8081938503a77b97ad80a340d0 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Wed, 10 Dec 2025 10:22:37 +0000 Subject: [PATCH 083/138] adds core alarm facility and civil example --- examples/08-civil-alarms.lua | 146 +++++++++++ src/fibers/alarm.lua | 99 ++++++-- tests/test.lua | 2 +- tests/test_alarm.lua | 463 +++++++++++++++++++++++++++++------ 4 files changed, 615 insertions(+), 95 deletions(-) create mode 100644 examples/08-civil-alarms.lua diff --git a/examples/08-civil-alarms.lua b/examples/08-civil-alarms.lua new file mode 100644 index 0000000..8ff52c7 --- /dev/null +++ b/examples/08-civil-alarms.lua @@ -0,0 +1,146 @@ +-- civil-alarms.lua +-- +-- Very simple civil-time helpers built on top of fibers.alarm. +-- This example trusts Lua's os.date / os.time for all civil-time logic. +-- +-- It provides: +-- * daily_at(hour, min, sec?) -> Alarm that fires once per day at local HH:MM:SS +-- * next_at(hour, min, sec?) -> Alarm that fires once at the next local HH:MM:SS + +package.path = "../src/?.lua;" .. package.path + +local fibers = require 'fibers' +local alarm_mod = require 'fibers.alarm' + +local perform = fibers.perform + +---------------------------------------------------------------------- +-- Internal helper: compute next local HH:MM:SS in epoch seconds +---------------------------------------------------------------------- + +--- Compute the next local HH:MM:SS occurrence, possibly using `last` for recurrence. +---@param hour integer # 0..23 +---@param min integer # 0..59 +---@param sec integer|nil # 0..59 (default 0) +---@param last number|nil # last fired epoch +---@param now number # current epoch +---@return number # next firing epoch +local function next_local_time_at(hour, min, sec, last, now) + sec = sec or 0 + + -- We rely entirely on os.date / os.time. This means: + -- * Local time zone and DST come from the process environment. + -- * Gaps/overlaps are handled as per the platform's C library. + + local t + + if last then + -- Subsequent firing: "tomorrow at HH:MM:SS" relative to the last firing. + t = os.date("*t", last) + t.day = t.day + 1 + else + -- First firing: choose between "today at HH:MM:SS" and "tomorrow at HH:MM:SS". + t = os.date("*t", now) + + local past = + t.hour > hour + or (t.hour == hour and ( + t.min > min + or (t.min == min and t.sec >= sec) + )) + + if past then + t.day = t.day + 1 + end + end + + t.hour, t.min, t.sec = hour, min, sec + + return os.time(t) +end + +---------------------------------------------------------------------- +-- One-shot: next local HH:MM:SS +---------------------------------------------------------------------- + +--- One-shot alarm that fires once at the next local HH:MM:SS. +---@param hour integer # 0..23 +---@param min integer # 0..59 +---@param sec integer|nil # 0..59 (default 0) +---@param label? string +---@return Alarm +local function next_at(hour, min, sec, label) + return alarm_mod.new{ + next_time = function(last, now) + -- If we have already fired once, stop. + if last ~= nil then + return nil + end + return next_local_time_at(hour, min, sec, last, now) + end, + label = label or string.format( + "next_%02d:%02d:%02d_local", + hour, min, sec or 0 + ), + } +end + +---------------------------------------------------------------------- +-- Recurring: daily at local HH:MM:SS +---------------------------------------------------------------------- + +--- Recurring alarm that fires once per day at local HH:MM:SS. +---@param hour integer # 0..23 +---@param min integer # 0..59 +---@param sec integer|nil # 0..59 (default 0) +---@param label? string +---@return Alarm +local function daily_at(hour, min, sec, label) + return alarm_mod.new{ + next_time = function(last, now) + return next_local_time_at(hour, min, sec, last, now) + end, + label = label or string.format( + "daily_%02d:%02d:%02d_local", + hour, min, sec or 0 + ), + } +end + +---------------------------------------------------------------------- +-- User code +---------------------------------------------------------------------- + +-- Install the wall-clock time source once at start-up. +alarm_mod.set_time_source(os.time) + +fibers.run(function() + local start_epoch = os.time() + + -- Create a one-off alarm at local HH:MM:SS+3. + local oo_target = start_epoch + 3 + local oo_civil = os.date("*t", oo_target) + local oo_alarm = next_at(oo_civil.hour, oo_civil.min, oo_civil.sec) + + -- Wait for alarm to fire. + local oo_fired, _, oo_fired_at = perform(oo_alarm:wait_op()) + + print("fired =", oo_fired) + print("scheduled local time =", os.date("%X", oo_target)) + print("fired at local time =", os.date("%X", oo_fired_at)) + print("delta seconds =", os.difftime(oo_fired_at, start_epoch)) + + -- Create a daily alarm at that local HH:MM:SS+5. + local d_target = start_epoch + 5 + local d_civil = os.date("*t", d_target) + local d_alarm = daily_at(d_civil.hour, d_civil.min, d_civil.sec) + + -- Wait for alarm to fire. + local d_fired, _, d_fired_at = fibers.perform(d_alarm:wait_op()) + + print("fired =", d_fired) + print("scheduled local time =", os.date("%X", d_target)) + print("fired at local time =", os.date("%X", d_fired_at)) + print("delta seconds =", os.difftime(d_fired_at, start_epoch)) + +end) diff --git a/src/fibers/alarm.lua b/src/fibers/alarm.lua index f3f82aa..6ae068b 100644 --- a/src/fibers/alarm.lua +++ b/src/fibers/alarm.lua @@ -1,6 +1,6 @@ -- fibers/alarm.lua -- --- Wall-clock based alarms integrated with the fibres runtime. +-- Wall-clock based alarms integrated with the fibers runtime. -- -- Each alarm is driven by a recurrence function: -- next_time(last_fired :: epoch|nil, now :: epoch) -> next_epoch|nil @@ -20,28 +20,50 @@ -- * No wait_op() will complete until set_time_source(...) has been called. -- * A wait_op() started before set_time_source will first wait for time -- to become ready, then for the next recurrence. +-- +-- Clock / civil time changes: +-- * alarm.time_changed() notifies all waiting alarms that the mapping +-- from monotonic time to civil time (UTC/zone) has changed. +-- * A wait_op() that is currently sleeping for the next recurrence +-- will be pre-empted, recompute its next firing time, and sleep again. +-- * This uses best-effort choice semantics: if a timer and a clock +-- change become ready together, either may win the choice. local op = require 'fibers.op' local sleep_mod = require 'fibers.sleep' local perform = require 'fibers.performer'.perform local cond_mod = require 'fibers.cond' - +local time = require 'fibers.utils.time' + +---@class Alarm +---@field _next_time fun(last: number|nil, now: number): number|nil +---@field _policy any +---@field _label string +---@field _last number|nil +---@field _next_wall number|nil +---@field _state "active"|"exhausted_pending"|"exhausted_done" local Alarm = {} Alarm.__index = Alarm ---------------------------------------------------------------------- --- Wall-clock source and "time ready" condition +-- Wall-clock source, readiness, and clock-change signalling ---------------------------------------------------------------------- -- Wall-clock "now" function (epoch seconds); replaced once real time is known. -local wall_now = os.time +local wall_now = time.realtime local time_ready = false --- Generic one-shot condition for “time is ready”. +-- One-shot condition for “time is ready”. local time_ready_cond = cond_mod.new() +-- Multi-shot condition for “clock / civil time has changed”. +-- This is recreated on every change so that each wait sees at most +-- one wake-up per change generation. +local clock_change_cond = cond_mod.new() + --- Install the wall-clock time source. --- May be called once, when real time is known (RTC, NTP, GNSS, etc.). +--- May be called once, when real time is known (RTC, NTP, GNSS, etc.). +---@param now_fn fun(): number local function set_time_source(now_fn) assert(type(now_fn) == "function", "set_time_source expects a function") assert(not time_ready, "set_time_source may only be called once") @@ -49,8 +71,28 @@ local function set_time_source(now_fn) wall_now = now_fn time_ready = true - -- Wake any fibres that were waiting for time to become ready. + -- Wake any fibers that were waiting for time to become ready. time_ready_cond:signal() + + -- Treat "time became known" as a clock change for any alarms that + -- might start waiting after this point. + clock_change_cond:signal() + clock_change_cond = cond_mod.new() +end + +--- Notify alarms that the civil time mapping has changed. +--- Call this when: +--- * the system's wall clock is adjusted (e.g. after NTP sync), or +--- * the time zone used by recurrence functions has changed. +local function time_changed() + if not time_ready then + -- Before time_ready, no alarm has gone past the readiness gate, + -- so there is nothing meaningful to reschedule. + return + end + + clock_change_cond:signal() + clock_change_cond = cond_mod.new() end ---------------------------------------------------------------------- @@ -67,13 +109,15 @@ function Alarm:is_active() end --- Cancel the alarm permanently. --- No further firings or exhaustion notification will be delivered. +--- No further firings or exhaustion notification will be delivered. function Alarm:cancel() self._state = "exhausted_done" self._next_wall = nil end -- Internal: ensure _next_wall is populated or update state on exhaustion. +---@param now number +---@return number|nil function Alarm:_ensure_next(now) if self._next_wall or self._state ~= "active" then return self._next_wall @@ -92,7 +136,7 @@ end --- Main CML-style operation: wait for the alarm to fire once. -- --- Returns an Event which, when performed, yields either: +-- Returns an Op which, when performed, yields either: -- -- * On successful firing: -- true, alarm, last_fired_epoch, ... @@ -101,11 +145,15 @@ end -- false, "no_more_recurrences", alarm, last_fired_epoch|nil -- -- After the exhaustion notification has been delivered, further --- wait_op() calls return an Event that never fires. +-- wait_op() calls return an Op that never fires. -- -- Before set_time_source is called, a wait_op() will first block -- until time becomes ready, and then behave exactly as if wait_op() -- had been called afterwards. +-- +-- If alarm.time_changed() is called while this wait_op() is sleeping +-- for its next firing time, the sleep will be pre-empted and the +-- next firing time will be recomputed from the updated civil time. function Alarm:wait_op() return op.guard(function() -- Fully inert: no more results of any kind. @@ -138,12 +186,28 @@ function Alarm:wait_op() local dt = next_wall - now if dt < 0 then dt = 0 end - -- Relative sleep using monotonic time, followed by state update. - return sleep_mod.sleep_op(dt):wrap(function(...) - -- Only update state on successful firing (not on abort). - self._last = next_wall - self._next_wall = nil - return true, self, self._last, ... + -- Build a race between: + -- * sleeping until the scheduled time; and + -- * a clock/civil-time change. + -- + -- sleep_op(dt) yields no user-level values on success. + local sleep_ev = sleep_mod.sleep_op(dt) + local change_ev = clock_change_cond:wait_op() + + local choice_ev = op.boolean_choice(sleep_ev, change_ev) + + return choice_ev:wrap(function(is_sleep) + if is_sleep then + -- Timer completed: commit this firing. + self._last = next_wall + self._next_wall = nil + return true, self, self._last + else + -- Clock or time zone changed before the timer fired: + -- clear the stale schedule and recompute under new civil time. + self._next_wall = nil + return perform(self:wait_op()) + end end) end) end @@ -160,6 +224,8 @@ Alarm.event = Alarm.wait_op -- params.next_time :: function(last_epoch|nil, now_epoch) -> next_epoch|nil -- params.policy :: optional policy table (DST, gaps, overlaps, etc.) -- params.label :: optional label for identification/logging +---@param params { next_time: fun(last: number|nil, now: number): number|nil, policy?: any, label?: string } +---@return Alarm local function new(params) assert(type(params) == "table", "alarm.new expects a parameter table") local next_time = params.next_time @@ -182,4 +248,5 @@ return { Alarm = Alarm, new = new, set_time_source = set_time_source, + time_changed = time_changed, } diff --git a/tests/test.lua b/tests/test.lua index 4cfff15..ecfaea5 100644 --- a/tests/test.lua +++ b/tests/test.lua @@ -13,6 +13,7 @@ local modules = { { 'io', 'exec_backend' }, { 'io', 'exec' }, { 'timer' }, + { 'alarm' }, { 'sched' }, { 'runtime' }, { 'channel' }, @@ -20,7 +21,6 @@ local modules = { { 'cond' }, { 'sleep' }, { 'waitgroup' }, - -- { 'alarm' }, { 'scope' }, } diff --git a/tests/test_alarm.lua b/tests/test_alarm.lua index f9071ab..3ea401c 100644 --- a/tests/test_alarm.lua +++ b/tests/test_alarm.lua @@ -1,85 +1,392 @@ +package.path = "../src/?.lua;" .. package.path + -- test_alarm.lua --- --- Simple demonstration of fibers.alarm with guard + named_choice. -print('testing: fibers.alarm') +print("testing: fibers.alarm") -package.path = "../src/?.lua;" .. package.path +local fibers = require "fibers" +local sleep_mod = require "fibers.sleep" +local alarm_mod = require "fibers.alarm" + +local sleep = sleep_mod.sleep + +local function approx_equal(a, b, eps) + eps = eps or 1e-3 + return math.abs(a - b) <= eps +end + +------------------------------------------------------------------------ +-- Shared wall-clock for tests +------------------------------------------------------------------------ + +-- We control "wall clock" via this variable. +local wall_time = 0 + +local function now_fn() + return wall_time +end + +-- Install our test time source once. Alarm code only allows this once. +alarm_mod.set_time_source(now_fn) + +------------------------------------------------------------------------ +-- Test 1: basic one-shot alarm fires at expected time +------------------------------------------------------------------------ + +local function test_basic_alarm() + print("[test_basic_alarm] start") + + wall_time = 0 + + local calls = {} + + local function next_time(last, now) + calls[#calls + 1] = { last = last, now = now } + if last ~= nil then + -- one-shot: only fire once + return nil + end + -- Fire 0.1 seconds after "now" + return now + 0.1 + end + + local al = alarm_mod.new{ next_time = next_time } + + local result = {} + + fibers.spawn(function() + local ok, alarm, fired_epoch = fibers.perform(al:wait_op()) + result.ok = ok + result.alarm = alarm + result.time = fired_epoch + end) + + -- Give the spawned fiber a chance to set up its wait + sleep(0.01) + + -- Let 0.5s of monotonic time pass, which is comfortably > 0.1 + sleep(0.5) + + assert(result.ok == true, "basic alarm did not fire") + assert(result.alarm == al, "basic alarm: unexpected alarm instance") + assert(approx_equal(result.time, 0.1), + ("basic alarm: expected fired_epoch ~= 0.1 (got %f)"):format(result.time)) + + assert(#calls >= 1, "basic alarm: next_time was never called") + assert(approx_equal(calls[1].now, 0), + ("basic alarm: expected first now ~= 0 (got %f)"):format(calls[1].now)) + + print("[test_basic_alarm] ok") +end + +------------------------------------------------------------------------ +-- Test 2: pre-emptive reschedule when time_changed() is called +------------------------------------------------------------------------ + +local function test_preemptive_reschedule() + print("[test_preemptive_reschedule] start") + + wall_time = 0 + + -- Offset controls how far in the future the alarm fires. + local offset = 10 + + local calls = {} + + local function next_time(last, now) + calls[#calls + 1] = { last = last, now = now } + if last ~= nil then + -- one-shot for this test: only fire once + return nil + end + return now + offset + end + + local al = alarm_mod.new{ next_time = next_time } + + local result = {} + + fibers.spawn(function() + local ok, alarm, fired_epoch = fibers.perform(al:wait_op()) + result.ok = ok + result.alarm = alarm + result.time = fired_epoch + end) + + -- Allow the spawned fiber to start its wait and schedule the initial sleep + sleep(0.01) + + -- At this point: + -- wall_time == 0 + -- next_time(nil, 0) -> 0 + offset (=10) + -- so the alarm is sleeping for dt = 10 seconds in monotonic time. + + -- Now simulate a civil-time change: + -- * new notion of "wall now", + -- * new offset (much shorter wait), + -- * and notify alarms via time_changed(). + wall_time = 100 -- new wall "now" + offset = 1 -- next firing should now be at 101 + + alarm_mod.time_changed() + + -- After time_changed(), the alarm's wait_op should: + -- * wake from the clock-change branch, + -- * clear _next_wall, + -- * recompute next_wall from next_time(nil, 100) -> 101, + -- * sleep for dt = 1, + -- * then fire at last_fired_epoch == 101. + + -- Let enough monotonic time pass for the shorter dt=1 sleep to complete. + sleep(2.0) + + assert(result.ok == true, "preemptive alarm did not fire") + assert(result.alarm == al, "preemptive alarm: unexpected alarm instance") + assert(approx_equal(result.time, 101), + ("preemptive alarm: expected fired_epoch ~= 101 (got %f)"):format(result.time)) + + -- Check that next_time was called at least twice: + assert(#calls >= 2, + ("preemptive alarm: expected at least 2 calls to next_time, got %d"):format(#calls)) + assert(approx_equal(calls[1].now, 0), + ("preemptive alarm: first now ~= 0 (got %f)"):format(calls[1].now)) + assert(approx_equal(calls[2].now, 100), + ("preemptive alarm: second now ~= 100 (got %f)"):format(calls[2].now)) + + print("[test_preemptive_reschedule] ok") +end + +------------------------------------------------------------------------ +-- Test 3: multiple alarms reschedule in parallel +------------------------------------------------------------------------ + +local function test_multiple_alarms_reschedule() + print("[test_multiple_alarms_reschedule] start") + + wall_time = 0 + + local offset1 = 10 + local offset2 = 20 + + local calls1, calls2 = {}, {} + + local function next_time1(last, now) + calls1[#calls1 + 1] = { last = last, now = now } + if last ~= nil then + return nil + end + return now + offset1 + end -local runtime = require 'fibers.runtime' -local perform = require 'fibers.performer'.perform -local op = require 'fibers.op' -local alarmmod = require 'fibers.alarm' - -local function main() - -- Two one-shot alarms at the same time. - local a1 = alarmmod.after(1.0) - local a2 = alarmmod.after(1.0) - - -- One repeating alarm: first fire after 0.5s, then every 0.5s. - local a3 = alarmmod.every(0.5) - - runtime.spawn_raw(function() - local function build_arms() - local arms = {} - - if a1:is_active() then - arms.a1 = a1:event():wrap(function(al, ...) - return "a1", al, ... - end) - end - - if a2:is_active() then - arms.a2 = a2:event():wrap(function(al, ...) - return "a2", al, ... - end) - end - - if a3:is_active() then - arms.a3 = a3:event():wrap(function(al, ...) - return "a3", al, ... - end) - end - - return arms - end - - -- A single dynamic event that, on each synchronisation, - -- chooses one of the currently-active alarms. - local ev = op.guard(function() - local arms = build_arms() - if next(arms) == nil then - return op.never() - else - return op.named_choice(arms) - end - end) - - -- Run until both one-shot alarms have fired; then stop - -- the scheduler. The repeating alarm will still be active - -- but we ignore it after that. - while true do - local name, al = perform(ev) - local t = runtime.now() - print(("[%.3f] alarm %s fired"):format(t, name)) - - if not a1:is_active() and not a2:is_active() then - print("Both one-shot alarms have fired; stopping runtime.") - runtime.stop() - break - end - end - end) - - runtime.main() + local function next_time2(last, now) + calls2[#calls2 + 1] = { last = last, now = now } + if last ~= nil then + return nil + end + return now + offset2 + end + + local al1 = alarm_mod.new{ next_time = next_time1 } + local al2 = alarm_mod.new{ next_time = next_time2 } + + local r1, r2 = {}, {} + + fibers.spawn(function() + local ok, alarm, t = fibers.perform(al1:wait_op()) + r1.ok, r1.alarm, r1.time = ok, alarm, t + end) + + fibers.spawn(function() + local ok, alarm, t = fibers.perform(al2:wait_op()) + r2.ok, r2.alarm, r2.time = ok, alarm, t + end) + + -- Let both alarms set up their initial waits. + sleep(0.01) + + -- Change civil time and offsets and notify once. + wall_time = 100 + offset1 = 1 + offset2 = 2 + + alarm_mod.time_changed() + + -- Enough monotonic time for both new sleeps (1 and 2 seconds). + sleep(3.0) + + assert(r1.ok == true, "alarm1 did not fire") + assert(r2.ok == true, "alarm2 did not fire") + + assert(r1.alarm == al1, "alarm1: unexpected alarm instance") + assert(r2.alarm == al2, "alarm2: unexpected alarm instance") + + assert(approx_equal(r1.time, 101), + ("alarm1: expected fired_epoch ~= 101 (got %f)"):format(r1.time)) + assert(approx_equal(r2.time, 102), + ("alarm2: expected fired_epoch ~= 102 (got %f)"):format(r2.time)) + + assert(#calls1 >= 2 and #calls2 >= 2, "multiple alarms: next_time not called enough times") + + assert(approx_equal(calls1[1].now, 0), ("alarm1: first now ~= 0 (got %f)"):format(calls1[1].now)) + assert(approx_equal(calls1[2].now, 100),("alarm1: second now ~= 100 (got %f)"):format(calls1[2].now)) + assert(approx_equal(calls2[1].now, 0), ("alarm2: first now ~= 0 (got %f)"):format(calls2[1].now)) + assert(approx_equal(calls2[2].now, 100),("alarm2: second now ~= 100 (got %f)"):format(calls2[2].now)) + + print("[test_multiple_alarms_reschedule] ok") +end + +------------------------------------------------------------------------ +-- Test 4: repeated time_changed() while waiting +------------------------------------------------------------------------ + +local function test_repeated_time_changes() + print("[test_repeated_time_changes] start") + + wall_time = 0 + + local offset = 10 + local calls = {} + + local function next_time(last, now) + calls[#calls + 1] = { last = last, now = now } + if last ~= nil then + -- one-shot + return nil + end + return now + offset + end + + local al = alarm_mod.new{ next_time = next_time } + + local result = {} + + fibers.spawn(function() + local ok, alarm, fired_epoch = fibers.perform(al:wait_op()) + result.ok = ok + result.alarm = alarm + result.time = fired_epoch + end) + + -- Allow initial wait setup (now = 0, next_wall = 10). + sleep(0.01) + + -- First change: bump wall_time to 100, keep offset = 10. + wall_time = 100 + offset = 10 + alarm_mod.time_changed() + sleep(0.01) + + -- Second change: wall_time to 200, offset = 5. + wall_time = 200 + offset = 5 + alarm_mod.time_changed() + sleep(0.01) + + -- Third change: wall_time to 300, offset = 1. + wall_time = 300 + offset = 1 + alarm_mod.time_changed() + + -- Final sleep long enough for the last dt = 1. + sleep(2.0) + + assert(result.ok == true, "repeated-change alarm did not fire") + assert(result.alarm == al, "repeated-change alarm: unexpected alarm instance") + assert(approx_equal(result.time, 301), + ("repeated-change alarm: expected fired_epoch ~= 301 (got %f)"):format(result.time)) + + assert(#calls >= 3, ("repeated-change alarm: expected multiple next_time calls, got %d"):format(#calls)) + + -- Check that we saw non-decreasing now values and that the last is ~300. + local last_now = calls[1].now + for i = 2, #calls do + assert(calls[i].now >= last_now, + ("repeated-change alarm: now not non-decreasing at call %d (prev=%f, now=%f)") + :format(i, last_now, calls[i].now)) + last_now = calls[i].now + end + + assert(approx_equal(last_now, 300), + ("repeated-change alarm: last now ~= 300 (got %f)"):format(last_now)) + + print("[test_repeated_time_changes] ok") end --- Allow this file to be run directly (e.g. `lua test_alarm.lua`) --- or required from elsewhere. -if ... == nil then - main() -else - return { - main = main, - } +------------------------------------------------------------------------ +-- Test 5: exhaustion behaviour still correct after reschedule +------------------------------------------------------------------------ + +local function test_exhaustion_after_reschedule() + print("[test_exhaustion_after_reschedule] start") + + wall_time = 0 + + local offset = 10 + local calls = {} + + local function next_time(last, now) + calls[#calls + 1] = { last = last, now = now } + if last ~= nil then + -- No further recurrences: one-shot alarm + return nil + end + return now + offset + end + + local al = alarm_mod.new{ next_time = next_time } + + local r1, r2 = {} + + fibers.spawn(function() + -- First wait: should fire once. + local ok1, alarm1, t1 = fibers.perform(al:wait_op()) + r1 = { ok = ok1, alarm = alarm1, time = t1 } + + -- Second wait: should give exhaustion notification. + local ok2, why2, alarm2, last2 = fibers.perform(al:wait_op()) + r2 = { ok = ok2, why = why2, alarm = alarm2, last = last2 } + end) + + -- Allow initial scheduling at now = 0, next_wall = 10. + sleep(0.01) + + -- Change civil time and offset before the first firing. + wall_time = 100 + offset = 1 + alarm_mod.time_changed() + + -- Enough time for the recomputed dt = 1 sleep. + sleep(2.0) + + -- Check first firing. + assert(r1.ok == true, "exhaustion test: first firing did not occur") + assert(r1.alarm == al, "exhaustion test: first firing alarm mismatch") + assert(approx_equal(r1.time, 101), + ("exhaustion test: expected first fired_epoch ~= 101 (got %f)"):format(r1.time)) + + -- Check exhaustion notification. + assert(r2.ok == false, "exhaustion test: second wait should report exhaustion") + assert(r2.why == "no_more_recurrences", + ("exhaustion test: expected reason 'no_more_recurrences', got %s"):format(tostring(r2.why))) + assert(r2.alarm == al, "exhaustion test: second wait alarm mismatch") + assert(approx_equal(r2.last, r1.time), + ("exhaustion test: expected last == first fired time (got %f vs %f)") + :format(r2.last or -1, r1.time or -1)) + + -- Ensure next_time saw at least two calls (initial schedule and exhaustion computation). + assert(#calls >= 2, ("exhaustion test: expected at least 2 next_time calls, got %d"):format(#calls)) + + print("[test_exhaustion_after_reschedule] ok") end + +------------------------------------------------------------------------ +-- Run tests under the fibers scheduler +------------------------------------------------------------------------ + +fibers.run(function() + test_basic_alarm() + test_preemptive_reschedule() + test_multiple_alarms_reschedule() + test_repeated_time_changes() + test_exhaustion_after_reschedule() +end) From 2cf2e9bd2050ca556d56905529764f8a7c50b21e Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Wed, 10 Dec 2025 21:19:02 +0000 Subject: [PATCH 084/138] remove all traces of context --- tests/test_context.lua | 112 ----------------------------------------- 1 file changed, 112 deletions(-) delete mode 100644 tests/test_context.lua diff --git a/tests/test_context.lua b/tests/test_context.lua deleted file mode 100644 index 1ca847a..0000000 --- a/tests/test_context.lua +++ /dev/null @@ -1,112 +0,0 @@ ---- Tests the Context implementation. -print('testing: fibers.context') - --- look one level up -package.path = "../src/?.lua;" .. package.path - -local context = require 'fibers.context' -local fibers = require 'fibers' -local sleep = require 'fibers.sleep' - -local perform = require 'fibers.performer'.perform - --- Test Background Context -local function test_background() - local ctx = context.background() - assert(ctx:value("key") == nil, "Background context should not have any values") - print("test_background passed") -end - --- Test With Cancel Context -local function test_with_cancel() - local parent = context.background() - local ctx, cancel = context.with_cancel(parent) - local child_ctx, _ = context.with_cancel(ctx) - - local is_cancelled = false - fibers.spawn(function() - perform(child_ctx:done_op()) - is_cancelled = true - end) - - cancel() - sleep.sleep(0.1) -- Give time for cancellation to propagate - - assert(is_cancelled, "Child context should be cancelled when parent is cancelled") - print("test_with_cancel passed") -end - --- Test With Value Context -local function test_with_value() - local parent = context.background() - local ctx_1 = context.with_value(parent, "key", "value") - local ctx_2 = context.with_value(ctx_1, "another_key", "value") - local ctx_3 = context.with_value(ctx_2, "key", "another_value") - - assert(ctx_1:value("key") == "value", "Context should have the correct value") - assert(ctx_3:value("another_key") == "value", "Child context should inherit parent value") - assert(ctx_3:value("key") == "another_value", "Child context should have its own value") - print("test_with_value passed") -end - --- Test With Timeout Context -local function test_with_timeout() - local parent = context.background() - local ctx, _ = context.with_timeout(parent, 0.01) - - local is_cancelled = false - fibers.spawn(function() - perform(ctx:done_op()) - is_cancelled = true - end) - - sleep.sleep(0.02) -- Give time for timeout to trigger - - assert(is_cancelled, "Context should be cancelled after timeout") - print("test_with_timeout passed") -end - --- Test Custom Cause -local function test_custom_cause() - local parent = context.background() - local ctx, cancel = context.with_cancel(parent) - local ctx_2, _ = context.with_cancel(ctx) - local custom_cause = "Custom Cancel Reason" - - cancel(custom_cause) - sleep.sleep(0.01) -- Give time for cancellation to propagate - - assert(ctx:err() == custom_cause, "Context should have the custom cancel cause") - assert(ctx_2:err() == custom_cause, "Child context should have the custom cancel cause") - print("test_custom_cause passed") -end - -local function test_cancel_on_with_value() - local parent = context.background() - local ctx, cancel = context.with_cancel(parent) - local ctx_2, _ = context.with_value(ctx, "key", "value") - local ctx_3, _ = context.with_value(ctx_2, "key2", "value2") - - cancel('cancelled') - sleep.sleep(0.01) -- Give time for cancellation to propagate - - assert(ctx:err() == 'cancelled', "Context should have cancel cause") - assert(ctx_2:err() == 'cancelled', "Child context should have cancel cause") - assert(ctx_2:value('key') == 'value', "Child context should have the value") - assert(ctx_3:err() == 'cancelled', "Child context should have cancel cause") - assert(ctx_3:value('key') == 'value', "Child context should have the value") - assert(ctx_3:value('key2') == 'value2', "Child context should have the value") -end --- Run all tests -local function main() - test_background() - test_with_cancel() - test_with_value() - test_with_timeout() - test_custom_cause() - test_cancel_on_with_value() - - print("All tests passed") -end - -fibers.run(main) From c2af85d3471566fef045e53a6b897dbfdbb1e5d2 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Wed, 10 Dec 2025 21:22:31 +0000 Subject: [PATCH 085/138] remove redundant queue --- src/fibers/queue.lua | 19 ---------------- tests/test.lua | 1 - tests/test_queue.lua | 53 -------------------------------------------- 3 files changed, 73 deletions(-) delete mode 100644 src/fibers/queue.lua delete mode 100644 tests/test_queue.lua diff --git a/src/fibers/queue.lua b/src/fibers/queue.lua deleted file mode 100644 index e5ec8fc..0000000 --- a/src/fibers/queue.lua +++ /dev/null @@ -1,19 +0,0 @@ --- Use of this source code is governed by the Apache 2.0 license; see COPYING. - ---- fibers.queue module --- Buffered channels for communication between fibers. --- @module fibers.queue - -local channel = require 'fibers.channel' - ---- Create a new Queue. --- @int[opt] bound The upper bound for the number of items in the queue. --- @treturn Queue The created Queue. -local function new(bound) - if bound then assert(bound >= 1) end - return channel.new(bound and bound or math.huge) -end - -return { - new = new -} diff --git a/tests/test.lua b/tests/test.lua index ecfaea5..faf145e 100644 --- a/tests/test.lua +++ b/tests/test.lua @@ -17,7 +17,6 @@ local modules = { { 'sched' }, { 'runtime' }, { 'channel' }, - { 'queue' }, { 'cond' }, { 'sleep' }, { 'waitgroup' }, diff --git a/tests/test_queue.lua b/tests/test_queue.lua deleted file mode 100644 index beec056..0000000 --- a/tests/test_queue.lua +++ /dev/null @@ -1,53 +0,0 @@ ---- Tests the Queue implementation. -print('testing: fibers.queue') - --- look one level up -package.path = "../src/?.lua;" .. package.path - -local queue = require 'fibers.queue' -local runtime = require 'fibers.runtime' - -local function equal(x, y) - if type(x) ~= type(y) then return false end - if type(x) == 'table' then - for k, v in pairs(x) do - if not equal(v, y[k]) then return false end - end - for k, _ in pairs(y) do - if x[k] == nil then return false end - end - return true - else - return x == y - end -end - -local log = {} -local function record(x) table.insert(log, x) end - -runtime.spawn_raw(function() - local q = queue.new() - record('a') - q:put('b') - record('c') - q:put('d') - record('e') - record(q:get()) - q:put('f') - record('g') - record(q:get()) - record('h') - record(q:get()) -end) - -local function run(...) - log = {} - runtime.current_scheduler:run() - assert(equal(log, { ... })) -end - --- With the new buffered channel implementation, the behavior is direct: -run('a', 'c', 'e', 'b', 'g', 'd', 'h', 'f') -for _ = 1, 20 do run() end - -print('test: ok') From c2dabaab0800e958a8c2e8589ed33a7bc3e676a9 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Wed, 10 Dec 2025 21:24:52 +0000 Subject: [PATCH 086/138] remove redundant old stream test --- tests/test_stream.lua | 41 ----------------------------------------- 1 file changed, 41 deletions(-) delete mode 100644 tests/test_stream.lua diff --git a/tests/test_stream.lua b/tests/test_stream.lua deleted file mode 100644 index 70083e7..0000000 --- a/tests/test_stream.lua +++ /dev/null @@ -1,41 +0,0 @@ ---- Tests the Stream implementation. -print('testing: fibers.stream') - --- look one level up -package.path = "../src/?.lua;" .. package.path - -local fibers = require 'fibers' -local stream = require 'fibers.stream' - -local function test() - local rd_io, wr_io = {}, {} - local rd, wr = stream.open(rd_io, true, false), stream.open(wr_io, false, true) - - function rd_io:close() end - - function rd_io:read() return 0 end - - function wr_io:write(buf, count) - rd.rx:write(buf, count) - return count - end - - function wr_io:close() end - - local message = "hello, world\n" - wr:setvbuf('line') - wr:write(message) - local message2 = rd:read_some_chars() - assert(message == message2) - assert(rd:read_some_chars() == nil) - - rd:close(); wr:close() -end - -local function main() - test() -end - -fibers.run(main) - -print('selftest: ok') From 7812c087650870a5a7b4d08d1d688ccfc1f55b0c Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Wed, 10 Dec 2025 22:41:41 +0000 Subject: [PATCH 087/138] renames scope:done_op() to scope:not_ok_op() --- DESIGN-NOTES.md | 4 ++-- EVENTS-AND-OPS.md | 4 ++-- src/fibers/scope.lua | 2 +- tests/test_scope.lua | 16 ++++++++-------- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/DESIGN-NOTES.md b/DESIGN-NOTES.md index 24dc0ff..d3248bb 100644 --- a/DESIGN-NOTES.md +++ b/DESIGN-NOTES.md @@ -155,7 +155,7 @@ Scopes implement fail-fast supervision: Cancellation is observable as an event: -- `Scope:done_op()` – an Op that becomes ready when the scope is cancelled or fails (but not on success), returning the error or cancellation reason; +- `Scope:not_ok_op()` – an Op that becomes ready when the scope is cancelled or fails (but not on success), returning the error or cancellation reason; - `Scope:join_op()` – an Op that becomes ready once the scope reaches a *terminal* status (after all child fibers and defers have completed), returning `(status, err)`. When a scope exits: @@ -541,7 +541,7 @@ Users can still catch and handle expected errors locally. Unexpected errors bubb Cancellation is expressed as part of the event algebra: - each scope has a cancellation condition; -- `Scope:done_op()` and the internal `cancel_op(scope)` both reuse this condition to build Ops that become ready on cancellation; +- `Scope:not_ok_op()` and the internal `cancel_op(scope)` both reuse this condition to build Ops that become ready on cancellation; - `Scope:run_op(ev)` races a user Op against cancellation; - `Scope:sync` and `Scope:perform` enforce that operations in failed or cancelled scopes do not silently proceed. diff --git a/EVENTS-AND-OPS.md b/EVENTS-AND-OPS.md index 16cdd5e..d67becf 100644 --- a/EVENTS-AND-OPS.md +++ b/EVENTS-AND-OPS.md @@ -36,7 +36,7 @@ Examples: * Timers: `sleep.sleep_op(dt)` * Streams: `stream:read_line_op()`, `stream:write_string_op("...")` * Processes: `cmd:run_op()`, `cmd:output_op()` -* Scopes: `scope:join_op()`, `scope:done_op()` +* Scopes: `scope:join_op()`, `scope:not_ok_op()` These all return `Op` values. @@ -296,7 +296,7 @@ A common pattern is “operation or cancellation, whichever first”: ```lua local scope = fibers.current_scope() local body = ch:get_op() -local cancel = scope:done_op() +local cancel = scope:not_ok_op() local ev = fibers.choice(body, cancel) diff --git a/src/fibers/scope.lua b/src/fibers/scope.lua index 5833036..f0f9cd5 100644 --- a/src/fibers/scope.lua +++ b/src/fibers/scope.lua @@ -335,7 +335,7 @@ end --- Op that fires when the scope is cancelled or fails. --- Returns the cancellation or failure reason when performed. ---@return Op -function Scope:done_op() +function Scope:not_ok_op() local ev = self._cancel_cond:wait_op() return ev:wrap(function() return self._error or "scope cancelled" diff --git a/tests/test_scope.lua b/tests/test_scope.lua index 24d1bd6..cf56bd9 100644 --- a/tests/test_scope.lua +++ b/tests/test_scope.lua @@ -512,10 +512,10 @@ local function test_sync_cancellation_race() end ------------------------------------------------------------------------------- --- 5. join_op and done_op (on failed/cancelled scopes) +-- 5. join_op and not_ok_op() (on failed/cancelled scopes) ------------------------------------------------------------------------------- -local function test_join_and_done_ops() +local function test_join_and_not_ok_op() -- Failed scope: body error. local failed_scope local st_fail, err_fail = scope.run(function(s) @@ -536,12 +536,12 @@ local function test_join_and_done_ops() end do - local ev = failed_scope:done_op() + local ev = failed_scope:not_ok_op() local reason = performer.perform(ev) - -- For a failed scope we also call cancel(error), so done_op + -- For a failed scope we also call cancel(error), so not_ok_op() -- should be triggered and report the same error. assert(tostring(reason):find("join test failure", 1, true), - "done_op on failed scope should report the failure reason") + "not_ok_op() on failed scope should report the failure reason") end -- Cancelled scope (explicit cancel, not body error). @@ -563,10 +563,10 @@ local function test_join_and_done_ops() end do - local ev = cancelled_scope:done_op() + local ev = cancelled_scope:not_ok_op() local reason = performer.perform(ev) assert(reason == "stop again", - "done_op on cancelled scope should report cancellation reason") + "not_ok_op() on cancelled scope should report cancellation reason") end end @@ -632,7 +632,7 @@ local function main() test_sync_respects_cancellation() test_sync_cancellation_race() - test_join_and_done_ops() + test_join_and_not_ok_op() test_fail_fast_from_child_fiber() io.stdout:write("OK\n") From 5ec28459b2942a07297d787eb7c27c4fce9dc904 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Thu, 11 Dec 2025 23:51:23 +0000 Subject: [PATCH 088/138] revert to standard LuaJIT 2.1 and reinstate PUC Lua integration testing --- .devcontainer/postCreateCommand.sh | 14 +++++++------- .github/workflows/ci.yml | 9 +++++++-- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/.devcontainer/postCreateCommand.sh b/.devcontainer/postCreateCommand.sh index 36fc65d..9d72939 100755 --- a/.devcontainer/postCreateCommand.sh +++ b/.devcontainer/postCreateCommand.sh @@ -15,6 +15,7 @@ sudo luarocks install cqueues sudo luarocks install http sudo luarocks install luaposix sudo luarocks install luacheck +sudo luarocks install luajit # install cffi-lua @@ -30,14 +31,13 @@ sudo ninja all sudo ninja test sudo cp cffi.so /usr/local/lib/lua/5.1/cffi.so +# install nixio + cd /tmp -git clone https://github.com/LuaJIT/LuaJIT -cd LuaJIT/ -git checkout v2.1.ROLLING -make && sudo make install -sudo ln -sf "$(ls -1 /usr/local/bin/luajit-2.1* | sort | tail -n 1)" /usr/local/bin/luajit -cd /tmp -rm -rf LuaJIT +sudo rm -rf cffi-lua +git clone https://github.com/Neopallium/nixio/ +cd nixio +sudo make install cd /workspaces/lua-fibers pre-commit install diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e971dfa..b70c2ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,11 +15,16 @@ jobs: chmod +x .devcontainer/postCreateCommand.sh ./.devcontainer/postCreateCommand.sh - - name: Run luajit 2.17 Tests in Tests Directory + - name: Run lua 5.1 Tests in Tests Directory + run: | + cd tests + lua test.lua + + - name: Run luajit 2.1 Tests in Tests Directory run: | cd tests luajit test.lua - name: Run Linter run: luacheck . - + From b0cc45e89660127dc7483d075ed6f9aab11f8e81 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Fri, 12 Dec 2025 00:00:01 +0000 Subject: [PATCH 089/138] stops terrible format on save thing --- .vscode/settings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 903c319..0748767 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,7 +2,7 @@ "editor.tabSize": 2, "editor.indentSize": "tabSize", "editor.insertSpaces": true, - "editor.formatOnSave": true, + "editor.formatOnSave": false, "editor.formatOnSaveMode": "modifications", "editor.codeActionsOnSave": { "source.fixAll.eslint": "never" @@ -14,4 +14,4 @@ "[lua]": { "editor.tabSize": 4 }, - } \ No newline at end of file + } From 328228b15ff410a2d97644a37e52acfb391a0041 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Fri, 12 Dec 2025 00:00:50 +0000 Subject: [PATCH 090/138] sigh, gitignore! --- .gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c17485e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*DS_Store From 0dd003c692f2ef853d977466e64774da8cd74af1 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Fri, 12 Dec 2025 00:03:46 +0000 Subject: [PATCH 091/138] goodbye scope:defer, arise scope:finally --- DESIGN-NOTES.md | 28 +- EXECUTION.md | 14 +- README.md | 360 +++++++++++++++++++--- STRUCTURED-CONCURRENCY.md | 89 ++++-- examples/03-scope-defers.lua | 10 +- examples/05-cancel-subprocess.lua | 4 +- examples/06-scope-command-shared-pipe.lua | 8 +- src/fibers.lua | 2 +- src/fibers/io/exec.lua | 8 +- src/fibers/scope.lua | 53 ++-- tests/test_scope.lua | 38 +-- 11 files changed, 461 insertions(+), 153 deletions(-) diff --git a/DESIGN-NOTES.md b/DESIGN-NOTES.md index d3248bb..60dc1b7 100644 --- a/DESIGN-NOTES.md +++ b/DESIGN-NOTES.md @@ -124,10 +124,10 @@ Scopes (`fibers.scope`) represent supervision domains and form a tree: - a parent (except the root); - a weak set of child scopes; - a waitgroup tracking child fibers; - - a LIFO stack of defers to run on exit; + - a LIFO stack of `finally` handlers to run on exit; - a cancellation condition and a join condition; - a status: `"running" | "ok" | "failed" | "cancelled"`; - - a primary error or cancellation reason, plus a list of additional failures from deferred cleanup handlers. + - a primary error or cancellation reason, plus a list of additional failures from finalisation handlers. Scopes are obtained via: @@ -156,16 +156,16 @@ Scopes implement fail-fast supervision: Cancellation is observable as an event: - `Scope:not_ok_op()` – an Op that becomes ready when the scope is cancelled or fails (but not on success), returning the error or cancellation reason; -- `Scope:join_op()` – an Op that becomes ready once the scope reaches a *terminal* status (after all child fibers and defers have completed), returning `(status, err)`. +- `Scope:join_op()` – an Op that becomes ready once the scope reaches a *terminal* status (after all child fibers and `finally` handlers have completed), returning `(status, err)`. When a scope exits: 1. a join worker waits for the internal waitgroup (all child fibers finished); 2. if still `"running"`, status is updated to `"ok"`; -3. defers are run in LIFO order; -4. any errors in defers either: - - if the scope would otherwise have been `"ok"`, turn it into `"failed"` and record the first defer error as the primary error; subsequent defer errors (if any) are recorded separately; or - - if the scope is already `"failed"` or `"cancelled"`, are recorded in the scope’s _defer_failures list without changing the primary error;5. the join condition is signalled, making `join_op` ready. +3. registered `finally` handlers are run in LIFO order; +4. any errors in `finally` handlers either: + - if the scope would otherwise have been `"ok"`, turn it into `"failed"` and record the first finaliser error as the primary error; subsequent finaliser errors (if any) are recorded separately; or + - if the scope is already `"failed"` or `"cancelled"`, are recorded in the scope’s list of additional finaliser failures without changing the primary error; 5. the join condition is signalled, making `join_op` ready. #### Ops under scopes @@ -468,7 +468,7 @@ Lifecycle Ops: Scope integration: - `exec.command` must be called from inside a fiber (it asserts `Runtime.current_fiber()`), and binds the `Command` to the current scope; -- the scope registers a `defer` that: +- the scope registers a `finally` handler that: - on scope exit: - performs a best-effort `shutdown_op`; - closes any owned stdio streams; @@ -550,9 +550,9 @@ The same pattern is used in the exec subsystem: - shutdown races process exit against a timer and then escalates; - `perform_with_scope_or_raw` ensures that exec helpers honour scope cancellation where appropriate, but can still complete shutdown once the owning scope is no longer running. -### 5.3 Defers and resource cleanup +### 5.3 Finalisers and resource cleanup -Each scope maintains LIFO defers via `scope:defer(fn)`: +Each scope maintains LIFO finalisers via `scope:finally(fn)`: - run exactly once when the scope exits (after child fibers finish); - can be used to: @@ -560,10 +560,10 @@ Each scope maintains LIFO defers via `scope:defer(fn)`: - shut down child processes; - release other resources tied to the scope’s lifetime. -Errors in defers: +Errors in finalisers: - convert `"ok"` into `"failed"` and record an error; or -- are appended to `_failures` if the scope was already failed or cancelled. +- are appended to the scope’s collection of additional failures if the scope was already failed or cancelled. `fibers.exec` uses this mechanism extensively to ensure that child processes are not left running beyond their scope. @@ -628,7 +628,7 @@ From non-fiber code: Inside `main_fn(scope, ...)`: - use `fibers.spawn(fn, ...)` to create child fibers under `scope`; -- use `fibers.run_scope`/`Scope.run` or `fibers.scope_op`/`Scope.with_op` to create further nested scopes where needed; +- use `fibers.run_scope`/`Scope.run` or `fibers.with_scope_op`/`Scope.with_op` to create further nested scopes where needed; - perform Ops via: - `fibers.perform(ev)` (uses the current scope); or - `scope:perform(ev)` / `scope:sync(ev)`. @@ -658,7 +658,7 @@ Express waits and coordination in terms of the event algebra and scopes: - bind logical units of work (requests, sessions, jobs) to their own scopes: - cancel the scope to cancel all associated work and resources; - - use defers to tie external resources to that scope. + - use `scope:finally` to tie external resources to that scope. --- diff --git a/EXECUTION.md b/EXECUTION.md index 1dc15d2..05eb4a9 100644 --- a/EXECUTION.md +++ b/EXECUTION.md @@ -398,7 +398,7 @@ Each `Command` is created with reference to the current scope: * `exec.command` asserts that it is called from inside a fiber. * The `Scope` returned by `Scope.current()` at that point is recorded. -* A `defer` handler is registered on that scope which: +* A `finally` handler is registered on that scope which: * performs a best-effort shutdown of the process; and * closes any streams owned by the command; and @@ -443,7 +443,7 @@ end) Here we run a long-lived process in its own child scope using `scope.run`. The updated `scope.run` returns: ```lua -status, err, defer_failures, ...body_results +status, err, finally_failures, ...body_results ``` In many cases only `status` and `err` are required. @@ -461,7 +461,7 @@ fibers.run(function(scope) } -- Run worker in a child scope for clearer lifetime boundaries. - local status, err, defer_failures, child_status, code, sig, cerr = + local status, err, extra_failures, child_status, code, sig, cerr = scope_mod.run(function(child_scope) -- Wait until the worker dies or the child scope is cancelled. return child_scope:perform(cmd:run_op()) @@ -469,16 +469,16 @@ fibers.run(function(scope) print("worker scope finished:", status, err) - -- Optional: record any errors from deferred cleanup in the worker scope. - for i, derr in ipairs(defer_failures) do - print("worker defer failure[" .. i .. "]:", derr) + -- Optional: record any errors from finaliser cleanup in the worker scope. + for i, derr in ipairs(extra_failures) do + print("worker extra failure[" .. i .. "]:", derr) end -- child_status/code/sig/cerr are the results from cmd:run_op(), if needed. end) ``` -Here, even if the outer `scope` fails due to some other fiber error, the `Command`’s defer handler will shut down the process and release its resources. +Here, even if the outer `scope` fails due to some other fiber error, the `Command`’s finaliser will shut down the process and release its resources. --- diff --git a/README.md b/README.md index 290f76e..3d0e11c 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,116 @@ # fibers -`fibers` is a small library for running many concurrent tasks in one Lua process, with structured lifetimes, cancellable operations, and integrated I/O and subprocess handling. +`fibers` is a small library for running many concurrent tasks in one Lua process, with: -It is aimed at “do a lot of things at once, shut them down cleanly, and know what failed”. +- structured lifetimes (supervision scopes), +- cancellable operations (the “event algebra”), +- integrated I/O and subprocess handling. + +It is aimed at Lua programmers who want to write robust, highly concurrent services and tools while staying in a single-threaded, easy-to-reason-about model. + +The main pattern is: + +```lua +local fibers = require 'fibers' + +local function main(scope) + -- normal code here +end + +fibers.run(main) +``` + +Inside `main` you work in terms of scopes, operations and ordinary Lua functions. The runtime, poller and backends handle scheduling and clean-up. + +--- + +## Highlights + +* **Fail-fast supervision scopes** + + Every fiber runs in a scope. If a child fails, its scope records the error, cancels siblings and runs finalisers to unwind resources. Failures are reported at `fibers.run` / `fibers.run_scope` boundaries; most application code can simply `error` or `assert` and let the scope handle it. + +* **First-class operations (“Ops”)** + + Anything that may block is represented as an `Op`: channel sends/receives, sleeps, I/O readiness, subprocess exit, scope completion, and so on. Ops can be combined with `choice`, `named_choice`, `boolean_choice`, `guard`, `bracket`, and `wrap`. Lean in to the algebra: it is highly composable and can often replace explicit helper fibers. + +* **Scopes as operations** + + Scopes themselves can be expressed as operations, so entire trees of work can be raced against other events (timeouts, cancellation triggers, I/O) using the same algebra as for channels and timers. + +* **Integrated I/O and subprocesses** + + Non-blocking file descriptors are wrapped as buffered `Stream` objects. Readiness integrates with the poller. The exec layer runs subprocesses with configurable stdio, exposes their lifetime as ops, and ensures they are shut down with their owning scope. --- ## Examples -### Run several tasks and fail fast on error +### 1. Fail fast and inspect failures at the boundary ```lua local fibers = require 'fibers' local sleep = require 'fibers.sleep' -fibers.run(function(scope) - -- Start two child tasks under the main scope. +local function main() + -- Start a child task under the main scope. fibers.spawn(function() sleep.sleep(0.5) print("first: finished ok") end) - fibers.spawn(function() - sleep.sleep(0.2) - error("second: something went wrong") - end) - -- Run some work in a nested child scope and observe its outcome. - local status, err, defer_errors = fibers.run_scope(function(child) - -- child is a Scope; if you want to associate work explicitly with it: - -- child:spawn(function(s) ... end) - -- - -- or just use ambient spawn: - -- fibers.spawn(function() ... end) + local status, err, extra_errors = fibers.run_scope(function(child) + child:finally(function() + print("finaliser 1 (outer)") + end) + + child:finally(function() + print("finaliser 2 (inner)") + -- This error is recorded as an additional failure. + error("finaliser 2 failed") + end) + + -- Child begins its work and fails + sleep.sleep(0.1) + error("child: boom") end) print("child scope status:", status, err) - if #defer_errors > 0 then - print("child scope defer failures:") - for i, e in ipairs(defer_errors) do + if #extra_errors > 0 then + print("child scope finaliser failures:") + for i, e in ipairs(extra_errors) do print(" [" .. i .. "]", e) end end -end) + + -- No need for extra blocking; run_scope already joins its child scope. +end + +fibers.run(main) ``` -If any child fails, its scope records the error, cancels its siblings and runs defers to clean up resources. The top-level `fibers.run` re-raises the primary failure for the outermost run, while `fibers.run_scope` returns the status, primary error and any additional defer-time failures. +Inside scopes you can freely `error` or `assert`. Uncaught failures: + +* are recorded by the scope, +* cancel sibling work, +* run finalisers for clean-up, +* and are reported at `fibers.run` / `fibers.run_scope`. + +There is usually no need for `pcall` in normal concurrent code; you inspect failures at the boundaries instead of catching them everywhere. + +If the top-level `main` fails, `fibers.run(main)` re-raises the primary failure. + --- -### Use channels and timeouts with the event algebra +### 2. Channels and timeouts using the event algebra ```lua local fibers = require 'fibers' local chan = require 'fibers.channel' local sleep = require 'fibers.sleep' -fibers.run(function(scope) +local function main() local c = chan.new() -- Producer @@ -64,17 +119,18 @@ fibers.run(function(scope) c:put("hello") end) - -- Consumer with timeout: either get a value, or time out after 1s. local function read_with_timeout() local read_op = c:get_op() local timeout_op = sleep.sleep_op(1.0) - -- Race the two operations. - local which, value = fibers.named_choice{ + -- One Op representing “either read or timeout”. + local ev = fibers.named_choice{ data = read_op, timeout = timeout_op, } + local which, value = fibers.perform(ev) + if which == "data" then return true, value else @@ -84,56 +140,262 @@ fibers.run(function(scope) local ok, value_or_err = read_with_timeout() print("result:", ok, value_or_err) -end) +end + +fibers.run(main) ``` -Here the channel read and the timer are both first-class operations. They participate in `choice`, support cancellation from their scope, and can be composed in the same way as other primitives. +Here: + +* the channel receive and the timer are both first-class operations, +* they participate in `named_choice`, +* they observe scope cancellation automatically, and +* they can be combined in the same way as other primitives. + +You do not need a special `with_timeout()` helper; racing an operation against `sleep.sleep_op` is the intended pattern. --- -### Run a subprocess bound to a scope +### 3. Race an entire subtree of work against a timeout + +Scopes themselves can be expressed as operations using `fibers.scope_op`. That allows whole trees of work to be raced as a single unit. ```lua local fibers = require 'fibers' -local exec = require 'fibers.exec' +local sleep = require 'fibers.sleep' + +local function main() + -- Build an Op that runs a child scope and completes when that scope does. + local function subtree_op() + return fibers.scope_op(function(child) + -- Launch some work under the child scope. + child:spawn(function() + sleep.sleep(2.0) + print("subtree: finished") + end) + + -- Represent the whole subtree as an Op: + -- this Op becomes ready when the child scope reaches a terminal state. + return child:join_op() + end) + end -fibers.run(function(scope) - local cmd = exec.command{ - "ls", "-l", - stdout = "pipe", - } + -- Race the subtree against a 1s timeout. + local ev = fibers.boolean_choice( + subtree_op(), -- true branch: subtree finishes (ok/failed/cancelled) + sleep.sleep_op(1.0) -- false branch: timeout + ) - local out, status, code, signal, err = - fibers.perform(cmd:output_op()) + local subtree_won, status = fibers.perform(ev) - if status == "exited" and code == 0 then - print(out) + if subtree_won then + print("subtree completed with status:", status) else - print("command failed:", status, code, signal, err) + print("timed out; subtree scope has been cancelled") end -end) +end + +fibers.run(main) +``` + +Because scopes are composable as ops: + +* you can express “do this whole batch of work, but only until X” in the same algebra you use for channels and timers; +* losing branches are cancelled and cleaned up via scope finalisers. + +--- + +### 4. Run a subprocess bound to a scope + +```lua +local fibers = require 'fibers' +local exec = require 'fibers.exec' + +local function main() + fibers.run_scope(function() + local cmd = exec.command{ + "ls", "-l", + stdout = "pipe", -- capture stdout + } + + -- output_op returns: + -- out, status, code, signal, err + local out, status, code, signal, err = fibers.perform(cmd:output_op()) + + if status == "exited" and code == 0 then + print(out) + else + print("command failed:", status, code, signal, err) + end + end) +end + +fibers.run(main) ``` -The `Command` is attached to the current scope. On scope exit it is given a grace period to shut down, then killed if still running, and any associated streams are closed. +The `Command` is attached to the current scope. On scope exit: + +* it is given a grace period to shut down politely, +* then killed if still running, +* and any owned streams and backend handles are closed. + +From user code, process operations are just more ops: they can be raced against timeouts, scope cancellation, or other events using the same tools as channels and timers. --- ## Concepts in brief -A **fiber** is a lightweight task scheduled by the runtime. `fibers.run` starts the scheduler and a root supervision scope; `fibers.spawn` creates more fibers under the current scope; if you need the scope explicitly you can obtain it via `fibers.current_scope()` or by using `fibers.run_scope`, which passes the child scope to its body. +### Fibers + +A **fiber** is a lightweight task scheduled by the runtime. + +* `fibers.run(main)` starts the scheduler and a root supervision scope, runs `main` inside a fiber, and returns only when the root scope has finished and cleaned up. +* `fibers.spawn(fn, ...)` creates new fibers under the current scope and calls `fn(...)` in them. + +Once you are inside `fibers.run`, there are no “coloured” functions beyond the requirement that blocking ops must be performed from inside a fiber. You do not manually join fibers; scopes track them and clean them up. + +### Scopes + +A **scope** is a supervision domain with a tree structure and fail-fast semantics. + +* Each fiber runs within some scope. +* When a scope fails or is cancelled: + + * child scopes are cancelled, + * child fibers stop as their ops observe cancellation, + * finalisers registered with `scope:finally` run in LIFO order to clean up resources. +* Scopes record: + + * a status (`"running"`, `"ok"`, `"failed"`, `"cancelled"`), + * a primary error or cancellation reason, + * any additional finaliser-time failures. + +Scopes can be: + +* observed from outside via `fibers.run_scope` or `scope:join_op()` / `scope:done_op()`, and +* wrapped as operations via `fibers.scope_op`, so that entire subtrees of work can participate in `choice`. + +Inside a scope, normal code typically does not wait for the scope itself; it registers finalisers and lets the parent scope observe status. + +### Operations (`Op`) + +An **operation** (`Op`) represents something that may block: + +* channel sends and receives, +* sleeps and timeouts, +* I/O readiness and stream reads/writes, +* subprocess completion, +* scope completion or cancellation, +* and any other primitive you build in the same style. + +Ops are not tied to a particular fiber until they are performed. They can be: + +* combined with `fibers.choice`, `fibers.named_choice`, `fibers.boolean_choice`, and `fibers.race`, +* delayed with `fibers.guard`, +* bracketed with acquire/release logic via `fibers.bracket`, +* post-processed via `:wrap`, +* given abort behaviour using negative acknowledgements (`with_nack` / `on_abort`). + +To perform an operation: + +* use `fibers.perform(op)` inside a fiber, which: + + * honours the current scope’s cancellation rules, + * treats scope failure as an error, + * and otherwise returns the operation’s results. + +Where you genuinely need non-cancellable behaviour (for example, in some finalisers), lower-level functions are available (`perform_raw`), but most application code should use `fibers.perform`. + +### I/O and streams + +The **I/O layer** wraps non-blocking file descriptors in buffered `Stream` objects: + +* integrates readability/writability with the poller (epoll, or poll/select), +* exposes operations such as: + + * `read_line_op`, `read_all_op`, `read_exactly_op`, + * `write_string_op`, and their synchronous wrappers, +* supports files, pipes, UNIX sockets and other backends through adaptor modules. -An **operation (`Op`)** represents something that may block: reading from a channel, waiting for a timeout, waiting for a process to exit, and so on. Operations can be composed with `choice`, guarded, bracketed with acquire/release logic, or wrapped to add behaviour. They are not tied to a particular task until performed. +Since `Stream` operations are ops, they can be raced and cancelled like any other blocking activity. -A **scope** is a supervision domain with a tree structure and fail-fast semantics. Each fiber runs within some scope. When a scope fails or is cancelled, child scopes are cancelled and their work is unwound using registered defers. Scopes expose operations to wait for completion or cancellation, and wrappers to run other operations under their cancellation policy. +### Subprocesses -The **I/O layer** wraps non-blocking file descriptors in buffered `Stream` objects, integrates readiness with the poller (epoll or poll/select), and provides helpers for files, pipes and UNIX sockets. The **exec layer** builds on this to start subprocesses with configurable stdio, expose their lifetime as an `Op`, and ensure they are shut down with their owning scope. +The **exec layer** runs subprocesses under scopes: -The intent is that “something might block” is always expressed as an `Op`, and “something might live for a while” is always owned by a scope. The runtime, poller and backends supply the mechanics; application code mostly works in terms of scopes, operations and normal Lua functions. +* builds a `Command` from an argv vector and stdio configuration, +* starts the process lazily when you first use it, +* exposes its lifetime as ops: + + * `run_op` – wait for exit, + * `shutdown_op` – attempt graceful shutdown with a grace period then force kill, + * `output_op` – capture stdout (and optionally stderr) and wait for exit, +* attaches process clean-up to scope finalisers, so that processes are always shut down when their owning scope ends. + +Again, callers mostly see these as just another family of ops. + +--- + +## Error handling + +Inside a scope: + +* letting an error escape a fiber is the normal way to signal failure; +* the scope tracks the first failure as its primary error and cancels siblings; +* finalisers run regardless and may themselves fail, in which case finaliser failures are recorded separately. + +At the boundary: + +* `fibers.run(main)` re-raises the primary error of the root scope (or returns normally on success), +* `fibers.run_scope(fn)` returns: + + * `status`, `err`, `extra_errors`, and any results returned by `fn`. + +Because errors are funnelled through scopes in this way, `pcall` is rarely needed in ordinary concurrent code. You use it only where you truly want local recovery. --- ## Requirements and installation -The library targets Lua 5.1–5.4 and LuaJIT on POSIX-like systems. It prefers FFI-based backends (epoll, pidfd) where available, and falls back to luaposix-based poll/select and SIGCHLD backends otherwise. +### Lua and platform + +* Lua 5.1–5.5 or LuaJIT. +* A POSIX-like platform (currently developed and tested on Linux). + +### Backend support + +`fibers` uses a pluggable backend for polling and subprocess handling. You need at least one of the following stacks available: + +* **FFI backend (preferred)** + * LuaJIT, or PUC Lua with [cffi-lua](https://github.com/q66/cffi-lua) + * Uses `epoll` for I/O and `pidfd` for process completion. + +* **luaposix backend** + * `luaposix` + * Uses `poll`/`select` plus `SIGCHLD` for process completion. + +* **nixio backend** + * `nixio` + * Uses a double-fork scheme for process completion and `poll` for I/O. + +The library will prefer an FFI-based backend when available, and otherwise fall back to a `luaposix`-based or `nixio`-based backend. + +OS-specific code is isolated in these backends (`fibers.io.*_backend` and the poller), so adding support for other platforms (eg. FreeBSD, macOS, Windows) should be largely a matter of implementing a compatible backend. + +### Installation + +Add the repository to your `package.path` (and `package.cpath` if necessary) so that modules such as `fibers`, `fibers.channel`, `fibers.sleep`, `fibers.io.file` and `fibers.exec` can be `require`d. + +Once available, the typical entry point is: + +```lua +local fibers = require 'fibers' + +local function main() + -- application code here +end + +fibers.run(main) +``` -Add the repository to your `package.path` so that modules such as `fibers`, `fibers.channel`, `fibers.sleep` and `fibers.io.file` can be `require`d. +From that point on, application code is structured in terms of scopes, operations and normal Lua functions. The runtime ensures that blocking work is expressed as ops, long-lived work is owned by scopes, and everything is shut down cleanly. diff --git a/STRUCTURED-CONCURRENCY.md b/STRUCTURED-CONCURRENCY.md index 332a7ca..e106594 100644 --- a/STRUCTURED-CONCURRENCY.md +++ b/STRUCTURED-CONCURRENCY.md @@ -22,7 +22,7 @@ The library uses *structured concurrency*: - Every fiber runs inside a *scope*. - Scopes form a tree. A scope may have child scopes. - Failures are tracked per scope and cause *fail-fast* cancellation of that scope and its children. -- Resources (streams, processes, etc.) are attached to scopes and cleaned up via defers when a scope finishes. +- Resources (streams, processes, etc.) are attached to scopes and cleaned up via finalisers when a scope finishes. Finalisers are told whether the scope finished successfully or was aborted (failed or cancelled). A useful way to think about this is: @@ -74,7 +74,7 @@ This is the primary way to introduce concurrency under the current scope. ### 2.3 `fibers.run_scope(body_fn, ...)` ```lua -local status, err, defer_failures, result1, result2 = fibers.run_scope(function(child_scope, arg) +local status, err, extra_failures, result1, result2 = fibers.run_scope(function(child_scope, arg) -- child_scope is a new child of the current scope return "ok:" .. arg, 42 end, "value") @@ -91,11 +91,16 @@ end, "value") ```lua status :: "ok" | "failed" | "cancelled" err :: primary error or cancellation reason (nil when status == "ok") - defer_failures :: array of additional errors from deferred handlers + extra_failures :: array of additional errors from finalisers ... :: results from body_fn (only when status == "ok") ``` -This gives a way to treat a block of concurrent work as a value-returning operation, with explicit success/failure information. If the scope would otherwise have completed successfully, the first failing defer promotes the scope to `"failed"` and becomes the primary `err`; only subsequent defer errors are recorded in `defer_failures`. +This gives a way to treat a block of concurrent work as a value-returning operation, with explicit success/failure information. When the child scope drains (all fibers complete) it runs its finalisers in LIFO order, passing each `(aborted, status, err)` where: +* `status` is the final scope status (`"ok"`, `"failed"`, `"cancelled"`), +* `err` is the primary error / cancellation reason (if any), and +* `aborted` is `true` when `status ~= "ok"`. + +If the scope would otherwise have completed successfully, the first failing finaliser promotes the scope to `"failed"` and becomes the primary `err`; only subsequent finaliser errors are recorded in `extra_failures`. ### 2.4 `fibers.scope_op(build_op)` @@ -151,13 +156,13 @@ Each scope has a status: * `"running"` – initial state. * `"ok"` – scope completed successfully. -* `"failed"` – a fiber in the scope raised an error, or a defer handler failed. +* `"failed"` – a fiber in the scope raised an error, or a finaliser failed. * `"cancelled"` – scope was cancelled explicitly or by a parent’s failure. Internally, a scope also tracks: * a primary error or cancellation reason (`_error`), and -* any additional errors from deferred handlers in a list (`_defer_failures`). +* any additional errors from finalisers in a list (`_extra_failures`). ### 3.1 How failures are recorded @@ -170,7 +175,7 @@ If a fiber running in a scope raises a Lua error while the scope is `"running"`: Subsequent errors from other fibers in the same scope are treated as cancellation noise and are not accumulated. -Errors raised by deferred handlers are handled separately and are described in section 4. +Errors raised by finalisers are handled separately and are described in section 4. ### 3.2 How cancellation works @@ -193,7 +198,7 @@ Scopes passed into your functions support: ```lua local status, err = scope:status() -local defer_errors = scope:defer_failures() +local extra_errors = scope:extra_failures() local parent = scope:parent() local children = scope:children() ``` @@ -202,23 +207,52 @@ These methods are mainly useful for diagnostics or building higher-level abstrac --- -## 4. Resource management with defers +## 4. Resource management with finalisers -Each scope maintains a LIFO list of deferred handlers, registered with: +Each scope maintains a LIFO list of finalisers, registered with: ```lua -scope:defer(function(s) - -- cleanup work; s is the same scope +scope:finally(function(aborted, status, err) + -- cleanup work end) -``` +```` -Defers run when the scope transitions from `"running"` to a terminal state (`"ok"`, `"failed"`, `"cancelled"`): +Finalisers run when the scope transitions from `"running"` to a terminal state (`"ok"`, `"failed"`, `"cancelled"`), after all child fibers in that scope have finished: * They run in reverse registration order (LIFO). -* If a defer raises an error: - * If the scope was `"ok"`, it becomes `"failed"` and the defer’s error becomes the primary error. - * Otherwise the error is added to the scope’s `defer_failures` list. +* Each finaliser is called as: + + ```lua + function(aborted, status, err) end + ``` + + where: + + * `status` is the final scope status (`"ok"`, `"failed"`, `"cancelled"`), + * `err` is the primary error or cancellation reason (if any), and + * `aborted` is `true` when `status ~= "ok"` (that is, on failure or cancellation). + +* If a finaliser raises an error: + + * If the scope was `"ok"`, it becomes `"failed"` and the finaliser’s error becomes the primary error. + * Otherwise the error is added to the scope’s `extra_failures` list. + +You can ignore any arguments you do not need. All of the following are valid: + +```lua +-- Only care whether the scope was aborted +scope:finally(function(aborted) + if aborted then + log("scope aborted") + end +end) + +-- Care about full status information +scope:finally(function(aborted, status, err) + log(("scope finished: status=%s err=%s"):format(status, tostring(err))) +end) +``` A typical pattern is to attach resources to the current scope: @@ -230,10 +264,17 @@ fibers.run(function(scope) local f, err = file.open("output.log", "w") if not f then error(err) end - scope:defer(function() + scope:finally(function(aborted, status, serr) local ok, cerr = f:close() if not ok then - error(cerr or "close failed") + -- Treat close failure as a scope failure if we otherwise succeeded. + if not aborted then + error(cerr or "close failed") + else + -- On an already-failed/cancelled scope, you might just log. + io.stderr:write("close failed during aborted scope: " + .. tostring(cerr) .. "\n") + end end end) @@ -241,7 +282,7 @@ fibers.run(function(scope) end) ``` -Many library components, such as process `Command` objects, register their own defers against the owning scope so that processes and pipes are cleaned up automatically when the scope ends. +Many library components, such as process `Command` objects, register their own finalisers against the owning scope so that processes and pipes are cleaned up automatically when the scope ends. Because finalisers see `(aborted, status, err)`, they can distinguish between normal shutdown and cleanup during failure or cancellation and behave accordingly. --- @@ -297,7 +338,7 @@ local function run_workers(n) end fibers.run(function(scope) - local status, err, defer_failures = run_workers(5) + local status, err, extra_failures = run_workers(5) if status == "ok" then print("all workers completed successfully") @@ -367,8 +408,8 @@ Key points: Structured concurrency underpins the rest of the library: * **Channels and other primitives** expose operations (`*_op`) that you run using `fibers.perform`. The current scope’s cancellation rules apply to all of them. -* **I/O** (`fibers.io.stream`, `fibers.io.file`, `fibers.io.socket`) creates streams and sockets that are typically tied to the lifecycle of a scope via `scope:defer`. -* **Process execution** (`fibers.exec`) registers a defer on the owning scope to ensure processes are shut down and associated streams are closed when the scope exits. +* **I/O** (`fibers.io.stream`, `fibers.io.file`, `fibers.io.socket`) creates streams and sockets that are typically tied to the lifecycle of a scope via `scope:finally`. +* **Process execution** (`fibers.exec`) registers a finaliser on the owning scope to ensure processes are shut down and associated streams are closed when the scope exits. As a result, an application built around scopes can use “scope ends” as the primary signal for cleaning up everything associated with that logical unit of work. @@ -397,7 +438,7 @@ In most applications this is not required; errors in user code should normally b * Use `fibers.spawn` to start fibers under the current scope. * Use `fibers.run_scope` when you want a sub-task with its own scope and a `(status, err, ...)` result. * Use `fibers.scope_op` when you need a scoped block to participate directly in `choice` and other event combinators. -* Use `scope:defer` to attach resource cleanup to the scope lifetime. +* Use `scope:finally` to attach resource cleanup to the scope lifetime. * Use `fibers.perform` to run operations so that cancellation and failure follow the scope tree. This provides a disciplined way to structure concurrent programs so that lifetimes, failures and cleanup are explicit, bounded, and predictable. diff --git a/examples/03-scope-defers.lua b/examples/03-scope-defers.lua index 9d0f5be..618180d 100644 --- a/examples/03-scope-defers.lua +++ b/examples/03-scope-defers.lua @@ -8,14 +8,14 @@ fibers.run(function() local function worker() local s = fibers.current_scope() - s:defer(function() - print("defer 1 (outer)") + s:finally(function() + print("finaliser 1 (outer)") end) - s:defer(function() - print("defer 2 (inner)") + s:finally(function() + print("finaliser 2 (inner)") -- This error is recorded as an additional failure. - error("defer 2 failed") + error("finaliser 2 failed") end) print("worker body starting") diff --git a/examples/05-cancel-subprocess.lua b/examples/05-cancel-subprocess.lua index ef565c9..de2829f 100644 --- a/examples/05-cancel-subprocess.lua +++ b/examples/05-cancel-subprocess.lua @@ -90,7 +90,7 @@ local function main() -- Note: -- * If the timeout wins, we do not try to handle cancellation -- inside the subscope. Instead we cancel the subscope from - -- here and let its defers (including Command’s defer) run. + -- here and let its finalisers (including Command’s finaliser) run. -- local proc_won, status2, code, signal, err = perform(boolean_choice( @@ -113,7 +113,7 @@ local function main() -- This cancels: -- * the reader fiber, -- * any other children in this scope, and - -- * the Command’s scope defer will run _on_scope_exit(), which + -- * the Command’s scope finaliser will run _on_scope_exit(), which -- calls shutdown_op and waits for the process to die. -- print("[subscope] timeout reached; cancelling subprocess scope") diff --git a/examples/06-scope-command-shared-pipe.lua b/examples/06-scope-command-shared-pipe.lua index e5de830..cca42eb 100644 --- a/examples/06-scope-command-shared-pipe.lua +++ b/examples/06-scope-command-shared-pipe.lua @@ -6,7 +6,7 @@ local exec = require "fibers.io.exec" local file = require "fibers.io.file" local cond_mod = require "fibers.cond" -local scope_op = fibers.scope_op +local with_scope_op = fibers.with_scope_op local named_choice = fibers.named_choice local perform = fibers.perform @@ -20,8 +20,8 @@ local function main(parent_scope) local reader_done = cond_mod.new() -- Ensure streams are closed even if something goes wrong. - parent_scope:defer(function() - print("[parent] defer: closing shared streams") + parent_scope:finally(function() + print("[parent] finaliser: closing shared streams") assert(r_stream:close()); assert(w_stream:close()) end) @@ -48,7 +48,7 @@ local function main(parent_scope) ---------------------------------------------------------------------- -- Child scope as an Op: command writes ticks to the shared stream ---------------------------------------------------------------------- - local child_scope_op = scope_op(function() + local child_scope_op = with_scope_op(function() print("[child] building child scope op") local script = [[for i in 0 1 2 3 4 5 6 7 8 9; do echo "tick $i"; sleep 1; done]] diff --git a/src/fibers.lua b/src/fibers.lua index 255e7cb..2896245 100644 --- a/src/fibers.lua +++ b/src/fibers.lua @@ -133,7 +133,7 @@ return { -- Scope utilities re-exported run_scope = Scope.run, - scope_op = Scope.with_op, + with_scope_op = Scope.with_op, set_unscoped_error_handler = Scope.set_unscoped_error_handler, current_scope = Scope.current } diff --git a/src/fibers/io/exec.lua b/src/fibers/io/exec.lua index c8e2823..7ce25e1 100644 --- a/src/fibers/io/exec.lua +++ b/src/fibers/io/exec.lua @@ -112,7 +112,7 @@ end --- Perform an op using the current scope when it is still running, --- otherwise fall back to a raw perform. This lets normal calls to ---- exec ops honour scope cancellation, while scope defers can still +--- exec ops honour scope cancellation, while scope finalisers can still --- run cleanup after the scope has reached a terminal state. ---@param ev Op ---@return any ... @@ -578,11 +578,11 @@ end -- Scope cleanup ---------------------------------------------------------------------- ---- Scope defer handler: best-effort shutdown and resource cleanup. +--- Scope finaliser: best-effort shutdown and resource cleanup. function Command:_on_scope_exit() if self._started and not self._done then -- Best-effort shutdown. Any error here will be caught by the - -- scope's defer machinery and recorded as a scope failure. + -- scope's finaliser machinery and recorded as a scope failure. op.perform_raw(self:shutdown_op(self._shutdown_grace)) end @@ -645,7 +645,7 @@ local function command_from_spec(spec) _err = nil, }, Command) - scope:defer(function() + scope:finally(function() cmd:_on_scope_exit() end) diff --git a/src/fibers/scope.lua b/src/fibers/scope.lua index f0f9cd5..afbf489 100644 --- a/src/fibers/scope.lua +++ b/src/fibers/scope.lua @@ -25,10 +25,10 @@ end ---@field _children table # weak-key set of child scopes ---@field _status ScopeStatus ---@field _error any ----@field _defer_failures any[] +---@field _finally_failures any[] ---@field failure_mode string # e.g. "fail_fast" ---@field _wg Waitgroup ----@field _defers fun(self: Scope)[] # LIFO defers +---@field _finalisers fun(self: Scope)[] # LIFO finalisers ---@field _cancel_cond Cond ---@field _join_cond Cond ---@field _join_worker_started boolean @@ -87,11 +87,11 @@ local function new_scope(parent) _status = "running", _error = nil, - _defer_failures = {}, + _finally_failures = {}, failure_mode = "fail_fast", _wg = waitgroup.new(), - _defers = {}, + _finalisers = {}, _cancel_cond = cond.new(), _join_cond = cond.new(), @@ -165,11 +165,12 @@ function Scope:new_child() return new_scope(self) end ---- Register a deferred handler to run when the scope closes (LIFO). +--- Register a finaliser to run when the scope closes (LIFO). ---@param handler fun(self: Scope) -function Scope:defer(handler) - local defers = self._defers - defers[#defers + 1] = handler +function Scope:finally(handler) + assert(type(handler) == "function", "scope:finally expects a function") + local finalisers = self._finalisers + finalisers[#finalisers + 1] = handler end ---@param reason any|nil @@ -261,11 +262,11 @@ function Scope:status() return self._status, self._error end ---- Return a shallow copy of non-primary defer failures recorded on this scope. +--- Return a shallow copy of non-primary finaliser failures recorded on this scope. ---@return any[] -function Scope:defer_failures() +function Scope:finally_failures() local out = {} - local f = self._defer_failures or {} + local f = self._finally_failures or {} for i, v in ipairs(f) do out[i] = v end @@ -276,7 +277,7 @@ end -- Join and done ops ---------------------------------------------------------------------- --- Internal: start a join worker that awaits children, runs defers and +-- Internal: start a join worker that awaits children, runs finalisers and -- finalises status, then signals _join_cond. function Scope:_start_join_worker() if self._join_worker_started then return end @@ -296,18 +297,22 @@ function Scope:_start_join_worker() self._error = nil end - local defers = self._defers - for i = #defers, 1, -1 do - local f = defers[i] - defers[i] = nil + local status = self._status + local primary_err = self._error + local aborted = (status ~= "ok") - local ok, err = safe.pcall(f, self) + local finalisers = self._finalisers + for i = #finalisers, 1, -1 do + local f = finalisers[i] + finalisers[i] = nil + + local ok, err = safe.pcall(f, aborted, status, primary_err) if not ok then if self._status == "ok" then self._status = "failed" self._error = self._error or err else - local failures = self._defer_failures + local failures = self._finally_failures failures[#failures + 1] = err end end @@ -475,13 +480,13 @@ end --- Returns: --- status :: "ok" | "failed" | "cancelled" --- err :: primary error or cancellation reason (nil on "ok") ---- defer_failures :: array of non-primary errors from deferred function recorded on the child scope +--- extra_failures :: array of non-primary errors from finaliser function recorded on the child scope --- ... :: any results returned from body_fn (only present on "ok") ---@param body_fn fun(s: Scope, ...): ... ---@param ... any ---@return ScopeStatus status ---@return any err ----@return any[] defer_failures +---@return any[] finally_failures ---@return any ... local function run(body_fn, ...) assert(runtime.current_fiber(), "scope.run must be called from inside a fiber") @@ -498,15 +503,15 @@ local function run(body_fn, ...) local status, err = op.perform_raw(child:join_op()) - -- Shallow copy of non-primary defer failures; will be {} when there are none. - local extra = child:defer_failures() + -- Shallow copy of non-primary finaliser failures; will be {} when there are none. + local extra = child:finally_failures() local res = child._result if res then - -- status, primary_err, defer_failures, ...body results... + -- status, primary_err, extra_failures, ...body results... return status, err, extra, unpack(res, 1, res.n) else - -- status, primary_err, defer_failures + -- status, primary_err, extra_failures return status, err, extra end end diff --git a/tests/test_scope.lua b/tests/test_scope.lua index cf56bd9..fbb8c21 100644 --- a/tests/test_scope.lua +++ b/tests/test_scope.lua @@ -296,7 +296,7 @@ local function test_with_op_child_fiber_failure() assert(res == "ok", "with_op main op should still return its result") -- At this point, with_op's release will have waited for the child scope - -- to close, including both spawned child fibers and defers. + -- to close, including both spawned child fibers and finalisers. end) assert(st == "ok" and serr == nil, @@ -368,14 +368,14 @@ end -- 3. Defers: LIFO ordering and execution on failure ------------------------------------------------------------------------------- -local function test_defers_lifo_and_failure() +local function test_finalisers_lifo_and_failure() local order = {} local scope_ref local st, serr = scope.run(function(s) scope_ref = s - s:defer(function() table.insert(order, "first") end) - s:defer(function() table.insert(order, "second") end) + s:finally(function() table.insert(order, "first") end) + s:finally(function() table.insert(order, "second") end) error("boom in body") end) @@ -388,36 +388,36 @@ local function test_defers_lifo_and_failure() assert(tostring(serr2):find("boom in body", 1, true), "scope error should mention the body error") - assert(#order == 2, "two defers should have run") + assert(#order == 2, "two finalisers should have run") assert(order[1] == "second" and order[2] == "first", - "defers should run in LIFO order even on failure") + "finalisers should run in LIFO order even on failure") end -- Defer failures after a successful body should turn the scope to 'failed' --- and surface the defer error as primary, but still preserve body results. -local function test_defer_failure_marks_scope_failed() +-- and surface the finaliser error as primary, but still preserve body results. +local function test_extra_failure_marks_scope_failed() local scope_ref local st, serr, _, body_res = scope.run(function(s) scope_ref = s - s:defer(function() - error("defer failure") + s:finally(function() + error("finaliser failure") end) return "body-result" end) assert(st == "failed", - "scope.run should report failed if a defer handler fails") - assert(tostring(serr):find("defer failure", 1, true), - "defer failure should be the primary error") + "scope.run should report failed if a finaliser fails") + assert(tostring(serr):find("finaliser failure", 1, true), + "finaliser failure should be the primary error") local st2, serr2 = scope_ref:status() - assert(st2 == "failed", "scope status should be failed after defer failure") - assert(tostring(serr2):find("defer failure", 1, true), - "scope error should mention the defer failure") + assert(st2 == "failed", "scope status should be failed after finaliser failure") + assert(tostring(serr2):find("finaliser failure", 1, true), + "scope error should mention the finaliser failure") assert(body_res == "body-result", - "scope.run should still return body results even if defers fail") + "scope.run should still return body results even if finalisers fail") end ------------------------------------------------------------------------------- @@ -625,8 +625,8 @@ local function main() test_run_success_and_failure() test_run_explicit_cancel() - test_defers_lifo_and_failure() - test_defer_failure_marks_scope_failed() + test_finalisers_lifo_and_failure() + test_extra_failure_marks_scope_failed() test_sync_wraps_op_failure() test_sync_respects_cancellation() From 46bf3220e108f646f5adf0c03b38dce66de5424e Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Fri, 12 Dec 2025 01:00:46 +0000 Subject: [PATCH 092/138] upgrade devcontainer base to Ubuntu 24.04 --- .devcontainer/Dockerfile | 41 +++++++++++++++++------------- .devcontainer/postCreateCommand.sh | 4 +-- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index cd44159..1491704 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,23 +1,30 @@ -FROM ubuntu:22.04 +FROM ubuntu:24.04 -# Install needed packages and setup non-root user. Use a separate RUN statement to add your own dependencies. ARG USERNAME=vscode ARG USER_UID=1000 ARG USER_GID=$USER_UID -# Create the user -RUN groupadd --gid $USER_GID $USERNAME \ - && useradd --uid $USER_UID --gid $USER_GID -m $USERNAME \ - # - # [Optional] Add sudo support. Omit if you don't need to install software after connecting. - && apt-get update \ - && apt-get install -y sudo \ - && echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \ - && chmod 0440 /etc/sudoers.d/$USERNAME +RUN set -eux; \ + # If UID/GID already exist (Ubuntu 24.04 ships with ubuntu:1000/1000), rename to requested user/group + existing_user="$(getent passwd "${USER_UID}" | cut -d: -f1 || true)"; \ + existing_group="$(getent group "${USER_GID}" | cut -d: -f1 || true)"; \ + if [ -n "${existing_group}" ] && [ "${existing_group}" != "${USERNAME}" ] && ! getent group "${USERNAME}" >/dev/null; then \ + groupmod -n "${USERNAME}" "${existing_group}"; \ + fi; \ + if [ -n "${existing_user}" ] && [ "${existing_user}" != "${USERNAME}" ] && ! id -u "${USERNAME}" >/dev/null 2>&1; then \ + usermod -l "${USERNAME}" "${existing_user}"; \ + usermod -d "/home/${USERNAME}" -m "${USERNAME}"; \ + fi; \ + # Ensure group exists (by name) and the user is on the intended UID/GID + getent group "${USERNAME}" >/dev/null || groupadd --gid "${USER_GID}" "${USERNAME}"; \ + id -u "${USERNAME}" >/dev/null 2>&1 || useradd --uid "${USER_UID}" --gid "${USER_GID}" -m "${USERNAME}"; \ + usermod -u "${USER_UID}" "${USERNAME}"; \ + usermod -g "${USER_GID}" "${USERNAME}"; \ + \ + apt-get update; \ + apt-get install -y sudo; \ + rm -rf /var/lib/apt/lists/*; \ + echo "${USERNAME} ALL=(root) NOPASSWD:ALL" > "/etc/sudoers.d/${USERNAME}"; \ + chmod 0440 "/etc/sudoers.d/${USERNAME}" -# ******************************************************** -# * Anything else you want to do like clean up goes here * -# ******************************************************** - -# [Optional] Set the default user. Omit if you want to keep the default as root. -USER $USERNAME \ No newline at end of file +USER $USERNAME diff --git a/.devcontainer/postCreateCommand.sh b/.devcontainer/postCreateCommand.sh index 9d72939..3491b7c 100755 --- a/.devcontainer/postCreateCommand.sh +++ b/.devcontainer/postCreateCommand.sh @@ -2,11 +2,11 @@ sudo apt update -y -sudo apt install -y apt-utils unzip curl wget git build-essential libreadline-dev dialog libssl-dev m4 netcat pre-commit +sudo apt install -y apt-utils unzip curl wget git build-essential libreadline-dev dialog libssl-dev m4 netcat-traditional pre-commit # install core lua packages -sudo apt install -y lua5.1 liblua5.1-dev luarocks +sudo apt install -y lua5.1 liblua5.1-0-dev luarocks # install luarocks packages From fa7e8375de528054e440144628bb0bf082f66731 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Fri, 12 Dec 2025 01:01:22 +0000 Subject: [PATCH 093/138] correct LuaLS annotations --- src/fibers/alarm.lua | 12 +++++++----- src/fibers/io/exec_backend.lua | 6 ------ tests/test_alarm.lua | 2 +- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/fibers/alarm.lua b/src/fibers/alarm.lua index 6ae068b..82ffb55 100644 --- a/src/fibers/alarm.lua +++ b/src/fibers/alarm.lua @@ -219,12 +219,14 @@ Alarm.event = Alarm.wait_op -- Constructors ---------------------------------------------------------------------- +---@class AlarmNewParams +---@field next_time fun(last: number|nil, now: number): number|nil +---@field policy any? +---@field label string? + --- Create a new alarm. --- --- params.next_time :: function(last_epoch|nil, now_epoch) -> next_epoch|nil --- params.policy :: optional policy table (DST, gaps, overlaps, etc.) --- params.label :: optional label for identification/logging ----@param params { next_time: fun(last: number|nil, now: number): number|nil, policy?: any, label?: string } +--- +---@param params AlarmNewParams ---@return Alarm local function new(params) assert(type(params) == "table", "alarm.new expects a parameter table") diff --git a/src/fibers/io/exec_backend.lua b/src/fibers/io/exec_backend.lua index ffca2e1..c3902b5 100644 --- a/src/fibers/io/exec_backend.lua +++ b/src/fibers/io/exec_backend.lua @@ -13,12 +13,6 @@ ---@field stdout ExecStreamConfig ---@field stderr ExecStreamConfig ----@class ProcHandle ----@field backend ExecBackend ----@field stdin Stream|nil ----@field stdout Stream|nil ----@field stderr Stream|nil - --- Backend module interface. ---@class ExecBackendModule ---@field is_supported fun(): boolean diff --git a/tests/test_alarm.lua b/tests/test_alarm.lua index 3ea401c..c80635b 100644 --- a/tests/test_alarm.lua +++ b/tests/test_alarm.lua @@ -335,7 +335,7 @@ local function test_exhaustion_after_reschedule() local al = alarm_mod.new{ next_time = next_time } - local r1, r2 = {} + local r1, r2 = {}, {} fibers.spawn(function() -- First wait: should fire once. From 225549d139802d7777d3e1e9d12eee2b71f6776f Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Fri, 12 Dec 2025 02:06:00 +0000 Subject: [PATCH 094/138] corrects LuaJIT installation method --- .devcontainer/postCreateCommand.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.devcontainer/postCreateCommand.sh b/.devcontainer/postCreateCommand.sh index 3491b7c..15d9233 100755 --- a/.devcontainer/postCreateCommand.sh +++ b/.devcontainer/postCreateCommand.sh @@ -2,7 +2,7 @@ sudo apt update -y -sudo apt install -y apt-utils unzip curl wget git build-essential libreadline-dev dialog libssl-dev m4 netcat-traditional pre-commit +sudo apt install -y apt-utils unzip curl wget git build-essential libreadline-dev dialog libssl-dev m4 netcat-traditional pre-commit luajit # install core lua packages @@ -15,7 +15,6 @@ sudo luarocks install cqueues sudo luarocks install http sudo luarocks install luaposix sudo luarocks install luacheck -sudo luarocks install luajit # install cffi-lua From 3e9caa07f7701b834a909f17fe1e4593960f7dc3 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Fri, 12 Dec 2025 02:20:21 +0000 Subject: [PATCH 095/138] renames finally_failures to extra_errors --- EXECUTION.md | 6 +- src/fibers/scope.lua | 550 +++++++++++++++++++++---------------------- 2 files changed, 278 insertions(+), 278 deletions(-) diff --git a/EXECUTION.md b/EXECUTION.md index 05eb4a9..7441b97 100644 --- a/EXECUTION.md +++ b/EXECUTION.md @@ -443,7 +443,7 @@ end) Here we run a long-lived process in its own child scope using `scope.run`. The updated `scope.run` returns: ```lua -status, err, finally_failures, ...body_results +status, err, extra_errors, ...body_results ``` In many cases only `status` and `err` are required. @@ -461,7 +461,7 @@ fibers.run(function(scope) } -- Run worker in a child scope for clearer lifetime boundaries. - local status, err, extra_failures, child_status, code, sig, cerr = + local status, err, extra_errors, child_status, code, sig, cerr = scope_mod.run(function(child_scope) -- Wait until the worker dies or the child scope is cancelled. return child_scope:perform(cmd:run_op()) @@ -470,7 +470,7 @@ fibers.run(function(scope) print("worker scope finished:", status, err) -- Optional: record any errors from finaliser cleanup in the worker scope. - for i, derr in ipairs(extra_failures) do + for i, derr in ipairs(extra_errors) do print("worker extra failure[" .. i .. "]:", derr) end diff --git a/src/fibers/scope.lua b/src/fibers/scope.lua index afbf489..ff966ee 100644 --- a/src/fibers/scope.lua +++ b/src/fibers/scope.lua @@ -12,9 +12,9 @@ local waitgroup = require 'fibers.waitgroup' local cond = require 'fibers.cond' local safe = require 'coxpcall' -local unpack = rawget(table, "unpack") or _G.unpack -local pack = rawget(table, "pack") or function(...) - return { n = select("#", ...), ... } +local unpack = rawget(table, 'unpack') or _G.unpack +local pack = rawget(table, 'pack') or function (...) + return { n = select('#', ...), ... } end ---@alias ScopeStatus "running"|"ok"|"failed"|"cancelled" @@ -25,10 +25,10 @@ end ---@field _children table # weak-key set of child scopes ---@field _status ScopeStatus ---@field _error any ----@field _finally_failures any[] +---@field _extra_errors any[] ---@field failure_mode string # e.g. "fail_fast" ---@field _wg Waitgroup ----@field _finalisers fun(self: Scope)[] # LIFO finalisers +---@field _finalisers fun(self: Scope)[] # LIFO finalisers ---@field _cancel_cond Cond ---@field _join_cond Cond ---@field _join_worker_started boolean @@ -38,7 +38,7 @@ Scope.__index = Scope -- Weak-keyed table mapping Fiber objects to their current Scope. ---@type table -local fiber_scopes = setmetatable({}, { __mode = "k" }) +local fiber_scopes = setmetatable({}, { __mode = 'k' }) -- Process-wide root scope and “current scope” when not in a fiber. ---@type Scope|nil @@ -50,11 +50,11 @@ local global_scope ---@param _ any ---@param err any local function default_unscoped_error_handler(_, err) - if root_scope then - root_scope:_record_failure(err) - else - io.stderr:write("Unscoped fiber error before root initialised: " .. tostring(err) .. "\n") - end + if root_scope then + root_scope:_record_failure(err) + else + io.stderr:write('Unscoped fiber error before root initialised: ' .. tostring(err) .. '\n') + end end ---@type fun(fib: any, err: any) @@ -63,8 +63,8 @@ local unscoped_error_handler = default_unscoped_error_handler --- Set the handler for uncaught errors in fibers that have no scope. ---@param handler fun(fib: Fiber, err: any) local function set_unscoped_error_handler(handler) - assert(type(handler) == "function", "unscoped error handler must be a function") - unscoped_error_handler = handler + assert(type(handler) == 'function', 'unscoped error handler must be a function') + unscoped_error_handler = handler end ---------------------------------------------------------------------- @@ -74,59 +74,59 @@ end --- Internal: current fiber object, or nil if not in a fiber. ---@return Fiber|nil local function current_fiber() - return runtime.current_fiber() + return runtime.current_fiber() end --- Internal: create a new Scope with the given parent. ---@param parent Scope|nil ---@return Scope local function new_scope(parent) - local s = setmetatable({ - _parent = parent, - _children = setmetatable({}, { __mode = "k" }), + local s = setmetatable({ + _parent = parent, + _children = setmetatable({}, { __mode = 'k' }), - _status = "running", - _error = nil, - _finally_failures = {}, - failure_mode = "fail_fast", + _status = 'running', + _error = nil, + _extra_errors = {}, + failure_mode = 'fail_fast', - _wg = waitgroup.new(), - _finalisers = {}, + _wg = waitgroup.new(), + _finalisers = {}, - _cancel_cond = cond.new(), - _join_cond = cond.new(), - _join_worker_started = false, - }, Scope) + _cancel_cond = cond.new(), + _join_cond = cond.new(), + _join_worker_started = false, + }, Scope) - if parent then - parent._children[s] = true - end + if parent then + parent._children[s] = true + end - return s + return s end --- Return the process-wide root scope, creating it if needed. ---@return Scope local function root() - if not root_scope then - root_scope = new_scope(nil) - global_scope = root_scope - - -- Error pump: attribute uncaught fiber errors to scopes. - runtime.spawn_raw(function() - while true do - local fib, err = runtime.wait_fiber_error() - local s = fiber_scopes[fib] - - if s then - s:_record_failure(err) - else - unscoped_error_handler(fib, err) - end - end - end) - end - return root_scope + if not root_scope then + root_scope = new_scope(nil) + global_scope = root_scope + + -- Error pump: attribute uncaught fiber errors to scopes. + runtime.spawn_raw(function () + while true do + local fib, err = runtime.wait_fiber_error() + local s = fiber_scopes[fib] + + if s then + s:_record_failure(err) + else + unscoped_error_handler(fib, err) + end + end + end) + end + return root_scope end --- Return the current Scope. @@ -134,11 +134,11 @@ end --- Outside a fiber: the process-wide current scope, defaulting to root. ---@return Scope local function current() - local fib = current_fiber() - if fib then - return fiber_scopes[fib] or root() - end - return global_scope or root() + local fib = current_fiber() + if fib then + return fiber_scopes[fib] or root() + end + return global_scope or root() end ---------------------------------------------------------------------- @@ -148,60 +148,59 @@ end --- Internal: record a failure in this scope and cancel children. ---@param err any function Scope:_record_failure(err) - if self._status == "running" then - self._status = "failed" - self._error = self._error or err - self:_propagate_cancel(self._error) - end - -- Ignore subsequent fiber failures; we treat them as - -- cancellation noise. Defer failures are recorded in - -- the join worker logic. + if self._status == 'running' then + self._status = 'failed' + self._error = self._error or err + self:_propagate_cancel(self._error) + end + -- Ignore subsequent fiber failures; we treat them as + -- cancellation noise. Defer failures are recorded in + -- the join worker logic. end - --- Create a new child scope of this scope (no body, no current() change). ---@return Scope function Scope:new_child() - return new_scope(self) + return new_scope(self) end --- Register a finaliser to run when the scope closes (LIFO). ---@param handler fun(self: Scope) function Scope:finally(handler) - assert(type(handler) == "function", "scope:finally expects a function") - local finalisers = self._finalisers - finalisers[#finalisers + 1] = handler + assert(type(handler) == 'function', 'scope:finally expects a function') + local finalisers = self._finalisers + finalisers[#finalisers + 1] = handler end ---@param reason any|nil function Scope:_propagate_cancel(reason) - local r = reason or self._error or "scope cancelled" + local r = reason or self._error or 'scope cancelled' - self._cancel_cond:signal() + self._cancel_cond:signal() - local children = self._children - for child in pairs(children) do - if child then - child:cancel(r) - end - end + local children = self._children + for child in pairs(children) do + if child then + child:cancel(r) + end + end end --- Cancel this scope and its children with an optional reason. --- Idempotent; subsequent calls after terminal success are ignored. ---@param reason any|nil function Scope:cancel(reason) - if self._status == "ok" then return end + if self._status == 'ok' then return end - local r = reason or self._error or "scope cancelled" + local r = reason or self._error or 'scope cancelled' - if self._status == "running" then - self._status = "cancelled" - if self._error == nil then - self._error = r - end - self:_propagate_cancel(r) - end + if self._status == 'running' then + self._status = 'cancelled' + if self._error == nil then + self._error = r + end + self:_propagate_cancel(r) + end end --- Spawn a child fiber attached to this scope. @@ -209,68 +208,68 @@ end ---@param fn fun(s: Scope, ...): any ---@param ... any function Scope:spawn(fn, ...) - assert(self._status == "running", "cannot spawn on a non-running scope") - local args = pack(...) - self._wg:add(1) + assert(self._status == 'running', 'cannot spawn on a non-running scope') + local args = pack(...) + self._wg:add(1) - runtime.spawn_raw(function() - local fib = current_fiber() - local prev = fib and fiber_scopes[fib] or nil + runtime.spawn_raw(function () + local fib = current_fiber() + local prev = fib and fiber_scopes[fib] or nil - if fib then - fiber_scopes[fib] = self - end + if fib then + fiber_scopes[fib] = self + end - local ok, err = safe.pcall(fn, self, unpack(args, 1, args.n)) + local ok, err = safe.pcall(fn, self, unpack(args, 1, args.n)) - if not ok then - local s = fib and (fiber_scopes[fib] or self) or self - s:_record_failure(err) - end + if not ok then + local s = fib and (fiber_scopes[fib] or self) or self + s:_record_failure(err) + end - if fib then - fiber_scopes[fib] = prev - end + if fib then + fiber_scopes[fib] = prev + end - self._wg:done() - end) + self._wg:done() + end) end --- Return this scope's parent, or nil for the root scope. ---@return Scope|nil function Scope:parent() - return self._parent + return self._parent end --- Return a shallow copy of this scope's children. ---@return Scope[] function Scope:children() - local out = {} - local ch = self._children or {} - local i = 1 - for child in pairs(ch) do - out[i] = child - i = i + 1 - end - return out + local out = {} + local ch = self._children or {} + local i = 1 + for child in pairs(ch) do + out[i] = child + i = i + 1 + end + return out end --- Return this scope's status and primary error (if any). ---@return ScopeStatus status ---@return any err function Scope:status() - return self._status, self._error + return self._status, self._error end --- Return a shallow copy of non-primary finaliser failures recorded on this scope. ---@return any[] -function Scope:finally_failures() - local out = {} - local f = self._finally_failures or {} - for i, v in ipairs(f) do - out[i] = v - end - return out +function Scope:extra_errors() + local out = {} + local f = self._extra_errors or {} + for i, v in ipairs(f) do + out[i] = v + end + return out end ---------------------------------------------------------------------- @@ -280,71 +279,71 @@ end -- Internal: start a join worker that awaits children, runs finalisers and -- finalises status, then signals _join_cond. function Scope:_start_join_worker() - if self._join_worker_started then return end - self._join_worker_started = true - - runtime.spawn_raw(function() - local fib = current_fiber() - local prev = fib and fiber_scopes[fib] - if fib then - fiber_scopes[fib] = self - end - - op.perform_raw(self._wg:wait_op()) - - if self._status == "running" then - self._status = "ok" - self._error = nil - end - - local status = self._status - local primary_err = self._error - local aborted = (status ~= "ok") - - local finalisers = self._finalisers - for i = #finalisers, 1, -1 do - local f = finalisers[i] - finalisers[i] = nil - - local ok, err = safe.pcall(f, aborted, status, primary_err) - if not ok then - if self._status == "ok" then - self._status = "failed" - self._error = self._error or err - else - local failures = self._finally_failures - failures[#failures + 1] = err - end - end - end - - if fib then - fiber_scopes[fib] = prev - end - - self._join_cond:signal() - end) + if self._join_worker_started then return end + self._join_worker_started = true + + runtime.spawn_raw(function () + local fib = current_fiber() + local prev = fib and fiber_scopes[fib] + if fib then + fiber_scopes[fib] = self + end + + op.perform_raw(self._wg:wait_op()) + + if self._status == 'running' then + self._status = 'ok' + self._error = nil + end + + local status = self._status + local primary_err = self._error + local aborted = (status ~= 'ok') + + local finalisers = self._finalisers + for i = #finalisers, 1, -1 do + local f = finalisers[i] + finalisers[i] = nil + + local ok, err = safe.pcall(f, aborted, status, primary_err) + if not ok then + if self._status == 'ok' then + self._status = 'failed' + self._error = self._error or err + else + local failures = self._extra_errors + failures[#failures + 1] = err + end + end + end + + if fib then + fiber_scopes[fib] = prev + end + + self._join_cond:signal() + end) end --- Op that fires once the scope has reached a terminal status. --- Returns (status, error) when performed. ---@return Op function Scope:join_op() - self:_start_join_worker() - local ev = self._join_cond:wait_op() - return ev:wrap(function() - return self._status, self._error - end) + self:_start_join_worker() + local ev = self._join_cond:wait_op() + return ev:wrap(function () + return self._status, self._error + end) end --- Op that fires when the scope is cancelled or fails. --- Returns the cancellation or failure reason when performed. ---@return Op function Scope:not_ok_op() - local ev = self._cancel_cond:wait_op() - return ev:wrap(function() - return self._error or "scope cancelled" - end) + local ev = self._cancel_cond:wait_op() + return ev:wrap(function () + return self._error or 'scope cancelled' + end) end ---------------------------------------------------------------------- @@ -357,10 +356,10 @@ end ---@param self Scope ---@return Op local function cancel_op(self) - local ev = self._cancel_cond:wait_op() - return ev:wrap(function() - return false, self._error or "scope cancelled", nil - end) + local ev = self._cancel_cond:wait_op() + return ev:wrap(function () + return false, self._error or 'scope cancelled', nil + end) end --- Wrap an op so that it observes this scope's cancellation and failure state. @@ -368,10 +367,10 @@ end ---@param ev Op ---@return Op function Scope:run_op(ev) - return op.guard(function() - local this_cancel_op = cancel_op(self) - return op.choice(ev, this_cancel_op) - end) + return op.guard(function () + local this_cancel_op = cancel_op(self) + return op.choice(ev, this_cancel_op) + end) end --- Perform an op under this scope, obeying its cancellation rules. @@ -381,22 +380,22 @@ end ---@return boolean ok ---@return any ... function Scope:sync(ev) - assert(runtime.current_fiber(), - "scope:sync must be called from inside a fiber (use fibers.run as an entry point)") + assert(runtime.current_fiber(), + 'scope:sync must be called from inside a fiber (use fibers.run as an entry point)') - local status, err = self:status() - if status ~= "running" then - return false, err or "scope cancelled" - end + local status, err = self:status() + if status ~= 'running' then + return false, err or 'scope cancelled' + end - local results = pack(op.perform_raw(self:run_op(ev))) + local results = pack(op.perform_raw(self:run_op(ev))) - status, err = self:status() - if status ~= "running" and status ~= "ok" then - return false, err or "scope cancelled" - end + status, err = self:status() + if status ~= 'running' and status ~= 'ok' then + return false, err or 'scope cancelled' + end - return true, unpack(results, 1, results.n) + return true, unpack(results, 1, results.n) end --- Perform an op under this scope, raising on failure or cancellation. @@ -404,16 +403,16 @@ end ---@param ev Op ---@return any ... function Scope:perform(ev) - -- sync does the fiber assertion and fail-fast logic - local results = pack(self:sync(ev)) + -- sync does the fiber assertion and fail-fast logic + local results = pack(self:sync(ev)) - local ok = results[1] - if not ok then - -- results[2] is the error value from sync - error(results[2]) - end + local ok = results[1] + if not ok then + -- results[2] is the error value from sync + error(results[2]) + end - return unpack(results, 2, results.n) + return unpack(results, 2, results.n) end ---------------------------------------------------------------------- @@ -426,47 +425,47 @@ end ---@param build_op fun(child_scope: Scope): Op ---@return Op local function with_op(build_op) - return op.guard(function() - local parent = current() - local child = new_scope(parent) - - local function acquire() - local fib = current_fiber() - if fib then - local prev = fiber_scopes[fib] - fiber_scopes[fib] = child - return { kind = "fiber", fib = fib, prev = prev } - else - local prev = global_scope or root() - global_scope = child - return { kind = "global", prev = prev } - end - end - - ---@param token { kind: "fiber"|"global", fib?: any, prev: Scope } - ---@param aborted boolean - local function release(token, aborted) - if token.kind == "fiber" then - fiber_scopes[token.fib] = token.prev - else - global_scope = token.prev - end - - if aborted and child._status == "running" then - child._status = "cancelled" - child._error = child._error or "scope aborted" - child:cancel(child._error) - end - - op.perform_raw(child:join_op()) - end - - local function use() - return build_op(child) - end - - return op.bracket(acquire, release, use) - end) + return op.guard(function () + local parent = current() + local child = new_scope(parent) + + local function acquire() + local fib = current_fiber() + if fib then + local prev = fiber_scopes[fib] + fiber_scopes[fib] = child + return { kind = 'fiber', fib = fib, prev = prev } + else + local prev = global_scope or root() + global_scope = child + return { kind = 'global', prev = prev } + end + end + + ---@param token { kind: "fiber"|"global", fib?: any, prev: Scope } + ---@param aborted boolean + local function release(token, aborted) + if token.kind == 'fiber' then + fiber_scopes[token.fib] = token.prev + else + global_scope = token.prev + end + + if aborted and child._status == 'running' then + child._status = 'cancelled' + child._error = child._error or 'scope aborted' + child:cancel(child._error) + end + + op.perform_raw(child:join_op()) + end + + local function use() + return build_op(child) + end + + return op.bracket(acquire, release, use) + end) end ---------------------------------------------------------------------- @@ -486,34 +485,34 @@ end ---@param ... any ---@return ScopeStatus status ---@return any err ----@return any[] finally_failures +---@return any[] extra_errors ---@return any ... local function run(body_fn, ...) - assert(runtime.current_fiber(), "scope.run must be called from inside a fiber") - local parent = current() - local child = new_scope(parent) - local args = pack(...) - - child._result = nil - - child:spawn(function(s) - local res = pack(body_fn(s, unpack(args, 1, args.n))) - s._result = res - end) - - local status, err = op.perform_raw(child:join_op()) - - -- Shallow copy of non-primary finaliser failures; will be {} when there are none. - local extra = child:finally_failures() - - local res = child._result - if res then - -- status, primary_err, extra_failures, ...body results... - return status, err, extra, unpack(res, 1, res.n) - else - -- status, primary_err, extra_failures - return status, err, extra - end + assert(runtime.current_fiber(), 'scope.run must be called from inside a fiber') + local parent = current() + local child = new_scope(parent) + local args = pack(...) + + child._result = nil + + child:spawn(function (s) + local res = pack(body_fn(s, unpack(args, 1, args.n))) + s._result = res + end) + + local status, err = op.perform_raw(child:join_op()) + + -- Shallow copy of non-primary finaliser failures; will be {} when there are none. + local extra = child:extra_errors() + + local res = child._result + if res then + -- status, primary_err, extra_failures, ...body results... + return status, err, extra, unpack(res, 1, res.n) + else + -- status, primary_err, extra_failures + return status, err, extra + end end ---------------------------------------------------------------------- @@ -521,10 +520,11 @@ end ---------------------------------------------------------------------- return { - root = root, - current = current, - run = run, - with_op = with_op, - Scope = Scope, - set_unscoped_error_handler = set_unscoped_error_handler, + root = root, + current = current, + run = run, + with_op = with_op, + Scope = Scope, + + set_unscoped_error_handler = set_unscoped_error_handler, } From 97c58acf38df29861630df27bff070908190138f Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sat, 13 Dec 2025 11:08:43 +0000 Subject: [PATCH 096/138] adds LuaLS automatic formatting, moves to tab identation fixes all linter warnings --- .editorconfig | 171 +++ .vscode/settings.json | 4 +- examples/01-concurrent-tasks-and-sleeping.lua | 30 +- ...ng-a-channel-receive-against-a-timeout.lua | 68 +- examples/03-scope-defers.lua | 50 +- ...tured-concurrency-and-fail-fast-scopes.lua | 68 +- examples/05-cancel-subprocess.lua | 210 ++-- examples/06-scope-command-shared-pipe.lua | 172 +-- ...07-subprocess-with-structured-shutdown.lua | 62 +- examples/08-civil-alarms.lua | 81 +- src/coxpcall.lua | 136 +-- src/fibers.lua | 170 +-- src/fibers/alarm.lua | 210 ++-- src/fibers/channel.lua | 224 ++-- src/fibers/cond.lua | 48 +- src/fibers/io/exec.lua | 771 ++++++------ src/fibers/io/exec_backend.lua | 20 +- src/fibers/io/exec_backend/core.lua | 206 ++-- src/fibers/io/exec_backend/nixio.lua | 912 +++++++------- src/fibers/io/exec_backend/pidfd.lua | 672 +++++------ src/fibers/io/exec_backend/sigchld.lua | 620 +++++----- src/fibers/io/exec_backend/stdio.lua | 348 +++--- src/fibers/io/fd_backend.lua | 16 +- src/fibers/io/fd_backend/core.lua | 418 +++---- src/fibers/io/fd_backend/ffi.lua | 639 +++++----- src/fibers/io/fd_backend/nixio.lua | 608 +++++----- src/fibers/io/fd_backend/posix.lua | 530 ++++----- src/fibers/io/file.lua | 304 ++--- src/fibers/io/mem_backend.lua | 258 ++-- src/fibers/io/poller.lua | 16 +- src/fibers/io/poller/core.lua | 198 ++-- src/fibers/io/poller/epoll.lua | 316 ++--- src/fibers/io/poller/nixio.lua | 142 +-- src/fibers/io/poller/select.lua | 150 +-- src/fibers/io/socket.lua | 348 +++--- src/fibers/io/stream.lua | 590 +++++----- src/fibers/oneshot.lua | 50 +- src/fibers/op.lua | 616 +++++----- src/fibers/performer.lua | 32 +- src/fibers/runtime.lua | 154 +-- src/fibers/sched.lua | 178 +-- src/fibers/scope.lua | 6 +- src/fibers/sleep.lua | 44 +- src/fibers/timer.lua | 138 +-- src/fibers/utils/bytes.lua | 15 +- src/fibers/utils/bytes/ffi.lua | 224 ++-- src/fibers/utils/bytes/lua.lua | 304 ++--- src/fibers/utils/ffi_compat.lua | 59 +- src/fibers/utils/fifo.lua | 34 +- src/fibers/utils/time.lua | 20 +- src/fibers/utils/time/core.lua | 100 +- src/fibers/utils/time/ffi.lua | 182 +-- src/fibers/utils/time/linux.lua | 243 ++-- src/fibers/utils/time/luaposix.lua | 160 +-- src/fibers/utils/time/nixio.lua | 227 ++-- src/fibers/wait.lua | 326 ++--- src/fibers/waitgroup.lua | 76 +- tests/test.lua | 40 +- tests/test_alarm.lua | 600 +++++----- tests/test_channel.lua | 140 +-- tests/test_cond.lua | 56 +- tests/test_io-exec.lua | 630 +++++----- tests/test_io-exec_backend.lua | 154 +-- tests/test_io-file.lua | 684 +++++------ tests/test_io-mem.lua | 370 +++--- tests/test_io-poller.lua | 50 +- tests/test_io-socket.lua | 118 +- tests/test_io-stream.lua | 189 +-- tests/test_op.lua | 804 ++++++------- tests/test_runtime.lua | 58 +- tests/test_sched.lua | 34 +- tests/test_scope.lua | 1046 ++++++++--------- tests/test_sleep.lua | 22 +- tests/test_timer.lua | 122 +- tests/test_utils-bytes.lua | 236 ++-- tests/test_utils-bytes_stress.lua | 284 ++--- tests/test_wait.lua | 196 +-- tests/test_waitgroup.lua | 190 +-- 78 files changed, 9594 insertions(+), 9403 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5cb0cb6 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,171 @@ + +# see https://github.com/CppCXY/EmmyLuaCodeStyle +[*.lua] +# [basic] + +# optional space/tab +indent_style = tab +# if indent_style is space, this is valid +indent_size = 4 +# if indent_style is tab, this is valid +tab_width = 4 +# none/single/double +quote_style = single + +continuation_indent = 4 +## extend option +# continuation_indent.before_block = 4 +# continuation_indent.in_expr = 4 +# continuation_indent.in_table = 4 + +# this mean utf8 length , if this is 'unset' then the line width is no longer checked +# this option decides when to chopdown the code +max_line_length = 120 + +# optional crlf/lf/cr/auto, if it is 'auto', in windows it is crlf other platforms are lf +# in neovim the value 'auto' is not a valid option, please use 'unset' +end_of_line = auto + +# none/ comma / semicolon / only_kv_colon +table_separator_style = none + +#optional keep/never/always/smart +trailing_table_separator = keep + +# keep/remove/remove_table_only/remove_string_only +call_arg_parentheses = keep + +detect_end_of_line = false + +# this will check text end with new line +insert_final_newline = true + +# [space] +space_around_table_field_list = true + +space_before_attribute = true + +space_before_function_open_parenthesis = false + +space_before_function_call_open_parenthesis = false + +space_before_closure_open_parenthesis = true + +# optional always/only_string/only_table/none +# or true/false +space_before_function_call_single_arg = always +## extend option +## always/keep/none +# space_before_function_call_single_arg.table = always +## always/keep/none +# space_before_function_call_single_arg.string = always + +space_before_open_square_bracket = false + +space_inside_function_call_parentheses = false + +space_inside_function_param_list_parentheses = false + +space_inside_square_brackets = false + +# like t[#t+1] = 1 +space_around_table_append_operator = false + +ignore_spaces_inside_function_call = false + +# detail number or 'keep' +space_before_inline_comment = 1 + +# convert '---' to '--- ' or '--' to '-- ' +space_after_comment_dash = false + +# [operator space] +space_around_math_operator = true +# space_around_math_operator.exponent = false + +space_after_comma = true + +space_after_comma_in_for_statement = true + +# true/false or none/always/no_space_asym +space_around_concat_operator = true + +space_around_logical_operator = true + +# true/false or none/always/no_space_asym +space_around_assign_operator = true + +# [align] + +align_call_args = false + +align_function_params = true + +# true/false or always +align_continuous_assign_statement = true + +align_continuous_rect_table_field = true + +align_continuous_line_space = 1 + +align_if_branch = false + +# option none / always / contain_curly/ +align_array_table = true + +align_continuous_similar_call_args = false + +align_continuous_inline_comment = true +# option none / always / only_call_stmt +align_chain_expr = none + +# [indent] + +never_indent_before_if_condition = false + +never_indent_comment_on_if_branch = false + +keep_indents_on_empty_lines = false + +allow_non_indented_comments = false +# [line space] + +# The following configuration supports four expressions +# keep +# fixed(n) +# min(n) +# max(n) +# for eg. min(2) + +line_space_after_if_statement = max(2) + +line_space_after_do_statement = max(2) + +line_space_after_while_statement = max(2) + +line_space_after_repeat_statement = max(2) + +line_space_after_for_statement = max(2) + +line_space_after_local_or_assign_statement = keep + +line_space_after_function_statement = fixed(2) + +line_space_after_expression_statement = keep + +line_space_after_comment = keep + +line_space_around_block = keep +# [line break] +break_all_list_when_line_exceed = false + +auto_collapse_lines = false + +break_before_braces = false + +# [preference] +ignore_space_after_colon = false + +remove_call_expression_list_finish_comma = false +# keep / always / same_line / replace_with_newline / never +end_statement_with_semicolon = keep diff --git a/.vscode/settings.json b/.vscode/settings.json index 0748767..9bcc741 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,8 @@ { - "editor.tabSize": 2, + "editor.tabSize": 4, "editor.indentSize": "tabSize", "editor.insertSpaces": true, - "editor.formatOnSave": false, + "editor.formatOnSave": true, "editor.formatOnSaveMode": "modifications", "editor.codeActionsOnSave": { "source.fixAll.eslint": "never" diff --git a/examples/01-concurrent-tasks-and-sleeping.lua b/examples/01-concurrent-tasks-and-sleeping.lua index 0c917ee..916ffcf 100644 --- a/examples/01-concurrent-tasks-and-sleeping.lua +++ b/examples/01-concurrent-tasks-and-sleeping.lua @@ -7,32 +7,32 @@ -- When all fibers in the root scope complete, the scheduler stops and -- run(main) returns. -package.path = "../src/?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path local fibers = require 'fibers' local run = fibers.run local spawn = fibers.spawn -local sleep = require 'fibers.sleep'.sleep +local sleep = require 'fibers.sleep'.sleep local function worker(name, delay, count) - for i = 1, count do - print(("[%s] tick %d"):format(name, i)) - sleep(delay) - end - print(("[%s] done"):format(name)) + for i = 1, count do + print(('[%s] tick %d'):format(name, i)) + sleep(delay) + end + print(('[%s] done'):format(name)) end local function main() - -- Spawn three child fibers under the current scope. - spawn(worker, "fast", 0.2, 5) - spawn(worker, "medium", 0.5, 4) - spawn(worker, "slow", 1.0, 3) + -- Spawn three child fibers under the current scope. + spawn(worker, 'fast', 0.2, 5) + spawn(worker, 'medium', 0.5, 4) + spawn(worker, 'slow', 1.0, 3) - -- Unlike Go our main fiber will wait for its scope to finish. - -- Once all children complete, scope reaches status "ok" - -- and fibers.run() returns. - print("Main fiber returning; children will keep the scheduler busy") + -- Unlike Go our main fiber will wait for its scope to finish. + -- Once all children complete, scope reaches status "ok" + -- and fibers.run() returns. + print('Main fiber returning; children will keep the scheduler busy') end run(main) diff --git a/examples/02-racing-a-channel-receive-against-a-timeout.lua b/examples/02-racing-a-channel-receive-against-a-timeout.lua index 9a4f1c7..751f015 100644 --- a/examples/02-racing-a-channel-receive-against-a-timeout.lua +++ b/examples/02-racing-a-channel-receive-against-a-timeout.lua @@ -1,4 +1,4 @@ -package.path = "../src/?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path local fibers = require 'fibers' local sleep = require 'fibers.sleep' @@ -10,39 +10,39 @@ local perform = fibers.perform local choice = fibers.choice local function main() - -- Buffered channel with capacity 1 so a late send will not block. - local c = chan.new(1) - - -- Simulate a slow producer. - spawn(function() - sleep.sleep(3.0) - print("[producer] sending result") - c:put("result from producer") -- will complete even if nobody is reading - print("[producer] done") - end) - - -- Build two Ops: - -- 1. Wait for a value from the channel. - -- 2. Wait for a timeout (2 seconds). - local recv_op = c:get_op():wrap(function(v) - return "value", v - end) - - local timeout_op = sleep.sleep_op(2.0):wrap(function() - return "timeout", nil - end) - - local tag, payload = perform(choice(recv_op, timeout_op)) - - if tag == "value" then - print("[main] got channel value:", payload) - elseif tag == "timeout" then - print("[main] timed out before producer responded") - else - print("[main] unexpected tag:", tag, payload) - end - - print("[main] returning from Example 3") + -- Buffered channel with capacity 1 so a late send will not block. + local c = chan.new(1) + + -- Simulate a slow producer. + spawn(function () + sleep.sleep(3.0) + print('[producer] sending result') + c:put('result from producer') -- will complete even if nobody is reading + print('[producer] done') + end) + + -- Build two Ops: + -- 1. Wait for a value from the channel. + -- 2. Wait for a timeout (2 seconds). + local recv_op = c:get_op():wrap(function (v) + return 'value', v + end) + + local timeout_op = sleep.sleep_op(2.0):wrap(function () + return 'timeout', nil + end) + + local tag, payload = perform(choice(recv_op, timeout_op)) + + if tag == 'value' then + print('[main] got channel value:', payload) + elseif tag == 'timeout' then + print('[main] timed out before producer responded') + else + print('[main] unexpected tag:', tag, payload) + end + + print('[main] returning from Example 3') end run(main) diff --git a/examples/03-scope-defers.lua b/examples/03-scope-defers.lua index 618180d..ec68943 100644 --- a/examples/03-scope-defers.lua +++ b/examples/03-scope-defers.lua @@ -1,36 +1,36 @@ -package.path = "../src/?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path local fibers = require 'fibers' local sleep = require 'fibers.sleep' -fibers.run(function() - -- worker(s) runs inside a fresh child scope s. - local function worker() - local s = fibers.current_scope() +fibers.run(function () + -- worker(s) runs inside a fresh child scope s. + local function worker() + local s = fibers.current_scope() - s:finally(function() - print("finaliser 1 (outer)") - end) + s:finally(function () + print('finaliser 1 (outer)') + end) - s:finally(function() - print("finaliser 2 (inner)") - -- This error is recorded as an additional failure. - error("finaliser 2 failed") - end) + s:finally(function () + print('finaliser 2 (inner)') + -- This error is recorded as an additional failure. + error('finaliser 2 failed') + end) - print("worker body starting") - sleep.sleep(0.1) - print("worker body raising error") - error("worker body failed") - end + print('worker body starting') + sleep.sleep(0.1) + print('worker body raising error') + error('worker body failed') + end - local status, err, def_errs = fibers.run_scope(worker) + local status, err, def_errs = fibers.run_scope(worker) - print("worker scope status:", status) - print("worker scope primary error:", err) + print('worker scope status:', status) + print('worker scope primary error:', err) - print("worker scope extra failures:", #def_errs) - for i, e in ipairs(def_errs) do - print((" [%d] %s"):format(i, tostring(e))) - end + print('worker scope extra failures:', #def_errs) + for i, e in ipairs(def_errs) do + print((' [%d] %s'):format(i, tostring(e))) + end end) diff --git a/examples/04-structured-concurrency-and-fail-fast-scopes.lua b/examples/04-structured-concurrency-and-fail-fast-scopes.lua index aad7047..6d44194 100644 --- a/examples/04-structured-concurrency-and-fail-fast-scopes.lua +++ b/examples/04-structured-concurrency-and-fail-fast-scopes.lua @@ -1,52 +1,52 @@ -package.path = "../src/?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path -local fibers = require 'fibers' -local run = fibers.run -local spawn = fibers.spawn -local run_scope = fibers.run_scope -local now = fibers.now +local fibers = require 'fibers' +local run = fibers.run +local spawn = fibers.spawn +local run_scope = fibers.run_scope +local now = fibers.now -local sleep = require 'fibers.sleep'.sleep +local sleep = require 'fibers.sleep'.sleep local function sometimes_fails(name, delay, fail_after) - for _ = 1, fail_after - 1 do - print(("[%s] ok at t=%.2f"):format(name, now())) - sleep(delay) - end - error(("[%s] failed after %d iterations"):format(name, fail_after)) + for _ = 1, fail_after - 1 do + print(('[%s] ok at t=%.2f'):format(name, now())) + sleep(delay) + end + error(('[%s] failed after %d iterations'):format(name, fail_after)) end local function sibling(name, delay) - while true do - print(("[%s] still running at t=%.2f"):format(name, now())) - sleep(delay) - end + while true do + print(('[%s] still running at t=%.2f'):format(name, now())) + sleep(delay) + end end local function main() - print("Main: starting child scope") + print('Main: starting child scope') - local status, err = run_scope(function(child_scope) - -- All fibers spawned here are supervised by child_scope. - spawn(sometimes_fails, "flaky", 0.3, 4) - spawn(sibling, "sibling", 0.2) + local status, err = run_scope(function (child_scope) + -- All fibers spawned here are supervised by child_scope. + spawn(sometimes_fails, 'flaky', 0.3, 4) + spawn(sibling, 'sibling', 0.2) - -- Block until child_scope terminates: - -- - if flaky fails, child_scope becomes "failed" and cancels siblings. - -- - if everything finishes, status is "ok". - local s, e = child_scope:status() - print("Child initial status:", s, e) + -- Block until child_scope terminates: + -- - if flaky fails, child_scope becomes "failed" and cancels siblings. + -- - if everything finishes, status is "ok". + local s, e = child_scope:status() + print('Child initial status:', s, e) - -- wait for completion using the scope join op - local ok, join_err = child_scope:sync(child_scope:join_op()) - print("child_scope:join_op() ->", ok, join_err) - end) + -- wait for completion using the scope join op + local ok, join_err = child_scope:sync(child_scope:join_op()) + print('child_scope:join_op() ->', ok, join_err) + end) - print("Main: child scope finished with:", status, err) + print('Main: child scope finished with:', status, err) - if status ~= "ok" then - print("Main: treating non-ok child status as error") - end + if status ~= 'ok' then + print('Main: treating non-ok child status as error') + end end run(main) diff --git a/examples/05-cancel-subprocess.lua b/examples/05-cancel-subprocess.lua index de2829f..870acf8 100644 --- a/examples/05-cancel-subprocess.lua +++ b/examples/05-cancel-subprocess.lua @@ -11,120 +11,120 @@ -- * Timeout is expressed algebraically via boolean_choice, -- rather than a separate watchdog fiber. -package.path = "../src/?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path -local fibers = require "fibers" -local exec = require "fibers.io.exec" -local sleep = require "fibers.sleep" +local fibers = require 'fibers' +local exec = require 'fibers.io.exec' +local sleep = require 'fibers.sleep' -local run = fibers.run -local spawn = fibers.spawn -local perform = fibers.perform +local run = fibers.run +local spawn = fibers.spawn +local perform = fibers.perform local boolean_choice = fibers.boolean_choice -local current_scope = fibers.current_scope -local sleep_op = sleep.sleep_op +local current_scope = fibers.current_scope +local sleep_op = sleep.sleep_op ---------------------------------------------------------------------- -- Main entry point ---------------------------------------------------------------------- local function main() - print("[root] starting subprocess example") - - -- Run the subprocess and its helper fibers inside a child scope. - -- We use run_scope so that we can interpret status and reason at - -- a clear supervision boundary. - local status, reason, _ = fibers.run_scope(function() - print("[subscope] starting child process") - - ------------------------------------------------------------------ - -- 1. Construct the command - ------------------------------------------------------------------ - - local script = [[for i in 0 1 2 3 4 5 6 7 8 9; do echo "tick $i"; sleep 1; done]] - - local cmd = exec.command{ - "sh", "-c", script, - stdin = "null", -- no input - stdout = "pipe", -- capture output - stderr = "inherit", -- pass through - } - - ------------------------------------------------------------------ - -- 2. Reader fiber: drain stdout until EOF or error - ------------------------------------------------------------------ - - spawn(function() - local out, serr = cmd:stdout_stream() - if not out then - print("[reader] no stdout stream:", serr) - return - end - - while true do - local line, rerr = out:read("*l") - if not line then - if rerr then - print("[reader] read error:", rerr) - else - print("[reader] EOF on stdout") - end - break - end - print("[reader]", line) - end - end) - - ------------------------------------------------------------------ - -- 3. Race process completion against a timeout - ------------------------------------------------------------------ - -- - -- boolean_choice(opA, opB) returns: - -- * true + results from opA if A wins - -- * false + results from opB if B wins - -- - -- We wrap the two arms so that: - -- * The command arm returns: true, status, code, signal, err - -- * The timeout arm returns: false - -- - -- Note: - -- * If the timeout wins, we do not try to handle cancellation - -- inside the subscope. Instead we cancel the subscope from - -- here and let its finalisers (including Command’s finaliser) run. - -- - - local proc_won, status2, code, signal, err = perform(boolean_choice( - cmd:run_op(), - sleep_op(3.0) - )) - - if proc_won then - -- Process finished before the timeout and the scope has not - -- yet been cancelled or failed. - print(("[subscope] command finished: status=%s code=%s signal=%s err=%s") - :format(tostring(status2), tostring(code), tostring(signal), tostring(err))) - return - end - - ------------------------------------------------------------------ - -- 4. Timeout: cancel the subscope - ------------------------------------------------------------------ - -- - -- This cancels: - -- * the reader fiber, - -- * any other children in this scope, and - -- * the Command’s scope finaliser will run _on_scope_exit(), which - -- calls shutdown_op and waits for the process to die. - -- - print("[subscope] timeout reached; cancelling subprocess scope") - current_scope():cancel("timeout") - end) - - -------------------------------------------------------------------- - -- 5. Supervision boundary: interpret the outcome - -------------------------------------------------------------------- - - print("[root] subprocess scope completed; status:", status, "reason:", reason) + print('[root] starting subprocess example') + + -- Run the subprocess and its helper fibers inside a child scope. + -- We use run_scope so that we can interpret status and reason at + -- a clear supervision boundary. + local status, reason, _ = fibers.run_scope(function () + print('[subscope] starting child process') + + ------------------------------------------------------------------ + -- 1. Construct the command + ------------------------------------------------------------------ + + local script = [[for i in 0 1 2 3 4 5 6 7 8 9; do echo "tick $i"; sleep 1; done]] + + local cmd = exec.command { + 'sh', '-c', script, + stdin = 'null', -- no input + stdout = 'pipe', -- capture output + stderr = 'inherit', -- pass through + } + + ------------------------------------------------------------------ + -- 2. Reader fiber: drain stdout until EOF or error + ------------------------------------------------------------------ + + spawn(function () + local out, serr = cmd:stdout_stream() + if not out then + print('[reader] no stdout stream:', serr) + return + end + + while true do + local line, rerr = out:read('*l') + if not line then + if rerr then + print('[reader] read error:', rerr) + else + print('[reader] EOF on stdout') + end + break + end + print('[reader]', line) + end + end) + + ------------------------------------------------------------------ + -- 3. Race process completion against a timeout + ------------------------------------------------------------------ + -- + -- boolean_choice(opA, opB) returns: + -- * true + results from opA if A wins + -- * false + results from opB if B wins + -- + -- We wrap the two arms so that: + -- * The command arm returns: true, status, code, signal, err + -- * The timeout arm returns: false + -- + -- Note: + -- * If the timeout wins, we do not try to handle cancellation + -- inside the subscope. Instead we cancel the subscope from + -- here and let its finalisers (including Command’s finaliser) run. + -- + + local proc_won, status2, code, signal, err = perform(boolean_choice( + cmd:run_op(), + sleep_op(3.0) + )) + + if proc_won then + -- Process finished before the timeout and the scope has not + -- yet been cancelled or failed. + print(('[subscope] command finished: status=%s code=%s signal=%s err=%s') + :format(tostring(status2), tostring(code), tostring(signal), tostring(err))) + return + end + + ------------------------------------------------------------------ + -- 4. Timeout: cancel the subscope + ------------------------------------------------------------------ + -- + -- This cancels: + -- * the reader fiber, + -- * any other children in this scope, and + -- * the Command’s scope finaliser will run _on_scope_exit(), which + -- calls shutdown_op and waits for the process to die. + -- + print('[subscope] timeout reached; cancelling subprocess scope') + current_scope():cancel('timeout') + end) + + -------------------------------------------------------------------- + -- 5. Supervision boundary: interpret the outcome + -------------------------------------------------------------------- + + print('[root] subprocess scope completed; status:', status, 'reason:', reason) end run(main) diff --git a/examples/06-scope-command-shared-pipe.lua b/examples/06-scope-command-shared-pipe.lua index cca42eb..3682bc5 100644 --- a/examples/06-scope-command-shared-pipe.lua +++ b/examples/06-scope-command-shared-pipe.lua @@ -1,93 +1,93 @@ -package.path = "../src/?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path -local fibers = require "fibers" -local sleep = require "fibers.sleep" -local exec = require "fibers.io.exec" -local file = require "fibers.io.file" -local cond_mod = require "fibers.cond" +local fibers = require 'fibers' +local sleep = require 'fibers.sleep' +local exec = require 'fibers.io.exec' +local file = require 'fibers.io.file' +local cond_mod = require 'fibers.cond' -local with_scope_op = fibers.with_scope_op -local named_choice = fibers.named_choice -local perform = fibers.perform +local with_scope_op = fibers.with_scope_op +local named_choice = fibers.named_choice +local perform = fibers.perform local function main(parent_scope) - ---------------------------------------------------------------------- - -- Shared stream owned by the parent scope - ---------------------------------------------------------------------- - local r_stream, w_stream = file.pipe() - - -- Condition used to signal "reader has finished" - local reader_done = cond_mod.new() - - -- Ensure streams are closed even if something goes wrong. - parent_scope:finally(function() - print("[parent] finaliser: closing shared streams") - assert(r_stream:close()); assert(w_stream:close()) - end) - - ---------------------------------------------------------------------- - -- Reader fiber in the parent scope - ---------------------------------------------------------------------- - fibers.spawn(function() - print("[parent-reader] started") - while true do - local line, err = perform(r_stream:read_line_op()) - - if not line then - print("[parent-reader] done, err:", err) - break - end - - print("[parent-reader] got:", line) - end - - -- Signal that the reader is finished. - reader_done:signal() - end) - - ---------------------------------------------------------------------- - -- Child scope as an Op: command writes ticks to the shared stream - ---------------------------------------------------------------------- - local child_scope_op = with_scope_op(function() - print("[child] building child scope op") - - local script = [[for i in 0 1 2 3 4 5 6 7 8 9; do echo "tick $i"; sleep 1; done]] - - local cmd = exec.command{ - "sh", "-c", script, - stdout = w_stream, -- shared stream from parent scope - } - - print("[child] starting ticking command") - -- Return an Op that completes when the process exits - return cmd:run_op() - end) - - ---------------------------------------------------------------------- - -- Race the child scope against a timeout - ---------------------------------------------------------------------- - local ev = named_choice{ - child_scope_done = child_scope_op, -- long-running command - timeout = sleep.sleep_op(3), -- after ~3 seconds - } - - local which, status, code_or_sig, err = perform(ev) - print("[parent] choice result:", which, status, code_or_sig, err) - - ---------------------------------------------------------------------- - -- Tear-down ordering: - -- 1. The choice has returned, so the child scope_op has either: - -- - completed (if child_scope_done won), or - -- - been cancelled and fully joined (if timeout won). - -- In both cases, the child process is no longer running. - -- 2. We now close the writer end so the reader sees EOF. - -- 3. We wait on reader_done to know the reader has finished. - ---------------------------------------------------------------------- - assert(w_stream:close()) - - -- Wait until the reader fiber has drained the stream and signalled completion. - perform(reader_done:wait_op()) - print("[parent] reader has signalled completion") + ---------------------------------------------------------------------- + -- Shared stream owned by the parent scope + ---------------------------------------------------------------------- + local r_stream, w_stream = file.pipe() + + -- Condition used to signal "reader has finished" + local reader_done = cond_mod.new() + + -- Ensure streams are closed even if something goes wrong. + parent_scope:finally(function () + print('[parent] finaliser: closing shared streams') + assert(r_stream:close()); assert(w_stream:close()) + end) + + ---------------------------------------------------------------------- + -- Reader fiber in the parent scope + ---------------------------------------------------------------------- + fibers.spawn(function () + print('[parent-reader] started') + while true do + local line, err = perform(r_stream:read_line_op()) + + if not line then + print('[parent-reader] done, err:', err) + break + end + + print('[parent-reader] got:', line) + end + + -- Signal that the reader is finished. + reader_done:signal() + end) + + ---------------------------------------------------------------------- + -- Child scope as an Op: command writes ticks to the shared stream + ---------------------------------------------------------------------- + local child_scope_op = with_scope_op(function () + print('[child] building child scope op') + + local script = [[for i in 0 1 2 3 4 5 6 7 8 9; do echo "tick $i"; sleep 1; done]] + + local cmd = exec.command { + 'sh', '-c', script, + stdout = w_stream, -- shared stream from parent scope + } + + print('[child] starting ticking command') + -- Return an Op that completes when the process exits + return cmd:run_op() + end) + + ---------------------------------------------------------------------- + -- Race the child scope against a timeout + ---------------------------------------------------------------------- + local ev = named_choice { + child_scope_done = child_scope_op, -- long-running command + timeout = sleep.sleep_op(3), -- after ~3 seconds + } + + local which, status, code_or_sig, err = perform(ev) + print('[parent] choice result:', which, status, code_or_sig, err) + + ---------------------------------------------------------------------- + -- Tear-down ordering: + -- 1. The choice has returned, so the child scope_op has either: + -- - completed (if child_scope_done won), or + -- - been cancelled and fully joined (if timeout won). + -- In both cases, the child process is no longer running. + -- 2. We now close the writer end so the reader sees EOF. + -- 3. We wait on reader_done to know the reader has finished. + ---------------------------------------------------------------------- + assert(w_stream:close()) + + -- Wait until the reader fiber has drained the stream and signalled completion. + perform(reader_done:wait_op()) + print('[parent] reader has signalled completion') end fibers.run(main) diff --git a/examples/07-subprocess-with-structured-shutdown.lua b/examples/07-subprocess-with-structured-shutdown.lua index 995b8af..a7304f1 100644 --- a/examples/07-subprocess-with-structured-shutdown.lua +++ b/examples/07-subprocess-with-structured-shutdown.lua @@ -1,41 +1,41 @@ -package.path = "../src/?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path local fibers = require 'fibers' local exec = require 'fibers.io.exec' -local run = fibers.run -local perform = fibers.perform +local run = fibers.run +local perform = fibers.perform local run_scope = fibers.run_scope local function main() - -- Put the subprocess in its own child scope so that any failure or - -- cancellation is neatly contained. - local status, code_or_sig, err = run_scope(function() - -- Simple shell pipeline: prints two lines with a pause. - local cmd = exec.command( - "sh", "-c", - "echo 'hello from child process'; " .. - "sleep 1; " .. - "echo 'goodbye from child process'" - ) - - -- output_op(): - -- returns an Op that, when performed, yields: - -- output : string (combined stdout) - -- status : "ok" | "failed" | "cancelled" | "exited" | "signalled" - -- code : exit code or signal number (depending on status) - -- signal : signal (if signalled) - -- err : string|nil backend error - local output, proc_status, code, signal, perr = perform(cmd:output_op()) - - print("[subprocess] status:", proc_status, "code:", code, "signal:", signal, "err:", perr) - print("[subprocess] output:") - io.stdout:write(output) - - return proc_status, code or signal, perr - end) - - print("[root] child exec scope finished with:", status, code_or_sig, err) + -- Put the subprocess in its own child scope so that any failure or + -- cancellation is neatly contained. + local status, code_or_sig, err = run_scope(function () + -- Simple shell pipeline: prints two lines with a pause. + local cmd = exec.command( + 'sh', '-c', + "echo 'hello from child process'; " .. + 'sleep 1; ' .. + "echo 'goodbye from child process'" + ) + + -- output_op(): + -- returns an Op that, when performed, yields: + -- output : string (combined stdout) + -- status : "ok" | "failed" | "cancelled" | "exited" | "signalled" + -- code : exit code or signal number (depending on status) + -- signal : signal (if signalled) + -- err : string|nil backend error + local output, proc_status, code, signal, perr = perform(cmd:output_op()) + + print('[subprocess] status:', proc_status, 'code:', code, 'signal:', signal, 'err:', perr) + print('[subprocess] output:') + io.stdout:write(output) + + return proc_status, code or signal, perr + end) + + print('[root] child exec scope finished with:', status, code_or_sig, err) end run(main) diff --git a/examples/08-civil-alarms.lua b/examples/08-civil-alarms.lua index 8ff52c7..fc9427a 100644 --- a/examples/08-civil-alarms.lua +++ b/examples/08-civil-alarms.lua @@ -7,7 +7,7 @@ -- * daily_at(hour, min, sec?) -> Alarm that fires once per day at local HH:MM:SS -- * next_at(hour, min, sec?) -> Alarm that fires once at the next local HH:MM:SS -package.path = "../src/?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path local fibers = require 'fibers' local alarm_mod = require 'fibers.alarm' @@ -36,24 +36,25 @@ local function next_local_time_at(hour, min, sec, last, now) if last then -- Subsequent firing: "tomorrow at HH:MM:SS" relative to the last firing. - t = os.date("*t", last) - t.day = t.day + 1 + t = os.date('*t', last) else -- First firing: choose between "today at HH:MM:SS" and "tomorrow at HH:MM:SS". - t = os.date("*t", now) + t = os.date('*t', now) local past = t.hour > hour or (t.hour == hour and ( - t.min > min - or (t.min == min and t.sec >= sec) - )) + t.min > min + or (t.min == min and t.sec >= sec) + )) if past then t.day = t.day + 1 end end + -- Narrow os.date('*t', ...) to the structured date type for the type checker. + ---@cast t osdate t.hour, t.min, t.sec = hour, min, sec return os.time(t) @@ -70,8 +71,8 @@ end ---@param label? string ---@return Alarm local function next_at(hour, min, sec, label) - return alarm_mod.new{ - next_time = function(last, now) + return alarm_mod.new { + next_time = function (last, now) -- If we have already fired once, stop. if last ~= nil then return nil @@ -79,7 +80,7 @@ local function next_at(hour, min, sec, label) return next_local_time_at(hour, min, sec, last, now) end, label = label or string.format( - "next_%02d:%02d:%02d_local", + 'next_%02d:%02d:%02d_local', hour, min, sec or 0 ), } @@ -96,12 +97,12 @@ end ---@param label? string ---@return Alarm local function daily_at(hour, min, sec, label) - return alarm_mod.new{ - next_time = function(last, now) + return alarm_mod.new { + next_time = function (last, now) return next_local_time_at(hour, min, sec, last, now) end, label = label or string.format( - "daily_%02d:%02d:%02d_local", + 'daily_%02d:%02d:%02d_local', hour, min, sec or 0 ), } @@ -114,33 +115,49 @@ end -- Install the wall-clock time source once at start-up. alarm_mod.set_time_source(os.time) -fibers.run(function() +--- Extract numeric hour/min/sec from an os.date('*t') table. +---@param t osdate +---@return integer hour +---@return integer min +---@return integer sec +local function civil_hms(t) + -- tonumber() gives the type checker a plain number; assertions make failures explicit. + local hour = assert(tonumber(t.hour), 'invalid hour from os.date') + local min = assert(tonumber(t.min), 'invalid min from os.date') + local sec = assert(tonumber(t.sec), 'invalid sec from os.date') + return hour, min, sec +end + +fibers.run(function () local start_epoch = os.time() -- Create a one-off alarm at local HH:MM:SS+3. local oo_target = start_epoch + 3 - local oo_civil = os.date("*t", oo_target) - local oo_alarm = next_at(oo_civil.hour, oo_civil.min, oo_civil.sec) + local oo_civil = os.date('*t', oo_target) + ---@cast oo_civil osdate + local oo_hour, oo_min, oo_sec = civil_hms(oo_civil) + local oo_alarm = next_at(oo_hour, oo_min, oo_sec) -- Wait for alarm to fire. local oo_fired, _, oo_fired_at = perform(oo_alarm:wait_op()) - print("fired =", oo_fired) - print("scheduled local time =", os.date("%X", oo_target)) - print("fired at local time =", os.date("%X", oo_fired_at)) - print("delta seconds =", os.difftime(oo_fired_at, start_epoch)) + print('fired =', oo_fired) + print('scheduled local time =', os.date('%X', oo_target)) + print('fired at local time =', os.date('%X', oo_fired_at)) + print('delta seconds =', os.difftime(oo_fired_at, start_epoch)) - -- Create a daily alarm at that local HH:MM:SS+5. + -- Create a daily alarm at local HH:MM:SS+5. local d_target = start_epoch + 5 - local d_civil = os.date("*t", d_target) - local d_alarm = daily_at(d_civil.hour, d_civil.min, d_civil.sec) - - -- Wait for alarm to fire. - local d_fired, _, d_fired_at = fibers.perform(d_alarm:wait_op()) - - print("fired =", d_fired) - print("scheduled local time =", os.date("%X", d_target)) - print("fired at local time =", os.date("%X", d_fired_at)) - print("delta seconds =", os.difftime(d_fired_at, start_epoch)) - + local d_civil = os.date('*t', d_target) + ---@cast d_civil osdate + local d_hour, d_min, d_sec = civil_hms(d_civil) + local d_alarm = daily_at(d_hour, d_min, d_sec) + + -- Wait for alarm to fire. + local d_fired, _, d_fired_at = perform(d_alarm:wait_op()) + + print('fired =', d_fired) + print('scheduled local time =', os.date('%X', d_target)) + print('fired at local time =', os.date('%X', d_fired_at)) + print('delta seconds =', os.difftime(d_fired_at, start_epoch)) end) diff --git a/src/coxpcall.lua b/src/coxpcall.lua index bd85b13..fb426ae 100644 --- a/src/coxpcall.lua +++ b/src/coxpcall.lua @@ -6,21 +6,21 @@ local M = {} -- Checks if (x)pcall function is coroutine safe ------------------------------------------------------------------------------- local function isCoroutineSafe(func) - local co = coroutine.create(function() - return func(coroutine.yield, function() end) - end) + local co = coroutine.create(function () + return func(coroutine.yield, function () end) + end) - coroutine.resume(co) - return coroutine.resume(co) + coroutine.resume(co) + return coroutine.resume(co) end -- Fast path: environment already has coroutine-safe pcall/xpcall if isCoroutineSafe(pcall) and isCoroutineSafe(xpcall) then - -- No globals; just return plain ones - M.pcall = pcall - M.xpcall = xpcall - M.running = coroutine.running - return M + -- No globals; just return plain ones + M.pcall = pcall + M.xpcall = xpcall + M.running = coroutine.running + return M end ------------------------------------------------------------------------------- @@ -29,78 +29,78 @@ end local performResume, handleReturnValue local oldpcall, oldxpcall = pcall, xpcall -local unpack = rawget(table, "unpack") or _G.unpack -local pack = rawget(table, "pack") or function(...) - return { n = select("#", ...), ... } +local unpack = rawget(table, 'unpack') or _G.unpack +local pack = rawget(table, 'pack') or function (...) + return { n = select('#', ...), ... } end -local running = coroutine.running -local coromap = setmetatable({}, { __mode = "k" }) +local running = coroutine.running +local coromap = setmetatable({}, { __mode = 'k' }) local function id(trace) - return trace + return trace end function handleReturnValue(err, co, status, ...) - if not status then - -- Error path from coroutine.resume(co, ...) - if err == id then - -- pcall semantics: propagate the original error object unchanged - -- coroutine.resume returns (false, errmsg, ...), so just - -- pass those “...” through as pcall would. - return false, ... - else - -- xpcall semantics: run the error handler on a traceback - return false, err(debug.traceback(co, (...)), ...) - end - end - - if coroutine.status(co) == 'suspended' then - return performResume(err, co, coroutine.yield(...)) - else - return true, ... - end + if not status then + -- Error path from coroutine.resume(co, ...) + if err == id then + -- pcall semantics: propagate the original error object unchanged + -- coroutine.resume returns (false, errmsg, ...), so just + -- pass those “...” through as pcall would. + return false, ... + else + -- xpcall semantics: run the error handler on a traceback + return false, err(debug.traceback(co, (...)), ...) + end + end + + if coroutine.status(co) == 'suspended' then + return performResume(err, co, coroutine.yield(...)) + else + return true, ... + end end function performResume(err, co, ...) - return handleReturnValue(err, co, coroutine.resume(co, ...)) + return handleReturnValue(err, co, coroutine.resume(co, ...)) end local function coxpcall(f, err, ...) - local current = running() - if not current then - -- Not in a coroutine: fall back to normal pcall/xpcall - if err == id then - return oldpcall(f, ...) - else - if select("#", ...) > 0 then - local oldf, params = f, pack(...) - f = function() return oldf(unpack(params, 1, params.n)) end - end - return oldxpcall(f, err) - end - else - local res, co = oldpcall(coroutine.create, f) - if not res then - local newf = function(...) return f(...) end - co = coroutine.create(newf) - end - coromap[co] = current - return performResume(err, co, ...) - end + local current = running() + if not current then + -- Not in a coroutine: fall back to normal pcall/xpcall + if err == id then + return oldpcall(f, ...) + else + if select('#', ...) > 0 then + local oldf, params = f, pack(...) + f = function () return oldf(unpack(params, 1, params.n)) end + end + return oldxpcall(f, err) + end + else + local res, co = oldpcall(coroutine.create, f) + if not res then + local newf = function (...) return f(...) end + co = coroutine.create(newf) + end + coromap[co] = current + return performResume(err, co, ...) + end end local function corunning(coro) - if coro ~= nil then - assert(type(coro) == "thread", - "Bad argument; expected thread, got: " .. type(coro)) - else - coro = running() - end - while coromap[coro] do - coro = coromap[coro] - end - if coro == "mainthread" then return nil end - return coro + if coro ~= nil then + assert(type(coro) == 'thread', + 'Bad argument; expected thread, got: ' .. type(coro)) + else + coro = running() + end + while coromap[coro] do + coro = coromap[coro] + end + if coro == 'mainthread' then return nil end + return coro end ------------------------------------------------------------------------------- @@ -108,7 +108,7 @@ end ------------------------------------------------------------------------------- local function copcall(f, ...) - return coxpcall(f, id, ...) + return coxpcall(f, id, ...) end M.pcall = copcall diff --git a/src/fibers.lua b/src/fibers.lua index 2896245..0946bfc 100644 --- a/src/fibers.lua +++ b/src/fibers.lua @@ -14,9 +14,9 @@ local Runtime = require 'fibers.runtime' local Scope = require 'fibers.scope' local Performer = require 'fibers.performer' -local unpack = rawget(table, "unpack") or _G.unpack -local pack = rawget(table, "pack") or function(...) - return { n = select("#", ...), ... } +local unpack = rawget(table, 'unpack') or _G.unpack +local pack = rawget(table, 'pack') or function (...) + return { n = select('#', ...), ... } end ---------------------------------------------------------------------- @@ -37,56 +37,56 @@ end ---@param ... any ---@return any ... -- only results from main_fn on success local function run(main_fn, ...) - assert(not Runtime.current_fiber(), - "fibers.run must not be called from inside a fiber") - - local root = Scope.root() - local args = pack(...) - - -- Outcome container populated by the child fiber. - local outcome = { - status = nil, -- "ok" | "failed" | "cancelled" - err = nil, -- primary error / reason - results = nil, -- packed results from main_fn on success - } - - root:spawn(function() - -- Scope.run creates a child scope and runs main_fn(body_scope, ...), - -- returning (status, err, ...results...). - local packed = pack(Scope.run(main_fn, unpack(args, 1, args.n))) - outcome.status = packed[1] - outcome.err = packed[2] - - if packed.n > 3 and outcome.status == "ok" then - local out = { n = packed.n - 3 } - local j = 1 - for i = 4, packed.n do - out[j] = packed[i] - j = j + 1 - end - outcome.results = out - end - - -- Stop the scheduler so Runtime.main() returns. - Runtime.stop() - end) - - -- Drive the scheduler until the main scope decides to stop it. - Runtime.main() - - -- Interpret the outcome. - local status, err, results = outcome.status, outcome.err, outcome.results - - if status ~= "ok" then - -- Re-raise the primary error / cancellation reason. - -- This may be any Lua value (string, table, etc.). - error(err or status) - end - - if results then - return unpack(results, 1, results.n) - end - -- No results from main_fn: return nothing. + assert(not Runtime.current_fiber(), + 'fibers.run must not be called from inside a fiber') + + local root = Scope.root() + local args = pack(...) + + -- Outcome container populated by the child fiber. + local outcome = { + status = nil, -- "ok" | "failed" | "cancelled" + err = nil, -- primary error / reason + results = nil, -- packed results from main_fn on success + } + + root:spawn(function () + -- Scope.run creates a child scope and runs main_fn(body_scope, ...), + -- returning (status, err, ...results...). + local packed = pack(Scope.run(main_fn, unpack(args, 1, args.n))) + outcome.status = packed[1] + outcome.err = packed[2] + + if packed.n > 3 and outcome.status == 'ok' then + local out = { n = packed.n - 3 } + local j = 1 + for i = 4, packed.n do + out[j] = packed[i] + j = j + 1 + end + outcome.results = out + end + + -- Stop the scheduler so Runtime.main() returns. + Runtime.stop() + end) + + -- Drive the scheduler until the main scope decides to stop it. + Runtime.main() + + -- Interpret the outcome. + local status, err, results = outcome.status, outcome.err, outcome.results + + if status ~= 'ok' then + -- Re-raise the primary error / cancellation reason. + -- This may be any Lua value (string, table, etc.). + error(err or status) + end + + if results then + return unpack(results, 1, results.n) + end + -- No results from main_fn: return nothing. end ---------------------------------------------------------------------- @@ -99,41 +99,41 @@ end ---@param fn fun(...): any ---@param ... any local function spawn(fn, ...) - local s = Scope.current() - local args = { ... } + local s = Scope.current() + local args = { ... } - -- Wrapper that discards the scope parameter injected by Scope:spawn. - local function shim(_, ...) - return fn(...) - end + -- Wrapper that discards the scope parameter injected by Scope:spawn. + local function shim(_, ...) + return fn(...) + end - return s:spawn(shim, unpack(args)) + return s:spawn(shim, unpack(args)) end return { - spawn = spawn, - run = run, - - perform = Performer.perform, - - now = Runtime.now, - - choice = Op.choice, - guard = Op.guard, - with_nack = Op.with_nack, - always = Op.always, - never = Op.never, - bracket = Op.bracket, - - -- Higher-level choice helpers - race = Op.race, - first_ready = Op.first_ready, - named_choice = Op.named_choice, - boolean_choice = Op.boolean_choice, - - -- Scope utilities re-exported - run_scope = Scope.run, - with_scope_op = Scope.with_op, - set_unscoped_error_handler = Scope.set_unscoped_error_handler, - current_scope = Scope.current + spawn = spawn, + run = run, + + perform = Performer.perform, + + now = Runtime.now, + + choice = Op.choice, + guard = Op.guard, + with_nack = Op.with_nack, + always = Op.always, + never = Op.never, + bracket = Op.bracket, + + -- Higher-level choice helpers + race = Op.race, + first_ready = Op.first_ready, + named_choice = Op.named_choice, + boolean_choice = Op.boolean_choice, + + -- Scope utilities re-exported + run_scope = Scope.run, + with_scope_op = Scope.with_op, + set_unscoped_error_handler = Scope.set_unscoped_error_handler, + current_scope = Scope.current } diff --git a/src/fibers/alarm.lua b/src/fibers/alarm.lua index 82ffb55..b956927 100644 --- a/src/fibers/alarm.lua +++ b/src/fibers/alarm.lua @@ -65,19 +65,19 @@ local clock_change_cond = cond_mod.new() --- May be called once, when real time is known (RTC, NTP, GNSS, etc.). ---@param now_fn fun(): number local function set_time_source(now_fn) - assert(type(now_fn) == "function", "set_time_source expects a function") - assert(not time_ready, "set_time_source may only be called once") + assert(type(now_fn) == 'function', 'set_time_source expects a function') + assert(not time_ready, 'set_time_source may only be called once') - wall_now = now_fn - time_ready = true + wall_now = now_fn + time_ready = true - -- Wake any fibers that were waiting for time to become ready. - time_ready_cond:signal() + -- Wake any fibers that were waiting for time to become ready. + time_ready_cond:signal() - -- Treat "time became known" as a clock change for any alarms that - -- might start waiting after this point. - clock_change_cond:signal() - clock_change_cond = cond_mod.new() + -- Treat "time became known" as a clock change for any alarms that + -- might start waiting after this point. + clock_change_cond:signal() + clock_change_cond = cond_mod.new() end --- Notify alarms that the civil time mapping has changed. @@ -85,14 +85,14 @@ end --- * the system's wall clock is adjusted (e.g. after NTP sync), or --- * the time zone used by recurrence functions has changed. local function time_changed() - if not time_ready then - -- Before time_ready, no alarm has gone past the readiness gate, - -- so there is nothing meaningful to reschedule. - return - end - - clock_change_cond:signal() - clock_change_cond = cond_mod.new() + if not time_ready then + -- Before time_ready, no alarm has gone past the readiness gate, + -- so there is nothing meaningful to reschedule. + return + end + + clock_change_cond:signal() + clock_change_cond = cond_mod.new() end ---------------------------------------------------------------------- @@ -105,33 +105,33 @@ end ---------------------------------------------------------------------- function Alarm:is_active() - return self._state == "active" + return self._state == 'active' end --- Cancel the alarm permanently. --- No further firings or exhaustion notification will be delivered. function Alarm:cancel() - self._state = "exhausted_done" - self._next_wall = nil + self._state = 'exhausted_done' + self._next_wall = nil end -- Internal: ensure _next_wall is populated or update state on exhaustion. ---@param now number ---@return number|nil function Alarm:_ensure_next(now) - if self._next_wall or self._state ~= "active" then - return self._next_wall - end - - local t = self._next_time(self._last, now) - if not t then - -- No further recurrences: schedule exhaustion notification. - self._state = "exhausted_pending" - return nil - end - - self._next_wall = t - return t + if self._next_wall or self._state ~= 'active' then + return self._next_wall + end + + local t = self._next_time(self._last, now) + if not t then + -- No further recurrences: schedule exhaustion notification. + self._state = 'exhausted_pending' + return nil + end + + self._next_wall = t + return t end --- Main CML-style operation: wait for the alarm to fire once. @@ -155,61 +155,61 @@ end -- for its next firing time, the sleep will be pre-empted and the -- next firing time will be recomputed from the updated civil time. function Alarm:wait_op() - return op.guard(function() - -- Fully inert: no more results of any kind. - if self._state == "exhausted_done" then - return op.never() - end - - -- Time not yet initialised: wait once for readiness, then recurse. - if not time_ready then - local ev = time_ready_cond:wait_op() - return ev:wrap(function() - -- At this point, time_ready is true; perform a fresh wait. - return perform(self:wait_op()) - end) - end - - -- Normal path: real time is available. - local now = wall_now() - self:_ensure_next(now) - - -- If the recurrence has just been exhausted, deliver the - -- one-off exhaustion notification and then become inert. - if self._state == "exhausted_pending" then - self._state = "exhausted_done" - return op.always(false, "no_more_recurrences", self, self._last) - end - - -- We have a valid next_wall at this point. - local next_wall = assert(self._next_wall, "alarm internal error: missing next_wall") - local dt = next_wall - now - if dt < 0 then dt = 0 end - - -- Build a race between: - -- * sleeping until the scheduled time; and - -- * a clock/civil-time change. - -- - -- sleep_op(dt) yields no user-level values on success. - local sleep_ev = sleep_mod.sleep_op(dt) - local change_ev = clock_change_cond:wait_op() - - local choice_ev = op.boolean_choice(sleep_ev, change_ev) - - return choice_ev:wrap(function(is_sleep) - if is_sleep then - -- Timer completed: commit this firing. - self._last = next_wall - self._next_wall = nil - return true, self, self._last - else - -- Clock or time zone changed before the timer fired: - -- clear the stale schedule and recompute under new civil time. - self._next_wall = nil - return perform(self:wait_op()) - end - end) - end) + return op.guard(function () + -- Fully inert: no more results of any kind. + if self._state == 'exhausted_done' then + return op.never() + end + + -- Time not yet initialised: wait once for readiness, then recurse. + if not time_ready then + local ev = time_ready_cond:wait_op() + return ev:wrap(function () + -- At this point, time_ready is true; perform a fresh wait. + return perform(self:wait_op()) + end) + end + + -- Normal path: real time is available. + local now = wall_now() + self:_ensure_next(now) + + -- If the recurrence has just been exhausted, deliver the + -- one-off exhaustion notification and then become inert. + if self._state == 'exhausted_pending' then + self._state = 'exhausted_done' + return op.always(false, 'no_more_recurrences', self, self._last) + end + + -- We have a valid next_wall at this point. + local next_wall = assert(self._next_wall, 'alarm internal error: missing next_wall') + local dt = next_wall - now + if dt < 0 then dt = 0 end + + -- Build a race between: + -- * sleeping until the scheduled time; and + -- * a clock/civil-time change. + -- + -- sleep_op(dt) yields no user-level values on success. + local sleep_ev = sleep_mod.sleep_op(dt) + local change_ev = clock_change_cond:wait_op() + + local choice_ev = op.boolean_choice(sleep_ev, change_ev) + + return choice_ev:wrap(function (is_sleep) + if is_sleep then + -- Timer completed: commit this firing. + self._last = next_wall + self._next_wall = nil + return true, self, self._last + else + -- Clock or time zone changed before the timer fired: + -- clear the stale schedule and recompute under new civil time. + self._next_wall = nil + return perform(self:wait_op()) + end + end) + end) end -- Convenience alias: treat the alarm itself as an Event factory. @@ -229,26 +229,26 @@ Alarm.event = Alarm.wait_op ---@param params AlarmNewParams ---@return Alarm local function new(params) - assert(type(params) == "table", "alarm.new expects a parameter table") - local next_time = params.next_time - assert(type(next_time) == "function", "alarm.new: next_time function required") + assert(type(params) == 'table', 'alarm.new expects a parameter table') + local next_time = params.next_time + assert(type(next_time) == 'function', 'alarm.new: next_time function required') - local self = setmetatable({ - _next_time = next_time, - _policy = params.policy, - _label = params.label or "", + local self = setmetatable({ + _next_time = next_time, + _policy = params.policy, + _label = params.label or '', - _last = nil, -- last fired wall-clock epoch - _next_wall = nil, -- next scheduled wall-clock epoch - _state = "active", -- lifecycle state - }, Alarm) + _last = nil, -- last fired wall-clock epoch + _next_wall = nil, -- next scheduled wall-clock epoch + _state = 'active', -- lifecycle state + }, Alarm) - return self + return self end return { - Alarm = Alarm, - new = new, - set_time_source = set_time_source, - time_changed = time_changed, + Alarm = Alarm, + new = new, + set_time_source = set_time_source, + time_changed = time_changed, } diff --git a/src/fibers/channel.lua b/src/fibers/channel.lua index 71c20a9..25e272c 100644 --- a/src/fibers/channel.lua +++ b/src/fibers/channel.lua @@ -19,19 +19,19 @@ Channel.__index = Channel ---@param buffer_size? integer # buffered capacity (0 or nil for unbuffered) ---@return Channel local function new(buffer_size) - buffer_size = buffer_size or 0 - - local buffer = nil - if buffer_size > 0 then - buffer = fifo.new() - end - - return setmetatable({ - buffer = buffer, - buffer_size = buffer_size, - getq = fifo.new(), -- waiting receivers - putq = fifo.new(), -- waiting senders - }, Channel) + buffer_size = buffer_size or 0 + + local buffer = nil + if buffer_size > 0 then + buffer = fifo.new() + end + + return setmetatable({ + buffer = buffer, + buffer_size = buffer_size, + getq = fifo.new(), -- waiting receivers + putq = fifo.new(), -- waiting senders + }, Channel) end ---------------------------------------------------------------------- @@ -42,13 +42,13 @@ end ---@param q any ---@return table|nil local function pop_active(q) - while not q:empty() do - local entry = q:pop() - if not entry.suspension or entry.suspension:waiting() then - return entry - end - end - return nil + while not q:empty() do + local entry = q:pop() + if not entry.suspension or entry.suspension:waiting() then + return entry + end + end + return nil end --- Op that sends val on the channel. @@ -57,115 +57,115 @@ end ---@param val any ---@return Op function Channel:put_op(val) - local getq, putq = self.getq, self.putq - local buffer, buffer_size = self.buffer, self.buffer_size - - ---@class ChannelPutEntry - ---@field val any - ---@field suspension Suspension|nil - ---@field wrap WrapFn|nil - local entry = { - val = val, - suspension = nil, - wrap = nil, - } - - local function try() - -- Case 1: rendezvous with a waiting receiver. - local recv = pop_active(getq) - if recv then - recv.suspension:complete(recv.wrap, val) - return true - end - -- Case 2: buffered channel with available space. - if buffer and buffer:length() < buffer_size then - buffer:push(val) - return true - end - -- Case 3: no receiver and no buffer space. - return false - end - - --- Enqueue as a waiting sender when the put cannot complete immediately. - ---@param suspension Suspension - ---@param wrap_fn WrapFn - local function block(suspension, wrap_fn) - entry.suspension = suspension - entry.wrap = wrap_fn - putq:push(entry) - end - - return op.new_primitive(nil, try, block) + local getq, putq = self.getq, self.putq + local buffer, buffer_size = self.buffer, self.buffer_size + + ---@class ChannelPutEntry + ---@field val any + ---@field suspension Suspension|nil + ---@field wrap WrapFn|nil + local entry = { + val = val, + suspension = nil, + wrap = nil, + } + + local function try() + -- Case 1: rendezvous with a waiting receiver. + local recv = pop_active(getq) + if recv then + recv.suspension:complete(recv.wrap, val) + return true + end + -- Case 2: buffered channel with available space. + if buffer and buffer:length() < buffer_size then + buffer:push(val) + return true + end + -- Case 3: no receiver and no buffer space. + return false + end + + --- Enqueue as a waiting sender when the put cannot complete immediately. + ---@param suspension Suspension + ---@param wrap_fn WrapFn + local function block(suspension, wrap_fn) + entry.suspension = suspension + entry.wrap = wrap_fn + putq:push(entry) + end + + return op.new_primitive(nil, try, block) end --- Op that receives a value from the channel. --- May take from the buffer or rendezvous directly with a sender. ---@return Op function Channel:get_op() - local getq, putq = self.getq, self.putq - local buffer = self.buffer - - ---@class ChannelGetEntry - ---@field suspension Suspension|nil - ---@field wrap WrapFn|nil - local entry = { - suspension = nil, - wrap = nil, - } - - local function pop_sender() - local sender = pop_active(putq) - if not sender then - return nil - end - -- Having chosen this sender, complete its suspension immediately. - sender.suspension:complete(sender.wrap) - return sender - end - - local function try() - local remote = pop_sender() - -- Case 1: take from buffer if there is a buffered value. - if buffer and buffer:length() > 0 then - local v = buffer:pop() - -- If there was a sender waiting, refill the buffer with its value. - if remote then - buffer:push(remote.val) - end - return true, v - end - -- Case 2: no buffered value; take directly from a sender. - if remote then - return true, remote.val - end - -- Case 3: nothing available. - return false - end - - --- Enqueue as a waiting receiver when no value is immediately available. - ---@param suspension Suspension - ---@param wrap_fn WrapFn - local function block(suspension, wrap_fn) - entry.suspension = suspension - entry.wrap = wrap_fn - getq:push(entry) - end - - return op.new_primitive(nil, try, block) + local getq, putq = self.getq, self.putq + local buffer = self.buffer + + ---@class ChannelGetEntry + ---@field suspension Suspension|nil + ---@field wrap WrapFn|nil + local entry = { + suspension = nil, + wrap = nil, + } + + local function pop_sender() + local sender = pop_active(putq) + if not sender then + return nil + end + -- Having chosen this sender, complete its suspension immediately. + sender.suspension:complete(sender.wrap) + return sender + end + + local function try() + local remote = pop_sender() + -- Case 1: take from buffer if there is a buffered value. + if buffer and buffer:length() > 0 then + local v = buffer:pop() + -- If there was a sender waiting, refill the buffer with its value. + if remote then + buffer:push(remote.val) + end + return true, v + end + -- Case 2: no buffered value; take directly from a sender. + if remote then + return true, remote.val + end + -- Case 3: nothing available. + return false + end + + --- Enqueue as a waiting receiver when no value is immediately available. + ---@param suspension Suspension + ---@param wrap_fn WrapFn + local function block(suspension, wrap_fn) + entry.suspension = suspension + entry.wrap = wrap_fn + getq:push(entry) + end + + return op.new_primitive(nil, try, block) end --- Synchronously send message on the channel. ---@param message any function Channel:put(message) - return perform(self:put_op(message)) + return perform(self:put_op(message)) end --- Synchronously receive a message from the channel. ---@return any function Channel:get() - return perform(self:get_op()) + return perform(self:get_op()) end return { - new = new, + new = new, } diff --git a/src/fibers/cond.lua b/src/fibers/cond.lua index 558d0f8..ae89a14 100644 --- a/src/fibers/cond.lua +++ b/src/fibers/cond.lua @@ -17,45 +17,45 @@ Cond.__index = Cond --- Build an Op that becomes ready when the condition fires. ---@return Op function Cond:wait_op() - local os = self._os - - return op.new_primitive( - nil, - function() - return os:is_triggered() - end, - --- Arrange to complete this suspension when the condition fires. - ---@param resumer Suspension - ---@param wrap_fn WrapFn - function(resumer, wrap_fn) - os:add_waiter(function() - if resumer:waiting() then - resumer:complete(wrap_fn) - end - end) - end - ) + local os = self._os + + return op.new_primitive( + nil, + function () + return os:is_triggered() + end, + --- Arrange to complete this suspension when the condition fires. + ---@param resumer Suspension + ---@param wrap_fn WrapFn + function (resumer, wrap_fn) + os:add_waiter(function () + if resumer:waiting() then + resumer:complete(wrap_fn) + end + end) + end + ) end --- Block the current fiber until the condition fires. ---@return any ... function Cond:wait() - return perform(self:wait_op()) + return perform(self:wait_op()) end --- Signal the condition (idempotent). function Cond:signal() - return self._os:signal() + return self._os:signal() end --- Create a new condition. ---@return Cond local function new() - return setmetatable({ - _os = oneshot.new(), -- no extra callback - }, Cond) + return setmetatable({ + _os = oneshot.new(), -- no extra callback + }, Cond) end return { - new = new, + new = new, } diff --git a/src/fibers/io/exec.lua b/src/fibers/io/exec.lua index 7ce25e1..d041c0c 100644 --- a/src/fibers/io/exec.lua +++ b/src/fibers/io/exec.lua @@ -33,15 +33,6 @@ local DEFAULT_SHUTDOWN_GRACE = 1.0 ---@alias CommandStatus "pending"|"running"|"exited"|"signalled"|"failed" ---- Backend handle for a running process. ----@class ExecBackend ----@field pid integer|nil ----@field wait_op fun(self: ExecBackend): Op ----@field terminate fun(self: ExecBackend)|nil ----@field kill fun(self: ExecBackend): boolean, string|nil|nil ----@field send_signal fun(self: ExecBackend, signal?: any): boolean, string|nil|nil ----@field close fun(self: ExecBackend): boolean, string|nil - --- Process handle returned by the backend. ---@class ProcHandle ---@field backend ExecBackend @@ -80,34 +71,34 @@ Command.__index = Command ---@param is_stderr boolean ---@return ExecStreamConfig local function norm_stream(value, is_stderr) - if value == nil then - return { mode = "inherit", stream = nil, owned = true } - end - - local t = type(value) - if t == "string" then - if value == "inherit" or value == "null" or value == "pipe" then - return { mode = value, stream = nil, owned = true } - end - if is_stderr and value == "stdout" then - return { mode = "stdout", stream = nil, owned = true } - end - error("invalid stdio mode: " .. tostring(value)) - end - - if stream_mod.is_stream(value) then - -- A user-supplied stream is not owned by the Command. - return { mode = "stream", stream = value, owned = false } - end - - error("invalid stdio configuration: " .. tostring(value)) + if value == nil then + return { mode = 'inherit', stream = nil, owned = true } + end + + local t = type(value) + if t == 'string' then + if value == 'inherit' or value == 'null' or value == 'pipe' then + return { mode = value, stream = nil, owned = true } + end + if is_stderr and value == 'stdout' then + return { mode = 'stdout', stream = nil, owned = true } + end + error('invalid stdio mode: ' .. tostring(value)) + end + + if stream_mod.is_stream(value) then + -- A user-supplied stream is not owned by the Command. + return { mode = 'stream', stream = value, owned = false } + end + + error('invalid stdio configuration: ' .. tostring(value)) end ---@param self Command local function assert_not_started(self) - if self._started then - error("command already started") - end + if self._started then + error('command already started') + end end --- Perform an op using the current scope when it is still running, @@ -117,14 +108,14 @@ end ---@param ev Op ---@return any ... local function perform_with_scope_or_raw(ev) - local s = Scope.current() - if s and s.perform then - local status = s:status() - if status == "running" then - return s:perform(ev) - end - end - return op.perform_raw(ev) + local s = Scope.current() + if s and s.perform then + local status = s:status() + if status == 'running' then + return s:perform(ev) + end + end + return op.perform_raw(ev) end ---------------------------------------------------------------------- @@ -136,20 +127,20 @@ end ---@param signal integer|nil ---@param err string|nil function Command:_record_exit(code, signal, err) - if self._done then return end - self._done = true - - if err then - self._status, self._err = "failed", err - elseif signal ~= nil then - self._status, self._signal = "signalled", signal - elseif code ~= nil then - self._status, self._code = "exited", code - else - self._status, self._err = "failed", "unknown process status" - end - - self._code, self._signal = code, signal + if self._done then return end + self._done = true + + if err then + self._status, self._err = 'failed', err + elseif signal ~= nil then + self._status, self._signal = 'signalled', signal + elseif code ~= nil then + self._status, self._code = 'exited', code + else + self._status, self._err = 'failed', 'unknown process status' + end + + self._code, self._signal = code, signal end --- Ensure the process has been started and a ProcHandle exists. @@ -157,58 +148,58 @@ end ---@return ProcHandle|nil proc ---@return string|nil err function Command:_ensure_started() - if self._started then - if self._proc then - return true, self._proc, nil - end - if self._status == "failed" then - return false, nil, self._err - end - return false, nil, "exec: command started without backend" - end - self._started = true - - -- High-level process spec passed to the backend. - local spec = { - argv = self._argv, - cwd = self._cwd, - env = self._env, - flags = self._flags, - stdin = self._stdin, - stdout = self._stdout, - stderr = self._stderr, - } - - -- Backend returns a ProcHandle: - -- { backend = ExecBackend, stdin = Stream|nil, stdout = Stream|nil, stderr = Stream|nil } - local proc_handle, start_err = proc_mod.start(spec) - if not proc_handle then - self._status = "failed" - self._done = true - self._err = start_err - return false, nil, start_err - end - - self._proc = proc_handle - self._pid = proc_handle.backend and proc_handle.backend.pid or nil - self._status = "running" - - -- If the backend created pipe streams for us, record them and mark them as owned - -- so they are cleaned up in _on_scope_exit. - if proc_handle.stdin then - self._stdin.stream = proc_handle.stdin - self._stdin.owned = true - end - if proc_handle.stdout then - self._stdout.stream = proc_handle.stdout - self._stdout.owned = true - end - if proc_handle.stderr then - self._stderr.stream = proc_handle.stderr - self._stderr.owned = true - end - - return true, proc_handle, nil + if self._started then + if self._proc then + return true, self._proc, nil + end + if self._status == 'failed' then + return false, nil, self._err + end + return false, nil, 'exec: command started without backend' + end + self._started = true + + -- High-level process spec passed to the backend. + local spec = { + argv = self._argv, + cwd = self._cwd, + env = self._env, + flags = self._flags, + stdin = self._stdin, + stdout = self._stdout, + stderr = self._stderr, + } + + -- Backend returns a ProcHandle: + -- { backend = ExecBackend, stdin = Stream|nil, stdout = Stream|nil, stderr = Stream|nil } + local proc_handle, start_err = proc_mod.start(spec) + if not proc_handle then + self._status = 'failed' + self._done = true + self._err = start_err + return false, nil, start_err + end + + self._proc = proc_handle + self._pid = proc_handle.backend and proc_handle.backend.pid or nil + self._status = 'running' + + -- If the backend created pipe streams for us, record them and mark them as owned + -- so they are cleaned up in _on_scope_exit. + if proc_handle.stdin then + self._stdin.stream = proc_handle.stdin + self._stdin.owned = true + end + if proc_handle.stdout then + self._stdout.stream = proc_handle.stdout + self._stdout.owned = true + end + if proc_handle.stderr then + self._stderr.stream = proc_handle.stderr + self._stderr.owned = true + end + + return true, proc_handle, nil end ---------------------------------------------------------------------- @@ -219,63 +210,63 @@ end ---@param v ExecStdin|nil ---@return Command function Command:set_stdin(v) - assert_not_started(self) - self._stdin = norm_stream(v, false) - return self + assert_not_started(self) + self._stdin = norm_stream(v, false) + return self end --- Set the stdout configuration for this command. ---@param v ExecStdout|nil ---@return Command function Command:set_stdout(v) - assert_not_started(self) - self._stdout = norm_stream(v, false) - return self + assert_not_started(self) + self._stdout = norm_stream(v, false) + return self end --- Set the stderr configuration for this command. ---@param v ExecStderr|nil ---@return Command function Command:set_stderr(v) - assert_not_started(self) - self._stderr = norm_stream(v, true) - return self + assert_not_started(self) + self._stderr = norm_stream(v, true) + return self end --- Set the working directory for this command. ---@param v string|nil ---@return Command function Command:set_cwd(v) - assert_not_started(self) - self._cwd = v - return self + assert_not_started(self) + self._cwd = v + return self end --- Set the environment for this command. ---@param v table|nil ---@return Command function Command:set_env(v) - assert_not_started(self) - self._env = v - return self + assert_not_started(self) + self._env = v + return self end --- Set backend-specific flags for this command. ---@param v table|nil ---@return Command function Command:set_flags(v) - assert_not_started(self) - self._flags = v or {} - return self + assert_not_started(self) + self._flags = v or {} + return self end --- Set the shutdown grace period in seconds. ---@param v number ---@return Command function Command:set_shutdown_grace(v) - assert_not_started(self) - self._shutdown_grace = v - return self + assert_not_started(self) + self._shutdown_grace = v + return self end ---------------------------------------------------------------------- @@ -287,36 +278,36 @@ end ---@return integer|nil code_or_signal ---@return string|nil err function Command:status() - local st = self._status - - if st == "exited" then - return st, self._code, self._err - elseif st == "signalled" then - return st, self._signal, self._err - elseif st == "failed" then - return st, nil, self._err - elseif st == "pending" or st == "running" then - return st, nil, nil - end - - -- Fallback for any unexpected status value. - return st, nil, self._err + local st = self._status + + if st == 'exited' then + return st, self._code, self._err + elseif st == 'signalled' then + return st, self._signal, self._err + elseif st == 'failed' then + return st, nil, self._err + elseif st == 'pending' or st == 'running' then + return st, nil, nil + end + + -- Fallback for any unexpected status value. + return st, nil, self._err end --- Return the process ID, if known. ---@return integer|nil pid function Command:pid() - return self._pid + return self._pid end --- Return a shallow copy of the argv array. ---@return string[] function Command:argv() - local out = {} - for i, v in ipairs(self._argv) do - out[i] = v - end - return out + local out = {} + for i, v in ipairs(self._argv) do + out[i] = v + end + return out end ---------------------------------------------------------------------- @@ -328,36 +319,36 @@ end ---@return boolean ok ---@return string|nil err function Command:kill(sig) - if self._done then - return true, nil - end - if not self._started then - return false, "command not started" - end - if self._status == "failed" then - return false, self._err or "command failed to start" - end - - local backend = self._proc and self._proc.backend or nil - if not backend then - return false, "no backend available" - end - - -- If the caller supplied an explicit signal/token and the backend exposes send_signal, pass it through. - if sig ~= nil and backend.send_signal then - return backend:send_signal(sig) - end - - -- Otherwise, prefer a forceful kill if implemented, fall back to terminate or a default send_signal. - if backend.kill then - return backend:kill() - elseif backend.terminate then - return backend:terminate() - elseif backend.send_signal then - return backend:send_signal() - end - - return false, "backend does not support signalling" + if self._done then + return true, nil + end + if not self._started then + return false, 'command not started' + end + if self._status == 'failed' then + return false, self._err or 'command failed to start' + end + + local backend = self._proc and self._proc.backend or nil + if not backend then + return false, 'no backend available' + end + + -- If the caller supplied an explicit signal/token and the backend exposes send_signal, pass it through. + if sig ~= nil and backend.send_signal then + return backend:send_signal(sig) + end + + -- Otherwise, prefer a forceful kill if implemented, fall back to terminate or a default send_signal. + if backend.kill then + return backend:kill() + elseif backend.terminate then + return backend:terminate() + elseif backend.send_signal then + return backend:send_signal() + end + + return false, 'backend does not support signalling' end ---------------------------------------------------------------------- @@ -368,57 +359,57 @@ end ---@return Stream|nil stream ---@return string|nil err function Command:stdin_stream() - local cfg = self._stdin - if cfg.mode == "inherit" or cfg.mode == "null" then - return nil - end - if cfg.mode == "stream" then - return cfg.stream - end - if cfg.mode == "pipe" then - local ok, _, err = self:_ensure_started() - return ok and self._stdin.stream or nil, err - end - return nil + local cfg = self._stdin + if cfg.mode == 'inherit' or cfg.mode == 'null' then + return nil + end + if cfg.mode == 'stream' then + return cfg.stream + end + if cfg.mode == 'pipe' then + local ok, _, err = self:_ensure_started() + return ok and self._stdin.stream or nil, err + end + return nil end --- Return a readable stdout stream for this command, if configured. ---@return Stream|nil stream ---@return string|nil err function Command:stdout_stream() - local cfg = self._stdout - if cfg.mode == "inherit" or cfg.mode == "null" then - return nil - end - if cfg.mode == "stream" then - return cfg.stream - end - if cfg.mode == "pipe" then - local ok, _, err = self:_ensure_started() - return ok and self._stdout.stream or nil, err - end - return nil + local cfg = self._stdout + if cfg.mode == 'inherit' or cfg.mode == 'null' then + return nil + end + if cfg.mode == 'stream' then + return cfg.stream + end + if cfg.mode == 'pipe' then + local ok, _, err = self:_ensure_started() + return ok and self._stdout.stream or nil, err + end + return nil end --- Return a readable stderr stream for this command, if configured. ---@return Stream|nil stream ---@return string|nil err function Command:stderr_stream() - local cfg = self._stderr - if cfg.mode == "inherit" or cfg.mode == "null" then - return nil - end - if cfg.mode == "stream" then - return cfg.stream - end - if cfg.mode == "pipe" then - local ok, _, err = self:_ensure_started() - return ok and self._stderr.stream or nil, err - end - if cfg.mode == "stdout" then - return self:stdout_stream() - end - return nil + local cfg = self._stderr + if cfg.mode == 'inherit' or cfg.mode == 'null' then + return nil + end + if cfg.mode == 'stream' then + return cfg.stream + end + if cfg.mode == 'pipe' then + local ok, _, err = self:_ensure_started() + return ok and self._stderr.stream or nil, err + end + if cfg.mode == 'stdout' then + return self:stdout_stream() + end + return nil end ---------------------------------------------------------------------- @@ -434,20 +425,20 @@ end --- err : string|nil ---@return Op function Command:run_op() - return op.guard(function() - local ok, proc, err = self:_ensure_started() - if not ok or not proc then - return op.always("failed", nil, nil, err) - end - if self._done then - return op.always(self._status, self._code, self._signal, self._err) - end - - return proc.backend:wait_op():wrap(function(...) - self:_record_exit(...) - return self._status, self._code, self._signal, self._err - end) - end) + return op.guard(function () + local ok, proc, err = self:_ensure_started() + if not ok or not proc then + return op.always('failed', nil, nil, err) + end + if self._done then + return op.always(self._status, self._code, self._signal, self._err) + end + + return proc.backend:wait_op():wrap(function (...) + self:_record_exit(...) + return self._status, self._code, self._signal, self._err + end) + end) end --- Op that attempts graceful shutdown, then forceful kill after a grace period. @@ -460,65 +451,65 @@ end ---@param grace number|nil ---@return Op function Command:shutdown_op(grace) - return op.guard(function() - local ok, proc, err = self:_ensure_started() - if not (ok and proc) then - return op.always("failed", nil, nil, err) - end - if self._done then - return op.always(self._status, self._code, self._signal, self._err) - end - - local g = grace or self._shutdown_grace or DEFAULT_SHUTDOWN_GRACE - - -- Polite termination: delegate behaviour to backend. - if proc.backend and proc.backend.terminate then - proc.backend:terminate() - elseif proc.backend and proc.backend.send_signal then - proc.backend:send_signal() - end - - local choice_ev = op.boolean_choice( - self:run_op():wrap(function(status, code, signal, e) - return true, status, code, signal, e - end), - sleep.sleep_op(g):wrap(function() - return false - end) - ) - - return choice_ev:wrap(function(is_exit, status, code, signal, e) - if is_exit then - return status, code, signal, e - end - - -- Grace period elapsed. Try a forceful kill. - local kill_err - if proc.backend then - if proc.backend.kill then - local ok2, err2 = proc.backend:kill() - if not ok2 and err2 then - kill_err = err2 - end - elseif proc.backend.send_signal then - local ok2, err2 = proc.backend:send_signal() - if not ok2 and err2 then - kill_err = err2 - end - end - end - - -- Wait for the process to complete. - -- Use the current scope if it is still running so that - -- callers see cancellation; fall back to a raw perform - -- during scope cleanup (where the owning scope is already - -- in a terminal state). - local code2, signal2, err2 = perform_with_scope_or_raw(proc.backend:wait_op()) - self:_record_exit(code2, signal2, err2) - local err_final = kill_err or err2 - return self._status, self._code, self._signal, err_final - end) - end) + return op.guard(function () + local ok, proc, err = self:_ensure_started() + if not (ok and proc) then + return op.always('failed', nil, nil, err) + end + if self._done then + return op.always(self._status, self._code, self._signal, self._err) + end + + local g = grace or self._shutdown_grace or DEFAULT_SHUTDOWN_GRACE + + -- Polite termination: delegate behaviour to backend. + if proc.backend and proc.backend.terminate then + proc.backend:terminate() + elseif proc.backend and proc.backend.send_signal then + proc.backend:send_signal() + end + + local choice_ev = op.boolean_choice( + self:run_op():wrap(function (status, code, signal, e) + return true, status, code, signal, e + end), + sleep.sleep_op(g):wrap(function () + return false + end) + ) + + return choice_ev:wrap(function (is_exit, status, code, signal, e) + if is_exit then + return status, code, signal, e + end + + -- Grace period elapsed. Try a forceful kill. + local kill_err + if proc.backend then + if proc.backend.kill then + local ok2, err2 = proc.backend:kill() + if not ok2 and err2 then + kill_err = err2 + end + elseif proc.backend.send_signal then + local ok2, err2 = proc.backend:send_signal() + if not ok2 and err2 then + kill_err = err2 + end + end + end + + -- Wait for the process to complete. + -- Use the current scope if it is still running so that + -- callers see cancellation; fall back to a raw perform + -- during scope cleanup (where the owning scope is already + -- in a terminal state). + local code2, signal2, err2 = perform_with_scope_or_raw(proc.backend:wait_op()) + self:_record_exit(code2, signal2, err2) + local err_final = kill_err or err2 + return self._status, self._code, self._signal, err_final + end) + end) end --- Op that collects stdout and waits for the process to exit. @@ -531,28 +522,28 @@ end --- err : string|nil ---@return Op function Command:output_op() - return op.guard(function() - -- If stdout is currently inherited, default to piping for this helper. - if not self._started and (self._stdout.mode == "inherit" or self._stdout.mode == nil) then - self._stdout = norm_stream("pipe", false) - end - - local ok, _, err = self:_ensure_started() - if not ok then - return op.always("", "failed", nil, nil, err) - end - - local stream, serr = self:stdout_stream() - if not stream then - return op.always("", "failed", nil, nil, serr or "no stdout stream available") - end - - return stream:read_all_op():wrap(function(out, io_err) - local status, code, signal, perr = perform_with_scope_or_raw(self:run_op()) - local err_final = io_err or perr - return out or "", status, code, signal, err_final - end) - end) + return op.guard(function () + -- If stdout is currently inherited, default to piping for this helper. + if not self._started and (self._stdout.mode == 'inherit' or self._stdout.mode == nil) then + self._stdout = norm_stream('pipe', false) + end + + local ok, _, err = self:_ensure_started() + if not ok then + return op.always('', 'failed', nil, nil, err) + end + + local stream, serr = self:stdout_stream() + if not stream then + return op.always('', 'failed', nil, nil, serr or 'no stdout stream available') + end + + return stream:read_all_op():wrap(function (out, io_err) + local status, code, signal, perr = perform_with_scope_or_raw(self:run_op()) + local err_final = io_err or perr + return out or '', status, code, signal, err_final + end) + end) end --- Op that collects combined stdout+stderr and waits for exit. @@ -565,13 +556,13 @@ end --- err : string|nil ---@return Op function Command:combined_output_op() - if self._stderr.mode == "pipe" or self._stderr.mode == "stream" then - error("combined_output_op: stderr must not already be a pipe or stream") - end - if not self._started and (self._stderr.mode == "inherit" or self._stderr.mode == nil) then - self._stderr = norm_stream("stdout", true) - end - return self:output_op() + if self._stderr.mode == 'pipe' or self._stderr.mode == 'stream' then + error('combined_output_op: stderr must not already be a pipe or stream') + end + if not self._started and (self._stderr.mode == 'inherit' or self._stderr.mode == nil) then + self._stderr = norm_stream('stdout', true) + end + return self:output_op() end ---------------------------------------------------------------------- @@ -580,30 +571,30 @@ end --- Scope finaliser: best-effort shutdown and resource cleanup. function Command:_on_scope_exit() - if self._started and not self._done then - -- Best-effort shutdown. Any error here will be caught by the - -- scope's finaliser machinery and recorded as a scope failure. - op.perform_raw(self:shutdown_op(self._shutdown_grace)) - end - - for _, name in ipairs { "stdin", "stdout", "stderr" } do - local cfg = self["_" .. name] - if cfg.stream and cfg.owned then - local ok, err = cfg.stream:close() - if not ok then - error(err or ("failed to close " .. name .. " stream")) - end - cfg.stream = nil - end - end - - if self._proc and self._proc.backend then - local ok, err = self._proc.backend:close() - if not ok then - error(err or "failed to close process backend") - end - self._proc.backend = nil - end + if self._started and not self._done then + -- Best-effort shutdown. Any error here will be caught by the + -- scope's finaliser machinery and recorded as a scope failure. + op.perform_raw(self:shutdown_op(self._shutdown_grace)) + end + + for _, name in ipairs { 'stdin', 'stdout', 'stderr' } do + local cfg = self['_' .. name] + if cfg.stream and cfg.owned then + local ok, err = cfg.stream:close() + if not ok then + error(err or ('failed to close ' .. name .. ' stream')) + end + cfg.stream = nil + end + end + + if self._proc and self._proc.backend then + local ok, err = self._proc.backend:close() + if not ok then + error(err or 'failed to close process backend') + end + self._proc.backend = nil + end end ---------------------------------------------------------------------- @@ -614,42 +605,42 @@ end ---@param spec ExecSpec ---@return Command local function command_from_spec(spec) - assert(Runtime.current_fiber(), "exec.command must be called from inside a fiber") - local scope = Scope.current() - - local argv = {} - local i = 1 - while spec[i] ~= nil do - argv[i] = assert(spec[i], "argv must not contain nil") - i = i + 1 - end - assert(argv[1], "exec.command: argv[1] must be non-nil") - - local cmd = setmetatable({ - _scope = scope, - _argv = argv, - _cwd = spec.cwd, - _env = spec.env, - _flags = spec.flags or {}, - _stdin = norm_stream(spec.stdin, false), - _stdout = norm_stream(spec.stdout, false), - _stderr = norm_stream(spec.stderr, true), - _shutdown_grace = spec.shutdown_grace or DEFAULT_SHUTDOWN_GRACE, - _started = false, - _done = false, - _proc = nil, - _pid = nil, - _status = "pending", - _code = nil, - _signal = nil, - _err = nil, - }, Command) - - scope:finally(function() - cmd:_on_scope_exit() - end) - - return cmd + assert(Runtime.current_fiber(), 'exec.command must be called from inside a fiber') + local scope = Scope.current() + + local argv = {} + local i = 1 + while spec[i] ~= nil do + argv[i] = assert(spec[i], 'argv must not contain nil') + i = i + 1 + end + assert(argv[1], 'exec.command: argv[1] must be non-nil') + + local cmd = setmetatable({ + _scope = scope, + _argv = argv, + _cwd = spec.cwd, + _env = spec.env, + _flags = spec.flags or {}, + _stdin = norm_stream(spec.stdin, false), + _stdout = norm_stream(spec.stdout, false), + _stderr = norm_stream(spec.stderr, true), + _shutdown_grace = spec.shutdown_grace or DEFAULT_SHUTDOWN_GRACE, + _started = false, + _done = false, + _proc = nil, + _pid = nil, + _status = 'pending', + _code = nil, + _signal = nil, + _err = nil, + }, Command) + + scope:finally(function () + cmd:_on_scope_exit() + end) + + return cmd end ---------------------------------------------------------------------- @@ -671,17 +662,17 @@ local exec = {} ---@param ... any ---@return Command function exec.command(...) - local n = select("#", ...) - if n == 1 and type((...)) == "table" then - return command_from_spec((...)) - end - - assert(n > 0, "exec.command: at least one argv element required") - local spec = {} - for i = 1, n do - spec[i] = assert(select(i, ...), "argv must not contain nil") - end - return command_from_spec(spec) + local n = select('#', ...) + if n == 1 and type((...)) == 'table' then + return command_from_spec((...)) + end + + assert(n > 0, 'exec.command: at least one argv element required') + local spec = {} + for i = 1, n do + spec[i] = assert(select(i, ...), 'argv must not contain nil') + end + return command_from_spec(spec) end exec.Command = Command diff --git a/src/fibers/io/exec_backend.lua b/src/fibers/io/exec_backend.lua index c3902b5..7778775 100644 --- a/src/fibers/io/exec_backend.lua +++ b/src/fibers/io/exec_backend.lua @@ -20,25 +20,25 @@ ---@type string[] local candidates = { - 'fibers.io.exec_backend.pidfd', -- Linux pidfd backend - 'fibers.io.exec_backend.sigchld', -- Portable SIGCHLD + self-pipe backend (luaposix) - 'fibers.io.exec_backend.nixio', -- Portable SIGCHLD + self-pipe backend (nixio) + 'fibers.io.exec_backend.pidfd', -- Linux pidfd backend + 'fibers.io.exec_backend.sigchld', -- Portable SIGCHLD + self-pipe backend (luaposix) + 'fibers.io.exec_backend.nixio', -- Portable SIGCHLD + self-pipe backend (nixio) } ---@type ExecBackendModule|nil local chosen for _, name in ipairs(candidates) do - local ok, mod = pcall(require, name) - if ok and type(mod) == "table" and mod.is_supported and mod.is_supported() then - ---@cast mod ExecBackendModule - chosen = mod - break - end + local ok, mod = pcall(require, name) + if ok and type(mod) == 'table' and mod.is_supported and mod.is_supported() then + ---@cast mod ExecBackendModule + chosen = mod + break + end end if not chosen then - error("fibers.io.exec_backend: no suitable process backend available on this platform") + error('fibers.io.exec_backend: no suitable process backend available on this platform') end ---@return ExecBackendModule diff --git a/src/fibers/io/exec_backend/core.lua b/src/fibers/io/exec_backend/core.lua index 8659d40..73740e3 100644 --- a/src/fibers/io/exec_backend/core.lua +++ b/src/fibers/io/exec_backend/core.lua @@ -25,74 +25,74 @@ ExecBackend.__index = ExecBackend --- Wait for the process to complete, returning (code, signal, err). ---@return Op function ExecBackend:wait_op() - local ops = self._ops - local state = self._state - - local function step() - if self.exited then - return true, self.code, self.signal, self.err - end - - local done, code, signal, err = ops.poll(state) - if not done then - return false - end - - self.exited = true - self.code = code - self.signal = signal - self.err = err - return true, self.code, self.signal, self.err - end - - ---@param task Task - ---@param suspension Suspension - ---@param leaf_wrap WrapFn - local function register(task, suspension, leaf_wrap) - return ops.register_wait(state, task, suspension, leaf_wrap) - end - - local function wrap(code, signal, err) - return code, signal, err - end - - return waitmod.waitable(register, step, wrap) + local ops = self._ops + local state = self._state + + local function step() + if self.exited then + return true, self.code, self.signal, self.err + end + + local done, code, signal, err = ops.poll(state) + if not done then + return false + end + + self.exited = true + self.code = code + self.signal = signal + self.err = err + return true, self.code, self.signal, self.err + end + + ---@param task Task + ---@param suspension Suspension + ---@param leaf_wrap WrapFn + local function register(task, suspension, leaf_wrap) + return ops.register_wait(state, task, suspension, leaf_wrap) + end + + local function wrap(code, signal, err) + return code, signal, err + end + + return waitmod.waitable(register, step, wrap) end function ExecBackend:send_signal(sig) - local ops = self._ops - if ops.send_signal then - return ops.send_signal(self._state, sig) - end - return false, "backend does not support send_signal" + local ops = self._ops + if ops.send_signal then + return ops.send_signal(self._state, sig) + end + return false, 'backend does not support send_signal' end function ExecBackend:terminate() - local ops = self._ops - if ops.terminate then - return ops.terminate(self._state) - elseif ops.send_signal then - return ops.send_signal(self._state, nil) - end - return false, "backend does not support terminate" + local ops = self._ops + if ops.terminate then + return ops.terminate(self._state) + elseif ops.send_signal then + return ops.send_signal(self._state, nil) + end + return false, 'backend does not support terminate' end function ExecBackend:kill() - local ops = self._ops - if ops.kill then - return ops.kill(self._state) - elseif ops.send_signal then - return ops.send_signal(self._state, nil) - end - return false, "backend does not support kill" + local ops = self._ops + if ops.kill then + return ops.kill(self._state) + elseif ops.send_signal then + return ops.send_signal(self._state, nil) + end + return false, 'backend does not support kill' end function ExecBackend:close() - local ops = self._ops - if ops.close then - return ops.close(self._state) - end - return true, nil + local ops = self._ops + if ops.close then + return ops.close(self._state) + end + return true, nil end ---------------------------------------------------------------------- @@ -122,54 +122,54 @@ end ---@param ops table ---@return table backend_module -- { start = fn, ExecBackend = ExecBackend, is_supported = fn } local function build_backend(ops) - assert(type(ops) == "table", "exec_backend ops must be a table") - assert(type(ops.spawn) == "function", "ops.spawn must be a function") - assert(type(ops.poll) == "function", "ops.poll must be a function") - assert(type(ops.register_wait) == "function", "ops.register_wait must be a function") - - local function start(spec) - local state, streams, err = ops.spawn(spec) - if not state then - return nil, err - end - - local backend = setmetatable({ - _ops = ops, - _state = state, - - pid = state.pid, -- for introspection - exited = false, - status = nil, - code = nil, - signal = nil, - err = nil, - }, ExecBackend) - - local handle = { - backend = backend, - stdin = streams and streams.stdin or nil, - stdout = streams and streams.stdout or nil, - stderr = streams and streams.stderr or nil, - } - - return handle, nil - end - - local function is_supported() - if type(ops.is_supported) == "function" then - return not not ops.is_supported() - end - return true - end - - return { - ExecBackend = ExecBackend, - start = start, - is_supported = is_supported, - } + assert(type(ops) == 'table', 'exec_backend ops must be a table') + assert(type(ops.spawn) == 'function', 'ops.spawn must be a function') + assert(type(ops.poll) == 'function', 'ops.poll must be a function') + assert(type(ops.register_wait) == 'function', 'ops.register_wait must be a function') + + local function start(spec) + local state, streams, err = ops.spawn(spec) + if not state then + return nil, err + end + + local backend = setmetatable({ + _ops = ops, + _state = state, + + pid = state.pid, -- for introspection + exited = false, + status = nil, + code = nil, + signal = nil, + err = nil, + }, ExecBackend) + + local handle = { + backend = backend, + stdin = streams and streams.stdin or nil, + stdout = streams and streams.stdout or nil, + stderr = streams and streams.stderr or nil, + } + + return handle, nil + end + + local function is_supported() + if type(ops.is_supported) == 'function' then + return not not ops.is_supported() + end + return true + end + + return { + ExecBackend = ExecBackend, + start = start, + is_supported = is_supported, + } end return { - ExecBackend = ExecBackend, - build_backend = build_backend, + ExecBackend = ExecBackend, + build_backend = build_backend, } diff --git a/src/fibers/io/exec_backend/nixio.lua b/src/fibers/io/exec_backend/nixio.lua index d2753bd..3bdf2d5 100644 --- a/src/fibers/io/exec_backend/nixio.lua +++ b/src/fibers/io/exec_backend/nixio.lua @@ -29,32 +29,32 @@ local stdio = require 'fibers.io.exec_backend.stdio' local ok, nixio = pcall(require, 'nixio') if not ok or not nixio then - return { is_supported = function() return false end } + return { is_supported = function () return false end } end -local const = nixio.const or {} +local const = nixio.const or {} -local unpack = rawget(table, "unpack") or _G.unpack +local unpack = rawget(table, 'unpack') or _G.unpack -local DEV_NULL = "/dev/null" +local DEV_NULL = '/dev/null' ---------------------------------------------------------------------- -- Small helpers ---------------------------------------------------------------------- local function errno_msg(prefix) - local eno = nixio.errno() - local estr = (nixio.strerror and nixio.strerror(eno)) or ("errno " .. tostring(eno)) - if prefix then - return ("%s: %s"):format(prefix, estr) - end - return estr + local eno = nixio.errno() + local estr = (nixio.strerror and nixio.strerror(eno)) or ('errno ' .. tostring(eno)) + if prefix then + return ('%s: %s'):format(prefix, estr) + end + return estr end local function close_fd(f) - if f and f.close then - pcall(function() f:close() end) - end + if f and f.close then + pcall(function () f:close() end) + end end ---------------------------------------------------------------------- @@ -65,41 +65,41 @@ end ---@param is_output boolean ---@return any fd, string|nil err local function open_dev_null(is_output) - local mode = is_output and "w" or "r" - local f, err = nixio.open(DEV_NULL, mode) - if not f then - return nil, err or errno_msg("open " .. DEV_NULL) - end - return f, nil + local mode = is_output and 'w' or 'r' + local f, err = nixio.open(DEV_NULL, mode) + if not f then + return nil, err or errno_msg('open ' .. DEV_NULL) + end + return f, nil end --- Create a pipe (child <-> parent). ---@return any rd, any wr, string|nil err local function make_pipe() - local rd, wr = nixio.pipe() - if not rd or not wr then - return nil, nil, errno_msg("pipe") - end - return rd, wr, nil + local rd, wr = nixio.pipe() + if not rd or not wr then + return nil, nil, errno_msg('pipe') + end + return rd, wr, nil end --- We rely on explicit close() in child/reaper instead of close-on-exec. ---@param _ any ---@return boolean, string|nil local function set_cloexec(_) - return true, nil + return true, nil end --- Wrap a parent-side fd into a Stream. ----@param role '"stdin"'|'"stdout"'|'"stderr"' +---@param role "stdin"|"stdout"|"stderr" ---@param fd any -- nixio File ---@return Stream local function open_stream(role, fd) - if role == "stdin" then - return file_io.fdopen(fd, "w") - else - return file_io.fdopen(fd, "r") - end + if role == 'stdin' then + return file_io.fdopen(fd, 'w') + else + return file_io.fdopen(fd, 'r') + end end ---------------------------------------------------------------------- @@ -110,44 +110,44 @@ end ---@param src any -- nixio File ---@param dest_fd integer local function setup_child_fd(src, dest_fd) - if not src then - return - end - - local curfd = src:fileno() - if curfd == dest_fd then - return - end - - local dest - if dest_fd == 0 then - dest = nixio.stdin - elseif dest_fd == 1 then - dest = nixio.stdout - elseif dest_fd == 2 then - dest = nixio.stderr - else - -- exec_backend.stdio only uses 0/1/2. - os.exit(127) - end - - local dup, _ = nixio.dup(src, dest) - if not dup then - os.exit(127) - end + if not src then + return + end + + local curfd = src:fileno() + if curfd == dest_fd then + return + end + + local dest + if dest_fd == 0 then + dest = nixio.stdin + elseif dest_fd == 1 then + dest = nixio.stdout + elseif dest_fd == 2 then + dest = nixio.stderr + else + -- exec_backend.stdio only uses 0/1/2. + os.exit(127) + end + + local dup, _ = nixio.dup(src, dest) + if not dup then + os.exit(127) + end end local function apply_child_env(env) - for name, value in pairs(env) do - if value == nil then - nixio.setenv(name) -- unset - else - local ok1, _ = nixio.setenv(name, tostring(value)) - if not ok1 then - os.exit(127) - end - end - end + for name, value in pairs(env) do + if value == nil then + nixio.setenv(name) -- unset + else + local ok1, _ = nixio.setenv(name, tostring(value)) + if not ok1 then + os.exit(127) + end + end + end end ---@param child_spec table -- child-facing spec with *fd fields @@ -155,51 +155,51 @@ end ---@param parent_fds table|nil ---@param sentinel_w any|nil -- nixio File, closed in child local function child_exec(child_spec, child_only, parent_fds, sentinel_w) - if sentinel_w then - close_fd(sentinel_w) - end - - if child_spec.cwd then - local ok1, _ = nixio.chdir(child_spec.cwd) - if not ok1 then - os.exit(127) - end - end - - if child_spec.flags and child_spec.flags.setsid then - local sid, _ = nixio.setsid() - if not sid then - os.exit(127) - end - end - - if child_spec.env then - apply_child_env(child_spec.env) - end - - setup_child_fd(child_spec.stdin_fd, 0) - setup_child_fd(child_spec.stdout_fd, 1) - setup_child_fd(child_spec.stderr_fd, 2) - - stdio.close_child_only(child_only, close_fd) - stdio.close_parent_fds(parent_fds, close_fd) - - local argv = child_spec.argv - local prog = assert(argv[1], "child_exec: argv[1] must be non-nil") - - -- execp(executable, ...) sets argv[0] automatically. - local n = #argv - if n == 1 then - nixio.execp(prog) - else - local args = {} - for i = 2, n do - args[#args + 1] = argv[i] - end - nixio.execp(prog, unpack(args)) - end - - os.exit(127) + if sentinel_w then + close_fd(sentinel_w) + end + + if child_spec.cwd then + local ok1, _ = nixio.chdir(child_spec.cwd) + if not ok1 then + os.exit(127) + end + end + + if child_spec.flags and child_spec.flags.setsid then + local sid, _ = nixio.setsid() + if not sid then + os.exit(127) + end + end + + if child_spec.env then + apply_child_env(child_spec.env) + end + + setup_child_fd(child_spec.stdin_fd, 0) + setup_child_fd(child_spec.stdout_fd, 1) + setup_child_fd(child_spec.stderr_fd, 2) + + stdio.close_child_only(child_only, close_fd) + stdio.close_parent_fds(parent_fds, close_fd) + + local argv = child_spec.argv + local prog = assert(argv[1], 'child_exec: argv[1] must be non-nil') + + -- execp(executable, ...) sets argv[0] automatically. + local n = #argv + if n == 1 then + nixio.execp(prog) + else + local args = {} + for i = 2, n do + args[#args + 1] = argv[i] + end + nixio.execp(prog, unpack(args)) + end + + os.exit(127) end ---------------------------------------------------------------------- @@ -220,62 +220,62 @@ end ---@field _reaper_reaped boolean|nil local function parse_status_line_into_state(line, state) - line = line:gsub("\r", "") - local tag, rest = line:match("^(%S+)%s*(.*)$") - if not tag then - state.err = state.err or "invalid status line from reaper" - state.exited = true - state._have_status = true - return - end - - if tag == "pid" then - local cpid = tonumber(rest) - if cpid then - state.child_pid = cpid - state.pid = state.pid or cpid - end - return - elseif tag == "exited" then - local code = tonumber(rest) or 0 - state.code = code - state.signal = nil - state.err = state.err or nil - state.exited = true - state._have_status = true - return - elseif tag == "signaled" or tag == "signalled" then - local sig = tonumber(rest) or 0 - state.code = nil - state.signal = sig - state.err = state.err or nil - state.exited = true - state._have_status = true - return - elseif tag == "failed" then - local msg = rest ~= "" and rest or "exec backend failed" - state.code = nil - state.signal = nil - state.err = msg - state.exited = true - state._have_status = true - return - else - state.err = state.err or ("unknown status tag '" .. tostring(tag) .. "'") - state.exited = true - state._have_status = true - return - end + line = line:gsub('\r', '') + local tag, rest = line:match('^(%S+)%s*(.*)$') + if not tag then + state.err = state.err or 'invalid status line from reaper' + state.exited = true + state._have_status = true + return + end + + if tag == 'pid' then + local cpid = tonumber(rest) + if cpid then + state.child_pid = cpid + state.pid = state.pid or cpid + end + return + elseif tag == 'exited' then + local code = tonumber(rest) or 0 + state.code = code + state.signal = nil + state.err = state.err or nil + state.exited = true + state._have_status = true + return + elseif tag == 'signaled' or tag == 'signalled' then + local sig = tonumber(rest) or 0 + state.code = nil + state.signal = sig + state.err = state.err or nil + state.exited = true + state._have_status = true + return + elseif tag == 'failed' then + local msg = rest ~= '' and rest or 'exec backend failed' + state.code = nil + state.signal = nil + state.err = msg + state.exited = true + state._have_status = true + return + else + state.err = state.err or ("unknown status tag '" .. tostring(tag) .. "'") + state.exited = true + state._have_status = true + return + end end local function reap_reaper(state) - if state._reaper_reaped or not state.reaper_pid then - return - end - local pid, _, _ = nixio.waitpid(state.reaper_pid, "nohang") - if pid and pid ~= 0 then - state._reaper_reaped = true - end + if state._reaper_reaped or not state.reaper_pid then + return + end + local pid, _, _ = nixio.waitpid(state.reaper_pid, 'nohang') + if pid and pid ~= 0 then + state._reaper_reaped = true + end end ---------------------------------------------------------------------- @@ -289,72 +289,72 @@ end ---@param sentinel_r any -- nixio File (parent-side read end, close here) ---@param sentinel_w any -- nixio File (reaper-side writer) local function reaper_main(child_spec, child_only, parent_fds, sentinel_r, sentinel_w) - -- Reaper does not need parent pipe ends or parent's sentinel read end. - stdio.close_parent_fds(parent_fds, close_fd) - close_fd(sentinel_r) - - -- Fork the real child. - local child_pid, err = nixio.fork() - if not child_pid then - if sentinel_w then - sentinel_w:write("failed " .. (err or errno_msg("fork")) .. "\n") - close_fd(sentinel_w) - end - os.exit(127) - end - - if child_pid == 0 then - -- In the real child. - child_exec(child_spec, child_only, parent_fds, sentinel_w) - os.exit(127) - end - - -- In the reaper. - stdio.close_child_only(child_only, close_fd) - - -- Tell parent the real child pid. - if sentinel_w then - pcall(function() - sentinel_w:write(("pid %d\n"):format(child_pid)) - end) - end - - -- Wait for the real child to exit. - local pid, how, what - while true do - pid, how, what = nixio.waitpid(child_pid) - if pid ~= nil then - break - end - local eno = nixio.errno() - if eno ~= const.EINTR then - break - end - end - - local line - if not pid then - line = "failed " .. errno_msg("waitpid") .. "\n" - else - if how == "exited" then - local code = tonumber(what) or 0 - line = ("exited %d\n"):format(code) - elseif how == "signaled" or how == "signalled" then - local sig = tonumber(what) or 0 - line = ("signaled %d\n"):format(sig) - else - line = ("failed unexpected %s %s\n"):format(tostring(how), tostring(what)) - end - end - - if sentinel_w then - pcall(function() - sentinel_w:write(line) - sentinel_w:close() - end) - end - - os.exit(0) + -- Reaper does not need parent pipe ends or parent's sentinel read end. + stdio.close_parent_fds(parent_fds, close_fd) + close_fd(sentinel_r) + + -- Fork the real child. + local child_pid, err = nixio.fork() + if not child_pid then + if sentinel_w then + sentinel_w:write('failed ' .. (err or errno_msg('fork')) .. '\n') + close_fd(sentinel_w) + end + os.exit(127) + end + + if child_pid == 0 then + -- In the real child. + child_exec(child_spec, child_only, parent_fds, sentinel_w) + os.exit(127) + end + + -- In the reaper. + stdio.close_child_only(child_only, close_fd) + + -- Tell parent the real child pid. + if sentinel_w then + pcall(function () + sentinel_w:write(('pid %d\n'):format(child_pid)) + end) + end + + -- Wait for the real child to exit. + local pid, how, what + while true do + pid, how, what = nixio.waitpid(child_pid) + if pid ~= nil then + break + end + local eno = nixio.errno() + if eno ~= const.EINTR then + break + end + end + + local line + if not pid then + line = 'failed ' .. errno_msg('waitpid') .. '\n' + else + if how == 'exited' then + local code = tonumber(what) or 0 + line = ('exited %d\n'):format(code) + elseif how == 'signaled' or how == 'signalled' then + local sig = tonumber(what) or 0 + line = ('signaled %d\n'):format(sig) + else + line = ('failed unexpected %s %s\n'):format(tostring(how), tostring(what)) + end + end + + if sentinel_w then + pcall(function () + sentinel_w:write(line) + sentinel_w:close() + end) + end + + os.exit(0) end ---------------------------------------------------------------------- @@ -365,79 +365,79 @@ end ---@param state NixioExecState ---@return boolean done, integer|nil code, integer|nil signal, string|nil err local function poll_state(state) - if state.exited then - return true, state.code, state.signal, state.err - end - - if not state.sentinel then - -- Sentinel has gone away without a status line. - if not state._have_status then - state.exited = true - state.err = state.err or "reaper sentinel closed" - end - reap_reaper(state) - return true, state.code, state.signal, state.err - end - - local bufsize = const.buffersize or 256 - - while true do - local chunk, _ = state.sentinel:read(bufsize) - - if not chunk then - local eno = nixio.errno() - if eno == const.EAGAIN or eno == const.EWOULDBLOCK or eno == const.EINTR then - -- Nothing available right now. - break - end - - -- Hard error; treat as completion if we do not yet have a status. - close_fd(state.sentinel) - state.sentinel = nil - if not state._have_status then - state.exited = true - state.err = state.err or "reaper sentinel closed" - end - reap_reaper(state) - break - end - - if #chunk == 0 then - -- EOF: writer closed pipe. - close_fd(state.sentinel) - state.sentinel = nil - if not state._have_status then - state.exited = true - state.err = state.err or "reaper sentinel closed" - end - reap_reaper(state) - break - end - - state._buf = (state._buf or "") .. chunk - - while true do - local line, rest = state._buf:match("^(.-)\n(.*)$") - if not line then - break - end - state._buf = rest - parse_status_line_into_state(line, state) - end - - if state.exited then - close_fd(state.sentinel) - state.sentinel = nil - reap_reaper(state) - break - end - end - - if state.exited then - return true, state.code, state.signal, state.err - else - return false, nil, nil, nil - end + if state.exited then + return true, state.code, state.signal, state.err + end + + if not state.sentinel then + -- Sentinel has gone away without a status line. + if not state._have_status then + state.exited = true + state.err = state.err or 'reaper sentinel closed' + end + reap_reaper(state) + return true, state.code, state.signal, state.err + end + + local bufsize = const.buffersize or 256 + + while true do + local chunk, _ = state.sentinel:read(bufsize) + + if not chunk then + local eno = nixio.errno() + if eno == const.EAGAIN or eno == const.EWOULDBLOCK or eno == const.EINTR then + -- Nothing available right now. + break + end + + -- Hard error; treat as completion if we do not yet have a status. + close_fd(state.sentinel) + state.sentinel = nil + if not state._have_status then + state.exited = true + state.err = state.err or 'reaper sentinel closed' + end + reap_reaper(state) + break + end + + if #chunk == 0 then + -- EOF: writer closed pipe. + close_fd(state.sentinel) + state.sentinel = nil + if not state._have_status then + state.exited = true + state.err = state.err or 'reaper sentinel closed' + end + reap_reaper(state) + break + end + + state._buf = (state._buf or '') .. chunk + + while true do + local line, rest = state._buf:match('^(.-)\n(.*)$') + if not line then + break + end + state._buf = rest + parse_status_line_into_state(line, state) + end + + if state.exited then + close_fd(state.sentinel) + state.sentinel = nil + reap_reaper(state) + break + end + end + + if state.exited then + return true, state.code, state.signal, state.err + else + return false, nil, nil, nil + end end ---------------------------------------------------------------------- @@ -448,116 +448,116 @@ end ---@param spec ExecProcSpec ---@return NixioExecState|nil state,{stdin:Stream|nil,stdout:Stream|nil,stderr:Stream|nil}|nil streams,string|nil err local function spawn(spec) - assert(type(spec) == "table", "ExecBackend.spawn: spec must be a table") - assert(type(spec.argv) == "table" and spec.argv[1], - "ExecBackend.spawn: spec.argv must be a non-empty array") - - local child_spec, child_only, parent_fds, cfg_err = - stdio.build_child_stdio(spec, open_dev_null, make_pipe, set_cloexec, close_fd) - if not child_spec then - return nil, nil, cfg_err - end - - -- Sentinel pipe: reaper writes, parent reads. - local sentinel_r, sentinel_w = nixio.pipe() - if not sentinel_r or not sentinel_w then - stdio.close_child_only(child_only, close_fd) - stdio.close_parent_fds(parent_fds, close_fd) - return nil, nil, errno_msg("pipe (sentinel)") - end - - -- We will use the sentinel in blocking mode temporarily for a - -- handshake to learn the real child pid, then switch to non-blocking. - sentinel_r:setblocking(true) - - -- Fork the per-command reaper. - local reaper_pid, ferr = nixio.fork() - if not reaper_pid then - stdio.close_child_only(child_only, close_fd) - stdio.close_parent_fds(parent_fds, close_fd) - close_fd(sentinel_r) - close_fd(sentinel_w) - return nil, nil, ferr or errno_msg("fork (reaper)") - end - - if reaper_pid == 0 then - reaper_main(child_spec, child_only, parent_fds, sentinel_r, sentinel_w) - os.exit(127) - end - - -- Parent. - stdio.close_child_only(child_only, close_fd) - close_fd(sentinel_w) - - local state = { - reaper_pid = reaper_pid, - pid = reaper_pid, -- will be updated once child pid is known - child_pid = nil, - sentinel = sentinel_r, - exited = false, - code = nil, - signal = nil, - err = nil, - _buf = nil, - _have_status = false, - _reaper_reaped = false, - } - - -- Handshake: read sentinel until we have seen a pid line and/or a - -- terminal status. This guarantees child_pid is known before any - -- external code can attempt to send signals. - local bufsize = const.buffersize or 256 - - while not state.child_pid and not state._have_status do - local chunk, rerr = sentinel_r:read(bufsize) - if not chunk then - close_fd(sentinel_r) - state.sentinel = nil - return nil, nil, rerr or errno_msg("sentinel handshake read") - end - if #chunk == 0 then - close_fd(sentinel_r) - state.sentinel = nil - return nil, nil, "sentinel closed during handshake" - end - - state._buf = (state._buf or "") .. chunk - - while true do - local line, rest = state._buf:match("^(.-)\n(.*)$") - if not line then - break - end - state._buf = rest - parse_status_line_into_state(line, state) - end - end - - -- Switch sentinel to non-blocking for normal event-loop use. - sentinel_r:setblocking(false) - - local streams = stdio.build_parent_streams(parent_fds, open_stream) - - return state, streams, nil + assert(type(spec) == 'table', 'ExecBackend.spawn: spec must be a table') + assert(type(spec.argv) == 'table' and spec.argv[1], + 'ExecBackend.spawn: spec.argv must be a non-empty array') + + local child_spec, child_only, parent_fds, cfg_err = + stdio.build_child_stdio(spec, open_dev_null, make_pipe, set_cloexec, close_fd) + if not child_spec then + return nil, nil, cfg_err + end + + -- Sentinel pipe: reaper writes, parent reads. + local sentinel_r, sentinel_w = nixio.pipe() + if not sentinel_r or not sentinel_w then + stdio.close_child_only(child_only, close_fd) + stdio.close_parent_fds(parent_fds, close_fd) + return nil, nil, errno_msg('pipe (sentinel)') + end + + -- We will use the sentinel in blocking mode temporarily for a + -- handshake to learn the real child pid, then switch to non-blocking. + sentinel_r:setblocking(true) + + -- Fork the per-command reaper. + local reaper_pid, ferr = nixio.fork() + if not reaper_pid then + stdio.close_child_only(child_only, close_fd) + stdio.close_parent_fds(parent_fds, close_fd) + close_fd(sentinel_r) + close_fd(sentinel_w) + return nil, nil, ferr or errno_msg('fork (reaper)') + end + + if reaper_pid == 0 then + reaper_main(child_spec, child_only, parent_fds, sentinel_r, sentinel_w) + os.exit(127) + end + + -- Parent. + stdio.close_child_only(child_only, close_fd) + close_fd(sentinel_w) + + local state = { + reaper_pid = reaper_pid, + pid = reaper_pid, -- will be updated once child pid is known + child_pid = nil, + sentinel = sentinel_r, + exited = false, + code = nil, + signal = nil, + err = nil, + _buf = '', + _have_status = false, + _reaper_reaped = false, + } + + -- Handshake: read sentinel until we have seen a pid line and/or a + -- terminal status. This guarantees child_pid is known before any + -- external code can attempt to send signals. + local bufsize = const.buffersize or 256 + + while not state.child_pid and not state._have_status do + local chunk, rerr = sentinel_r:read(bufsize) + if not chunk then + close_fd(sentinel_r) + state.sentinel = nil + return nil, nil, rerr or errno_msg('sentinel handshake read') + end + if #chunk == 0 then + close_fd(sentinel_r) + state.sentinel = nil + return nil, nil, 'sentinel closed during handshake' + end + + state._buf = (state._buf or '') .. chunk + + while true do + local line, rest = state._buf:match('^(.-)\n(.*)$') + if not line then + break + end + state._buf = rest + parse_status_line_into_state(line, state) + end + end + + -- Switch sentinel to non-blocking for normal event-loop use. + sentinel_r:setblocking(false) + + local streams = stdio.build_parent_streams(parent_fds, open_stream) + + return state, streams, nil end --- poll(state) -> done, code, signal, err local function poll_backend(state) - return poll_state(state) + return poll_state(state) end --- register_wait(state, task, suspension, leaf_wrap) -> WaitToken local function register_wait(state, task, _, _) - if not state.sentinel then - -- No fd to wait on; reschedule once so that step() can see terminal state. - local sched = runtime.current_scheduler - if sched and sched.schedule then - sched:schedule(task) - end - return { unlink = function() return false end } - end - - return poller.get():wait(state.sentinel, "rd", task) + if not state.sentinel then + -- No fd to wait on; reschedule once so that step() can see terminal state. + local sched = runtime.current_scheduler + if sched and sched.schedule then + sched:schedule(task) + end + return { unlink = function () return false end } + end + + return poller.get():wait(state.sentinel, 'rd', task) end --- Send a signal to the real child. @@ -565,72 +565,72 @@ end ---@param sig integer|nil ---@return boolean ok, string|nil err local function send_signal(state, sig) - sig = sig or const.SIGTERM or 15 - - -- If already finished, nothing to do. - if state.exited then - return true, nil - end - - -- Process any queued sentinel data (should not normally change - -- child_pid, as the handshake has already seen the pid line). - poll_state(state) - - if state.exited then - return true, nil - end - - local target = state.child_pid - if not target then - -- As a last resort, fall back to the reaper pid; this should be - -- unreachable in normal operation because the handshake ensures - -- child_pid is known. - target = state.reaper_pid or state.pid - end - if not target then - return false, "no child or reaper pid available" - end - - local ok1, err = nixio.kill(target, sig) - if not ok1 then - return false, err or errno_msg("kill") - end - return true, nil + sig = sig or const.SIGTERM or 15 + + -- If already finished, nothing to do. + if state.exited then + return true, nil + end + + -- Process any queued sentinel data (should not normally change + -- child_pid, as the handshake has already seen the pid line). + poll_state(state) + + if state.exited then + return true, nil + end + + local target = state.child_pid + if not target then + -- As a last resort, fall back to the reaper pid; this should be + -- unreachable in normal operation because the handshake ensures + -- child_pid is known. + target = state.reaper_pid or state.pid + end + if not target then + return false, 'no child or reaper pid available' + end + + local ok1, err = nixio.kill(target, sig) + if not ok1 then + return false, err or errno_msg('kill') + end + return true, nil end local function terminate(state) - return send_signal(state, const.SIGTERM or 15) + return send_signal(state, const.SIGTERM or 15) end local function kill_proc(state) - return send_signal(state, const.SIGKILL or 9) + return send_signal(state, const.SIGKILL or 9) end local function close_state(state) - close_fd(state.sentinel) - state.sentinel = nil - reap_reaper(state) - return true, nil + close_fd(state.sentinel) + state.sentinel = nil + reap_reaper(state) + return true, nil end local function is_supported() - return type(nixio) == "table" - and type(nixio.fork) == "function" - and type(nixio.waitpid) == "function" - and type(nixio.execp) == "function" - and type(nixio.pipe) == "function" - and type(nixio.open) == "function" + return type(nixio) == 'table' + and type(nixio.fork) == 'function' + and type(nixio.waitpid) == 'function' + and type(nixio.execp) == 'function' + and type(nixio.pipe) == 'function' + and type(nixio.open) == 'function' end local ops = { - spawn = spawn, - poll = poll_backend, - register_wait = register_wait, - send_signal = send_signal, - terminate = terminate, - kill = kill_proc, - close = close_state, - is_supported = is_supported, + spawn = spawn, + poll = poll_backend, + register_wait = register_wait, + send_signal = send_signal, + terminate = terminate, + kill = kill_proc, + close = close_state, + is_supported = is_supported, } return core.build_backend(ops) diff --git a/src/fibers/io/exec_backend/pidfd.lua b/src/fibers/io/exec_backend/pidfd.lua index 421a021..d9adea8 100644 --- a/src/fibers/io/exec_backend/pidfd.lua +++ b/src/fibers/io/exec_backend/pidfd.lua @@ -12,29 +12,29 @@ local ffi_c = require 'fibers.utils.ffi_compat' local file_io = require 'fibers.io.file' local stdio = require 'fibers.io.exec_backend.stdio' -local ffi = ffi_c.ffi -local C = ffi_c.C -local toint = ffi_c.tonumber -local get_errno = ffi_c.errno -local DEV_NULL = "/dev/null" +local ffi = ffi_c.ffi +local C = ffi_c.C +local toint = ffi_c.tonumber +local get_errno = ffi_c.errno +local DEV_NULL = '/dev/null' -local bit = rawget(_G, "bit") or require 'bit32' +local bit = rawget(_G, 'bit') or require 'bit32' ---------------------------------------------------------------------- -- FFI / CFFI availability ---------------------------------------------------------------------- if not (ffi_c.is_supported and ffi_c.is_supported()) then - return { is_supported = function() return false end } + return { is_supported = function () return false end } end ---------------------------------------------------------------------- -- FFI declarations and constants ---------------------------------------------------------------------- -local ARCH = ffi.arch or ((jit and jit.arch) or "x64") +local ARCH = ffi.arch or ((jit and jit.arch) or 'x64') -ffi.cdef[[ +ffi.cdef [[ typedef int pid_t; typedef unsigned int uint; @@ -64,10 +64,10 @@ ffi.cdef[[ ]] -- Raw syscall number for pidfd_open. -local SYS_pidfd_open = 434 -- Linux generic -if ARCH == "mips" or ARCH == "mipsel" then - -- See https://www.linux-mips.org/wiki/Syscall - SYS_pidfd_open = 4000 + 434 +local SYS_pidfd_open = 434 -- Linux generic +if ARCH == 'mips' or ARCH == 'mipsel' then + -- See https://www.linux-mips.org/wiki/Syscall + SYS_pidfd_open = 4000 + 434 end -- fcntl constants (Linux) @@ -85,10 +85,10 @@ local O_WRONLY = 1 -- wait/errno constants (Linux) local WNOHANG = 1 -local EINTR = 4 -local ESRCH = 3 -local ECHILD = 10 -local ENOSYS = 38 +local EINTR = 4 +local ESRCH = 3 +local ECHILD = 10 +local ENOSYS = 38 -- Signals (Linux values). local SIGTERM = 15 @@ -99,28 +99,28 @@ local SIGKILL = 9 ---------------------------------------------------------------------- local function strerror(e) - local s = C.strerror(e) - if s == nil then - return "errno " .. tostring(e) - end - return ffi.string(s) + local s = C.strerror(e) + if s == nil then + return 'errno ' .. tostring(e) + end + return ffi.string(s) end local function errno_msg(prefix, err, eno) - if err and err ~= "" then - return err - end - if eno then - return ("%s (errno %d)"):format(prefix, eno) - end - return prefix + if err and err ~= '' then + return err + end + if eno then + return ('%s (errno %d)'):format(prefix, eno) + end + return prefix end -- In the child we must not return to Lua on fatal errors. local function must_child(ok) - if not ok then - C._exit(127) - end + if not ok then + C._exit(127) + end end ---------------------------------------------------------------------- @@ -128,73 +128,73 @@ end ---------------------------------------------------------------------- local function pidfd_open_raw(pid, flags) - pid = ffi.new("pid_t", pid) - flags = ffi.new("uint", flags or 0) - - local fd = toint(C.syscall(SYS_pidfd_open, pid, flags)) - if fd == -1 then - local e = get_errno() - return nil, strerror(e), e - end - return fd, nil, nil + pid = ffi.new('pid_t', pid) + flags = ffi.new('uint', flags or 0) + + local fd = toint(C.syscall(SYS_pidfd_open, pid, flags)) + if fd == -1 then + local e = get_errno() + return nil, strerror(e), e + end + return fd, nil, nil end ---------------------------------------------------------------------- -- fcntl helpers: set_nonblock / set_cloexec ---------------------------------------------------------------------- -local getfl_fp = ffi.cast("int (*)(int, int)", C.fcntl) -local setfl_fp = ffi.cast("int (*)(int, int, int)", C.fcntl) +local getfl_fp = ffi.cast('int (*)(int, int)', C.fcntl) +local setfl_fp = ffi.cast('int (*)(int, int, int)', C.fcntl) local function set_nonblock(fd) - local before = assert(toint(getfl_fp(fd, F_GETFL))) - if before < 0 then - local e = get_errno() - return false, ("F_GETFL failed: %s"):format(strerror(e)), e - end - - local new_flags = bit.bor(before, O_NONBLOCK) - local rc = toint(setfl_fp(fd, F_SETFL, new_flags)) - if rc < 0 then - local e = get_errno() - return false, ("F_SETFL failed: %s"):format(strerror(e)), e - end - - -- Optional sanity check. - local after = assert(toint(getfl_fp(fd, F_GETFL))) - if after < 0 then - local e = get_errno() - return false, ("F_GETFL (post) failed: %s"):format(strerror(e)), e - end - - if bit.band(after, O_NONBLOCK) == 0 then - return false, - ("set_nonblock: O_NONBLOCK not set after F_SETFL; before=0x%x after=0x%x") - :format(before, after), - nil - end - - return true, nil, nil + local before = assert(toint(getfl_fp(fd, F_GETFL))) + if before < 0 then + local e = get_errno() + return false, ('F_GETFL failed: %s'):format(strerror(e)), e + end + + local new_flags = bit.bor(before, O_NONBLOCK) + local rc = toint(setfl_fp(fd, F_SETFL, new_flags)) + if rc < 0 then + local e = get_errno() + return false, ('F_SETFL failed: %s'):format(strerror(e)), e + end + + -- Optional sanity check. + local after = assert(toint(getfl_fp(fd, F_GETFL))) + if after < 0 then + local e = get_errno() + return false, ('F_GETFL (post) failed: %s'):format(strerror(e)), e + end + + if bit.band(after, O_NONBLOCK) == 0 then + return false, + ('set_nonblock: O_NONBLOCK not set after F_SETFL; before=0x%x after=0x%x') + :format(before, after), + nil + end + + return true, nil, nil end -local getfd_fp = ffi.cast("int (*)(int, int)", C.fcntl) -local setfd_fp = ffi.cast("int (*)(int, int, int)", C.fcntl) +local getfd_fp = ffi.cast('int (*)(int, int)', C.fcntl) +local setfd_fp = ffi.cast('int (*)(int, int, int)', C.fcntl) local function set_cloexec(fd) - local before = assert(toint(getfd_fp(fd, F_GETFD))) - if before < 0 then - local e = get_errno() - return false, ("F_GETFD failed: %s"):format(strerror(e)), e - end - - local new_flags = bit.bor(before, FD_CLOEXEC) - local rc = toint(setfd_fp(fd, F_SETFD, new_flags)) - if rc < 0 then - local e = get_errno() - return false, ("F_SETFD failed: %s"):format(strerror(e)), e - end - - return true, nil, nil + local before = assert(toint(getfd_fp(fd, F_GETFD))) + if before < 0 then + local e = get_errno() + return false, ('F_GETFD failed: %s'):format(strerror(e)), e + end + + local new_flags = bit.bor(before, FD_CLOEXEC) + local rc = toint(setfd_fp(fd, F_SETFD, new_flags)) + if rc < 0 then + local e = get_errno() + return false, ('F_SETFD failed: %s'):format(strerror(e)), e + end + + return true, nil, nil end ---------------------------------------------------------------------- @@ -202,20 +202,20 @@ end ---------------------------------------------------------------------- local function WIFEXITED(status) - return bit.band(status, 0x7f) == 0 + return bit.band(status, 0x7f) == 0 end local function WEXITSTATUS(status) - return bit.rshift(status, 8) + return bit.rshift(status, 8) end local function WIFSIGNALED(status) - local term = bit.band(status, 0x7f) - return term ~= 0 and term ~= 0x7f + local term = bit.band(status, 0x7f) + return term ~= 0 and term ~= 0x7f end local function WTERMSIG(status) - return bit.band(status, 0x7f) + return bit.band(status, 0x7f) end ---------------------------------------------------------------------- @@ -223,77 +223,77 @@ end ---------------------------------------------------------------------- local function build_argv_c(argv) - local n = #argv - local cargv = ffi.new("char *[?]", n + 1) - - for i = 1, n do - local s = assert(argv[i], "argv must not contain nil") - local cs = ffi.new("char[?]", #s + 1) - ffi.copy(cs, s) - cargv[i - 1] = cs - end - cargv[n] = nil - - return cargv + local n = #argv + local cargv = ffi.new('char *[?]', n + 1) + + for i = 1, n do + local s = assert(argv[i], 'argv must not contain nil') + local cs = ffi.new('char[?]', #s + 1) + ffi.copy(cs, s) + cargv[i - 1] = cs + end + cargv[n] = nil + + return cargv end local function setup_child_fd(src_fd, dest_fd) - if not src_fd or src_fd == dest_fd then - return - end - local rc = toint(C.dup2(src_fd, dest_fd)) - if rc < 0 then - must_child(false) - end + if not src_fd or src_fd == dest_fd then + return + end + local rc = toint(C.dup2(src_fd, dest_fd)) + if rc < 0 then + must_child(false) + end end local function apply_child_env(env) - for name, value in pairs(env) do - local v = value and tostring(value) or nil - local rc = C.setenv(name, v, 1) -- value == nil clears the variable - if rc ~= 0 then - must_child(false) - end - end + for name, value in pairs(env) do + local v = value and tostring(value) or nil + local rc = C.setenv(name, v, 1) -- value == nil clears the variable + if rc ~= 0 then + must_child(false) + end + end end ---@param spec table -- child-facing spec with *fd fields local function child_exec(spec) - if spec.cwd then - local rc = C.chdir(spec.cwd) - must_child(rc == 0) - end - - if spec.flags and spec.flags.setsid then - local rc = toint(C.setsid()) - must_child(rc ~= -1) - end - - if spec.env then - apply_child_env(spec.env) - end - - setup_child_fd(spec.stdin_fd, 0) - setup_child_fd(spec.stdout_fd, 1) - setup_child_fd(spec.stderr_fd, 2) - - do - local seen = {} - for _, fd in ipairs { spec.stdin_fd, spec.stdout_fd, spec.stderr_fd } do - if fd and fd > 2 and not seen[fd] then - seen[fd] = true - C.close(fd) - end - end - end - - local argv = spec.argv - local cargv = build_argv_c(argv) - - C.execvp(argv[1], cargv) - - -- If we reach here, execvp failed. - C._exit(127) + if spec.cwd then + local rc = C.chdir(spec.cwd) + must_child(rc == 0) + end + + if spec.flags and spec.flags.setsid then + local rc = toint(C.setsid()) + must_child(rc ~= -1) + end + + if spec.env then + apply_child_env(spec.env) + end + + setup_child_fd(spec.stdin_fd, 0) + setup_child_fd(spec.stdout_fd, 1) + setup_child_fd(spec.stderr_fd, 2) + + do + local seen = {} + for _, fd in ipairs { spec.stdin_fd, spec.stdout_fd, spec.stderr_fd } do + if fd and fd > 2 and not seen[fd] then + seen[fd] = true + C.close(fd) + end + end + end + + local argv = spec.argv + local cargv = build_argv_c(argv) + + C.execvp(argv[1], cargv) + + -- If we reach here, execvp failed. + C._exit(127) end ---------------------------------------------------------------------- @@ -310,74 +310,74 @@ end ---@field err string|nil local function finalise_state(st, status, code, signal, err) - if st.exited then - return - end - st.exited = true - st.status = status - st.code = code - st.signal = signal - st.err = err + if st.exited then + return + end + st.exited = true + st.status = status + st.code = code + st.signal = signal + st.err = err end --- Blocking wait used only in the error path after a failed pidfd_open. local function wait_blocking(pid) - local status_buf = ffi.new("int[1]") - while true do - local rpid = toint(C.waitpid(pid, status_buf, 0)) - if rpid == -1 then - local e = get_errno() - if e ~= EINTR then - return - end - else - -- Child reaped. - return - end - end + local status_buf = ffi.new('int[1]') + while true do + local rpid = toint(C.waitpid(pid, status_buf, 0)) + if rpid == -1 then + local e = get_errno() + if e ~= EINTR then + return + end + else + -- Child reaped. + return + end + end end --- Non-blocking wait on a single child. ---@param st PidfdState ---@return boolean done, integer|nil code, integer|nil signal, string|nil err local function poll_state(st) - if st.exited then - return true, st.code, st.signal, st.err - end - - local status_buf = ffi.new("int[1]") - local rpid = toint(C.waitpid(st.pid, status_buf, WNOHANG)) - - if rpid == 0 then - -- Still running. - return false, nil, nil, nil - end - - if rpid == -1 then - local e = get_errno() - if e == ECHILD or e == ESRCH then - -- Child already gone or reaped elsewhere. - finalise_state(st, nil, nil, nil, nil) - return true, st.code, st.signal, st.err - end - finalise_state(st, nil, nil, nil, errno_msg("waitpid failed", nil, e)) - return true, st.code, st.signal, st.err - end - - local status = status_buf[0] - - if WIFEXITED(status) then - local code = WEXITSTATUS(status) - finalise_state(st, status, code, nil, nil) - elseif WIFSIGNALED(status) then - local sig = WTERMSIG(status) - finalise_state(st, status, nil, sig, nil) - else - -- Stopped/continued or other odd state; treat as “completed, detail unknown”. - finalise_state(st, status, nil, nil, nil) - end - - return true, st.code, st.signal, st.err + if st.exited then + return true, st.code, st.signal, st.err + end + + local status_buf = ffi.new('int[1]') + local rpid = toint(C.waitpid(st.pid, status_buf, WNOHANG)) + + if rpid == 0 then + -- Still running. + return false, nil, nil, nil + end + + if rpid == -1 then + local e = get_errno() + if e == ECHILD or e == ESRCH then + -- Child already gone or reaped elsewhere. + finalise_state(st, nil, nil, nil, nil) + return true, st.code, st.signal, st.err + end + finalise_state(st, nil, nil, nil, errno_msg('waitpid failed', nil, e)) + return true, st.code, st.signal, st.err + end + + local status = status_buf[0] + + if WIFEXITED(status) then + local code = WEXITSTATUS(status) + finalise_state(st, status, code, nil, nil) + elseif WIFSIGNALED(status) then + local sig = WTERMSIG(status) + finalise_state(st, status, nil, sig, nil) + else + -- Stopped/continued or other odd state; treat as “completed, detail unknown”. + finalise_state(st, status, nil, nil, nil) + end + + return true, st.code, st.signal, st.err end ---------------------------------------------------------------------- @@ -385,36 +385,36 @@ end ---------------------------------------------------------------------- local function open_dev_null(is_output) - local flags = is_output and O_WRONLY or O_RDONLY - local fd = toint(C.open(DEV_NULL, flags, 0)) - if fd < 0 then - local e = get_errno() - return nil, errno_msg("failed to open " .. DEV_NULL, nil, e) - end - return fd, nil + local flags = is_output and O_WRONLY or O_RDONLY + local fd = toint(C.open(DEV_NULL, flags, 0)) + if fd < 0 then + local e = get_errno() + return nil, errno_msg('failed to open ' .. DEV_NULL, nil, e) + end + return fd, nil end local function make_pipe() - local pipefd = ffi.new("int[2]") - local rc = toint(C.pipe(pipefd)) - if rc ~= 0 then - local e = get_errno() - return nil, nil, errno_msg("pipe() failed", nil, e) - end - return toint(pipefd[0]), toint(pipefd[1]), nil + local pipefd = ffi.new('int[2]') + local rc = toint(C.pipe(pipefd)) + if rc ~= 0 then + local e = get_errno() + return nil, nil, errno_msg('pipe() failed', nil, e) + end + return toint(pipefd[0]), toint(pipefd[1]), nil end local function close_fd(fd) - C.close(fd) + C.close(fd) end local function open_stream(role, fd) - if role == "stdin" then - return file_io.fdopen(fd, O_WRONLY) - else - -- stdout / stderr - return file_io.fdopen(fd, O_RDONLY) - end + if role == 'stdin' then + return file_io.fdopen(fd, O_WRONLY) + else + -- stdout / stderr + return file_io.fdopen(fd, O_RDONLY) + end end ---------------------------------------------------------------------- @@ -425,113 +425,113 @@ end ---@param spec ExecProcSpec ---@return PidfdState|nil state, {stdin:Stream|nil, stdout:Stream|nil, stderr:Stream|nil}|nil streams, string|nil err local function spawn(spec) - assert(type(spec) == "table", "ExecBackend.spawn: spec must be a table") - assert(type(spec.argv) == "table" and spec.argv[1], - "ExecBackend.spawn: spec.argv must be a non-empty array") - - -- Common stdio wiring. - local child_spec, child_only, parent_fds, cfg_err = - stdio.build_child_stdio(spec, open_dev_null, make_pipe, set_cloexec, close_fd) - if not child_spec then - return nil, nil, cfg_err - end - - -- Fork. - local pid = toint(C.fork()) - if pid < 0 then - local e = get_errno() - stdio.close_child_only(child_only, close_fd) - stdio.close_parent_fds(parent_fds, close_fd) - return nil, nil, errno_msg("fork failed", nil, e) - end - - if pid == 0 then - child_exec(child_spec) -- never returns - end - - -- Parent: child-only fds no longer needed. - stdio.close_child_only(child_only, close_fd) - - -- Open pidfd. - local pidfd, perr, perrno = pidfd_open_raw(pid, 0) - if not pidfd then - C.kill(pid, SIGKILL) - wait_blocking(pid) - stdio.close_parent_fds(parent_fds, close_fd) - return nil, nil, errno_msg("pidfd_open failed", perr, perrno) - end - - local ok, e1 = set_nonblock(pidfd) - assert(ok, "set_nonblock(pidfd) failed: " .. tostring(e1)) - - ok, e1 = set_cloexec(pidfd) - assert(ok, "set_cloexec(pidfd) failed: " .. tostring(e1)) - - local state = { - pid = pid, - pidfd = pidfd, - exited = false, - status = nil, - code = nil, - signal = nil, - err = nil, - } - - -- Common mapping from parent_fds -> Streams. - local streams = stdio.build_parent_streams(parent_fds, open_stream) - - return state, streams, nil + assert(type(spec) == 'table', 'ExecBackend.spawn: spec must be a table') + assert(type(spec.argv) == 'table' and spec.argv[1], + 'ExecBackend.spawn: spec.argv must be a non-empty array') + + -- Common stdio wiring. + local child_spec, child_only, parent_fds, cfg_err = + stdio.build_child_stdio(spec, open_dev_null, make_pipe, set_cloexec, close_fd) + if not child_spec then + return nil, nil, cfg_err + end + + -- Fork. + local pid = toint(C.fork()) + if pid < 0 then + local e = get_errno() + stdio.close_child_only(child_only, close_fd) + stdio.close_parent_fds(parent_fds, close_fd) + return nil, nil, errno_msg('fork failed', nil, e) + end + + if pid == 0 then + child_exec(child_spec) -- never returns + end + + -- Parent: child-only fds no longer needed. + stdio.close_child_only(child_only, close_fd) + + -- Open pidfd. + local pidfd, perr, perrno = pidfd_open_raw(pid, 0) + if not pidfd then + C.kill(pid, SIGKILL) + wait_blocking(pid) + stdio.close_parent_fds(parent_fds, close_fd) + return nil, nil, errno_msg('pidfd_open failed', perr, perrno) + end + + local ok, e1 = set_nonblock(pidfd) + assert(ok, 'set_nonblock(pidfd) failed: ' .. tostring(e1)) + + ok, e1 = set_cloexec(pidfd) + assert(ok, 'set_cloexec(pidfd) failed: ' .. tostring(e1)) + + local state = { + pid = pid, + pidfd = pidfd, + exited = false, + status = nil, + code = nil, + signal = nil, + err = nil, + } + + -- Common mapping from parent_fds -> Streams. + local streams = stdio.build_parent_streams(parent_fds, open_stream) + + return state, streams, nil end --- poll(state) -> done, code, signal, err local function poll(state) - return poll_state(state) + return poll_state(state) end --- register_wait(state, task, suspension, leaf_wrap) -> WaitToken local function register_wait(state, task, _, _) - if not state.pidfd then - -- No pidfd: best-effort reschedule. - runtime.current_scheduler:schedule(task) - return { unlink = function() return false end } - end - return poller.get():wait(state.pidfd, "rd", task) + if not state.pidfd then + -- No pidfd: best-effort reschedule. + runtime.current_scheduler:schedule(task) + return { unlink = function () return false end } + end + return poller.get():wait(state.pidfd, 'rd', task) end local function send_signal(state, sig) - sig = sig or SIGTERM + sig = sig or SIGTERM - local rc = toint(C.kill(state.pid, sig)) - if rc == 0 then - return true, nil - end + local rc = toint(C.kill(state.pid, sig)) + if rc == 0 then + return true, nil + end - local e = get_errno() - if e == ESRCH then - return true, nil - end + local e = get_errno() + if e == ESRCH then + return true, nil + end - return false, errno_msg("kill failed", nil, e) + return false, errno_msg('kill failed', nil, e) end local function terminate(state) - return send_signal(state, SIGTERM) + return send_signal(state, SIGTERM) end local function kill_proc(state) - return send_signal(state, SIGKILL) + return send_signal(state, SIGKILL) end local function close_state(state) - if state.pidfd then - local rc = toint(C.close(state.pidfd)) - state.pidfd = nil - if rc ~= 0 then - local e = get_errno() - return false, strerror(e) - end - end - return true, nil + if state.pidfd then + local rc = toint(C.close(state.pidfd)) + state.pidfd = nil + if rc ~= 0 then + local e = get_errno() + return false, strerror(e) + end + end + return true, nil end ---------------------------------------------------------------------- @@ -539,29 +539,29 @@ end ---------------------------------------------------------------------- local function is_supported() - local pid = C.getpid() - local fd, _, eno = pidfd_open_raw(pid, 0) - if fd then - C.close(fd) - return true - end - - if eno == ENOSYS then - return false - end - - return true + local pid = C.getpid() + local fd, _, eno = pidfd_open_raw(pid, 0) + if fd then + C.close(fd) + return true + end + + if eno == ENOSYS then + return false + end + + return true end local ops = { - spawn = spawn, - poll = poll, - register_wait = register_wait, - send_signal = send_signal, - terminate = terminate, - kill = kill_proc, - close = close_state, - is_supported = is_supported, + spawn = spawn, + poll = poll, + register_wait = register_wait, + send_signal = send_signal, + terminate = terminate, + kill = kill_proc, + close = close_state, + is_supported = is_supported, } return core.build_backend(ops) diff --git a/src/fibers/io/exec_backend/sigchld.lua b/src/fibers/io/exec_backend/sigchld.lua index 7daf373..3bbdfce 100644 --- a/src/fibers/io/exec_backend/sigchld.lua +++ b/src/fibers/io/exec_backend/sigchld.lua @@ -25,9 +25,9 @@ local runtime = require 'fibers.runtime' local file_io = require 'fibers.io.file' local stdio = require 'fibers.io.exec_backend.stdio' -local bit = rawget(_G, "bit") or require 'bit32' +local bit = rawget(_G, 'bit') or require 'bit32' -local DEV_NULL = "/dev/null" +local DEV_NULL = '/dev/null' ---------------------------------------------------------------------- -- Global state for SIGCHLD handling @@ -53,13 +53,13 @@ local sig_w local reaper_started = false local function errno_msg(prefix, err, eno) - if err and err ~= "" then - return err - end - if eno then - return ("%s (errno %d)"):format(prefix, eno) - end - return prefix + if err and err ~= '' then + return err + end + if eno then + return ('%s (errno %d)'):format(prefix, eno) + end + return prefix end ---------------------------------------------------------------------- @@ -67,64 +67,64 @@ end ---------------------------------------------------------------------- local function set_nonblock(fd) - local flags, err, eno = fcntl.fcntl(fd, fcntl.F_GETFL) - if flags == nil then - return nil, errno_msg("fcntl(F_GETFL)", err, eno) - end - local newflags = bit.bor(flags, fcntl.O_NONBLOCK) - local ok, err2, eno2 = fcntl.fcntl(fd, fcntl.F_SETFL, newflags) - if ok == nil then - return nil, errno_msg("fcntl(F_SETFL)", err2, eno2) - end - return true + local flags, err, eno = fcntl.fcntl(fd, fcntl.F_GETFL) + if flags == nil then + return nil, errno_msg('fcntl(F_GETFL)', err, eno) + end + local newflags = bit.bor(flags, fcntl.O_NONBLOCK) + local ok, err2, eno2 = fcntl.fcntl(fd, fcntl.F_SETFL, newflags) + if ok == nil then + return nil, errno_msg('fcntl(F_SETFL)', err2, eno2) + end + return true end local function set_cloexec(fd) - local flags, err, eno = fcntl.fcntl(fd, fcntl.F_GETFD) - if flags == nil then - return nil, errno_msg("fcntl(F_GETFD)", err, eno) - end - local newflags = bit.bor(flags, fcntl.FD_CLOEXEC or 0) - local ok, err2, eno2 = fcntl.fcntl(fd, fcntl.F_SETFD, newflags) - if ok == nil then - return nil, errno_msg("fcntl(F_SETFD)", err2, eno2) - end - return true + local flags, err, eno = fcntl.fcntl(fd, fcntl.F_GETFD) + if flags == nil then + return nil, errno_msg('fcntl(F_GETFD)', err, eno) + end + local newflags = bit.bor(flags, fcntl.FD_CLOEXEC or 0) + local ok, err2, eno2 = fcntl.fcntl(fd, fcntl.F_SETFD, newflags) + if ok == nil then + return nil, errno_msg('fcntl(F_SETFD)', err2, eno2) + end + return true end local function must_child(ok, _, _) - if not ok or ok == 0 then - unistd._exit(127) - end + if not ok or ok == 0 then + unistd._exit(127) + end end local function build_argt(argv) - local cmd = assert(argv[1], "ProcSpec.argv[1] must be executable") - local argt = {} - argt[0] = cmd - for i = 2, #argv do - argt[i - 1] = argv[i] - end - return cmd, argt + local cmd = assert(argv[1], 'ProcSpec.argv[1] must be executable') + local argt = {} + argt[0] = cmd + for i = 2, #argv do + argt[i - 1] = argv[i] + end + return cmd, argt end local function setup_child_fd(src_fd, dest_fd) - if src_fd == nil or src_fd == dest_fd then - return - end - local newfd, err, eno = unistd.dup2(src_fd, dest_fd) - if not newfd then - must_child(false, err, eno) - end + if src_fd == nil or src_fd == dest_fd then + return + end + local newfd, err, eno = unistd.dup2(src_fd, dest_fd) + if not newfd then + must_child(false, err, eno) + end end local function apply_child_env(env) - for name, value in pairs(env) do - local ok, err, eno = stdlib.setenv(name, value and tostring(value) or nil) - if ok == nil then - must_child(false, err, eno) - end - end + for name, value in pairs(env) do + local ok, err, eno = stdlib.setenv(name, value and tostring(value) or nil) + if ok == nil then + must_child(false, err, eno) + end + end end ---------------------------------------------------------------------- @@ -132,47 +132,47 @@ end ---------------------------------------------------------------------- local function install_self_pipe_and_handler() - if sig_r ~= nil then - return - end - - local r, w, err, eno = unistd.pipe() - if not r then - error("exec_backend.sigchld: pipe() failed: " .. errno_msg("pipe", err, eno)) - end - - local ok1, e1 = set_nonblock(r) - local ok2, e2 = set_nonblock(w) - local ok3, e3 = set_cloexec(r) - local ok4, e4 = set_cloexec(w) - if not (ok1 and ok2 and ok3 and ok4) then - if r then unistd.close(r) end - if w then unistd.close(w) end - error("exec_backend.sigchld: failed to configure self-pipe: " - .. tostring(e1 or e2 or e3 or e4)) - end - - sig_r, sig_w = r, w - - local function handler() - unistd.write(sig_w, "x") - end - - if jit and jit.off then - jit.off(handler, true) - end - - local flags = psignal.SA_RESTART - local old, serr, seno - if flags ~= nil then - old, serr, seno = psignal.signal(psignal.SIGCHLD, handler, flags) - else - old, serr, seno = psignal.signal(psignal.SIGCHLD, handler) - end - if not old and serr then - error("exec_backend.sigchld: signal(SIGCHLD) failed: " - .. errno_msg("signal", serr, seno)) - end + if sig_r ~= nil then + return + end + + local r, w, err, eno = unistd.pipe() + if not r then + error('exec_backend.sigchld: pipe() failed: ' .. errno_msg('pipe', err, eno)) + end + + local ok1, e1 = set_nonblock(r) + local ok2, e2 = set_nonblock(w) + local ok3, e3 = set_cloexec(r) + local ok4, e4 = set_cloexec(w) + if not (ok1 and ok2 and ok3 and ok4) then + if r then unistd.close(r) end + if w then unistd.close(w) end + error('exec_backend.sigchld: failed to configure self-pipe: ' + .. tostring(e1 or e2 or e3 or e4)) + end + + sig_r, sig_w = r, w + + local function handler() + unistd.write(sig_w, 'x') + end + + if jit and jit.off then + jit.off(handler, true) + end + + local flags = psignal.SA_RESTART + local old, serr, seno + if flags ~= nil then + old, serr, seno = psignal.signal(psignal.SIGCHLD, handler, flags) + else + old, serr, seno = psignal.signal(psignal.SIGCHLD, handler) + end + if not old and serr then + error('exec_backend.sigchld: signal(SIGCHLD) failed: ' + .. errno_msg('signal', serr, seno)) + end end ---------------------------------------------------------------------- @@ -188,78 +188,78 @@ end ---@field err string|nil local function finalise_state(st, status, code, signal, err) - if st.exited then - return - end - st.exited = true - st.status = status - st.code = code - st.signal = signal - st.err = err + if st.exited then + return + end + st.exited = true + st.status = status + st.code = code + st.signal = signal + st.err = err end local function poll_state(st) - if st.exited then - return true, st.code, st.signal, st.err - end - return false, nil, nil, nil + if st.exited then + return true, st.code, st.signal, st.err + end + return false, nil, nil, nil end local function drain_self_pipe() - if not sig_r then return end - - while true do - local s, _, eno = unistd.read(sig_r, 4096) - if s == nil then - if eno == errno.EAGAIN or eno == errno.EWOULDBLOCK then - break - end - break - end - if #s < 4096 then - break - end - end + if not sig_r then return end + + while true do + local s, _, eno = unistd.read(sig_r, 4096) + if s == nil then + if eno == errno.EAGAIN or eno == errno.EWOULDBLOCK then + break + end + break + end + if #s < 4096 then + break + end + end end local function reap_known_children() - local to_remove = {} - - for pid, st in pairs(children) do - if st and not st.exited then - local rpid, how, v3, err, eno = syswait.wait(pid, syswait.WNOHANG) - if rpid == nil then - if eno == errno.ECHILD then - finalise_state(st, nil, nil, nil, nil) - to_remove[#to_remove + 1] = pid - elseif eno ~= errno.EINTR then - finalise_state(st, nil, nil, nil, errno_msg("wait failed", err, eno)) - to_remove[#to_remove + 1] = pid - end - elseif how ~= "running" then - if how == "exited" then - local code = v3 - finalise_state(st, v3, code, nil, nil) - else - local sig = v3 - finalise_state(st, v3, nil, sig, nil) - end - to_remove[#to_remove + 1] = pid - end - end - end - - if child_sched then - for i = 1, #to_remove do - local pid = to_remove[i] - children[pid] = nil - waiters:notify_all(pid, child_sched) - end - else - for i = 1, #to_remove do - children[to_remove[i]] = nil - end - end + local to_remove = {} + + for pid, st in pairs(children) do + if st and not st.exited then + local rpid, how, v3, err, eno = syswait.wait(pid, syswait.WNOHANG) + if rpid == nil then + if eno == errno.ECHILD then + finalise_state(st, nil, nil, nil, nil) + to_remove[#to_remove + 1] = pid + elseif eno ~= errno.EINTR then + finalise_state(st, nil, nil, nil, errno_msg('wait failed', err, eno)) + to_remove[#to_remove + 1] = pid + end + elseif how ~= 'running' then + if how == 'exited' then + local code = v3 + finalise_state(st, v3, code, nil, nil) + else + local sig = v3 + finalise_state(st, v3, nil, sig, nil) + end + to_remove[#to_remove + 1] = pid + end + end + end + + if child_sched then + for i = 1, #to_remove do + local pid = to_remove[i] + children[pid] = nil + waiters:notify_all(pid, child_sched) + end + else + for i = 1, #to_remove do + children[to_remove[i]] = nil + end + end end ---@class ReaperTask : Task @@ -267,35 +267,35 @@ local ReaperTask = {} ReaperTask.__index = ReaperTask function ReaperTask:run() - if not sig_r or not child_sched then - return - end - - self.armed = false - drain_self_pipe() - reap_known_children() - - if sig_r then - self.armed = true - poller.get():wait(sig_r, "rd", self) - end + if not sig_r or not child_sched then + return + end + + self.armed = false + drain_self_pipe() + reap_known_children() + + if sig_r then + self.armed = true + poller.get():wait(sig_r, 'rd', self) + end end local function start_reaper() - if reaper_started then - return - end + if reaper_started then + return + end - install_self_pipe_and_handler() + install_self_pipe_and_handler() - reaper_started = true - child_sched = runtime.current_scheduler + reaper_started = true + child_sched = runtime.current_scheduler - local task = setmetatable({}, ReaperTask) - if not task.armed then - task.armed = true - poller.get():wait(sig_r, "rd", task) - end + local task = setmetatable({}, ReaperTask) + if not task.armed then + task.armed = true + poller.get():wait(sig_r, 'rd', task) + end end ---------------------------------------------------------------------- @@ -304,46 +304,46 @@ end ---@param spec table -- child-facing spec with *fd fields local function child_exec(spec) - if spec.cwd then - local ok, err, eno = unistd.chdir(spec.cwd) - if not ok then - must_child(false, err, eno) - end - end - - if spec.flags and spec.flags.setsid then - local res, err, eno - if unistd.setsid then - res, err, eno = unistd.setsid() - elseif unistd.setpid then - res, err, eno = unistd.setpid("s", 0) - end - if res == nil then - must_child(false, err, eno) - end - end - - if spec.env then - apply_child_env(spec.env) - end - - setup_child_fd(spec.stdin_fd, 0) - setup_child_fd(spec.stdout_fd, 1) - setup_child_fd(spec.stderr_fd, 2) - - do - local seen = {} - for _, fd in ipairs{ spec.stdin_fd, spec.stdout_fd, spec.stderr_fd } do - if fd and fd > 2 and not seen[fd] then - seen[fd] = true - unistd.close(fd) - end - end - end - - local cmd, argt = build_argt(spec.argv) - unistd.execp(cmd, argt) - unistd._exit(127) + if spec.cwd then + local ok, err, eno = unistd.chdir(spec.cwd) + if not ok then + must_child(false, err, eno) + end + end + + if spec.flags and spec.flags.setsid then + local res, err, eno + if unistd.setsid then + res, err, eno = unistd.setsid() + elseif unistd.setpid then + res, err, eno = unistd.setpid('s', 0) + end + if res == nil then + must_child(false, err, eno) + end + end + + if spec.env then + apply_child_env(spec.env) + end + + setup_child_fd(spec.stdin_fd, 0) + setup_child_fd(spec.stdout_fd, 1) + setup_child_fd(spec.stderr_fd, 2) + + do + local seen = {} + for _, fd in ipairs { spec.stdin_fd, spec.stdout_fd, spec.stderr_fd } do + if fd and fd > 2 and not seen[fd] then + seen[fd] = true + unistd.close(fd) + end + end + end + + local cmd, argt = build_argt(spec.argv) + unistd.execp(cmd, argt) + unistd._exit(127) end ---------------------------------------------------------------------- @@ -351,32 +351,32 @@ end ---------------------------------------------------------------------- local function open_dev_null(is_output) - local flags = is_output and fcntl.O_WRONLY or fcntl.O_RDONLY - local fd, err, eno = fcntl.open(DEV_NULL, flags, 0) - if not fd then - return nil, errno_msg("failed to open " .. DEV_NULL, err, eno) - end - return fd, nil + local flags = is_output and fcntl.O_WRONLY or fcntl.O_RDONLY + local fd, err, eno = fcntl.open(DEV_NULL, flags, 0) + if not fd then + return nil, errno_msg('failed to open ' .. DEV_NULL, err, eno) + end + return fd, nil end local function make_pipe() - local rd, wr, err, eno = unistd.pipe() - if not rd then - return nil, nil, errno_msg("pipe() failed", err, eno) - end - return rd, wr, nil + local rd, wr, err, eno = unistd.pipe() + if not rd then + return nil, nil, errno_msg('pipe() failed', err, eno) + end + return rd, wr, nil end local function close_fd(fd) - unistd.close(fd) + unistd.close(fd) end local function open_stream(role, fd) - if role == "stdin" then - return file_io.fdopen(fd, fcntl.O_WRONLY) - else - return file_io.fdopen(fd, fcntl.O_RDONLY) - end + if role == 'stdin' then + return file_io.fdopen(fd, fcntl.O_WRONLY) + else + return file_io.fdopen(fd, fcntl.O_RDONLY) + end end ---------------------------------------------------------------------- @@ -387,105 +387,105 @@ end ---@param spec ExecProcSpec ---@return SigchldState|nil state, {stdin:Stream|nil, stdout:Stream|nil, stderr:Stream|nil}|nil streams, string|nil err local function spawn(spec) - assert(type(spec) == "table", "ExecBackend.spawn: spec must be a table") - assert(type(spec.argv) == "table" and spec.argv[1], - "ExecBackend.spawn: spec.argv must be a non-empty array") - - install_self_pipe_and_handler() - start_reaper() - - -- Common stdio wiring. - local child_spec, child_only, parent_fds, cfg_err = - stdio.build_child_stdio(spec, open_dev_null, make_pipe, set_cloexec, close_fd) - if not child_spec then - return nil, nil, cfg_err - end - - local pid, err, eno = unistd.fork() - if not pid then - stdio.close_child_only(child_only, close_fd) - stdio.close_parent_fds(parent_fds, close_fd) - return nil, nil, errno_msg("fork failed", err, eno) - end - - if pid == 0 then - child_exec(child_spec) - end - - -- Parent: child-only fds no longer needed. - stdio.close_child_only(child_only, close_fd) - - local state = { - pid = pid, - exited = false, - status = nil, - code = nil, - signal = nil, - err = nil, - } - - children[pid] = state - - local streams = stdio.build_parent_streams(parent_fds, open_stream) - - return state, streams, nil + assert(type(spec) == 'table', 'ExecBackend.spawn: spec must be a table') + assert(type(spec.argv) == 'table' and spec.argv[1], + 'ExecBackend.spawn: spec.argv must be a non-empty array') + + install_self_pipe_and_handler() + start_reaper() + + -- Common stdio wiring. + local child_spec, child_only, parent_fds, cfg_err = + stdio.build_child_stdio(spec, open_dev_null, make_pipe, set_cloexec, close_fd) + if not child_spec then + return nil, nil, cfg_err + end + + local pid, err, eno = unistd.fork() + if not pid then + stdio.close_child_only(child_only, close_fd) + stdio.close_parent_fds(parent_fds, close_fd) + return nil, nil, errno_msg('fork failed', err, eno) + end + + if pid == 0 then + child_exec(child_spec) + end + + -- Parent: child-only fds no longer needed. + stdio.close_child_only(child_only, close_fd) + + local state = { + pid = pid, + exited = false, + status = nil, + code = nil, + signal = nil, + err = nil, + } + + children[pid] = state + + local streams = stdio.build_parent_streams(parent_fds, open_stream) + + return state, streams, nil end local function poll(state) - -- Fast path: if the child is already marked as exited, just report it. - if state.exited then - return true, state.code, state.signal, state.err - end - -- Ensure progress even when no scheduler is running - reap_known_children() - return poll_state(state) + -- Fast path: if the child is already marked as exited, just report it. + if state.exited then + return true, state.code, state.signal, state.err + end + -- Ensure progress even when no scheduler is running + reap_known_children() + return poll_state(state) end local function register_wait(state, task, _, _) - start_reaper() - return waiters:add(state.pid, task) + start_reaper() + return waiters:add(state.pid, task) end local function send_signal(state, sig) - sig = sig or psignal.SIGTERM - local rc, err, eno = psignal.kill(state.pid, sig) - if rc == 0 then - return true, nil - end - if rc == nil and eno == errno.ESRCH then - return true, nil - end - return false, errno_msg("kill failed", err, eno) + sig = sig or psignal.SIGTERM + local rc, err, eno = psignal.kill(state.pid, sig) + if rc == 0 then + return true, nil + end + if rc == nil and eno == errno.ESRCH then + return true, nil + end + return false, errno_msg('kill failed', err, eno) end local function terminate(state) - return send_signal(state, psignal.SIGTERM) + return send_signal(state, psignal.SIGTERM) end local function kill_proc(state) - return send_signal(state, psignal.SIGKILL) + return send_signal(state, psignal.SIGKILL) end local function close_state(state) - waiters:clear_key(state.pid) - return true, nil + waiters:clear_key(state.pid) + return true, nil end local function is_supported() - if rawget(_G, "jit") then return false end -- rare LuaJit instability - return psignal.SIGCHLD ~= nil + if rawget(_G, 'jit') then return false end -- rare LuaJit instability + return psignal.SIGCHLD ~= nil end local ops = { - spawn = spawn, - poll = poll, - register_wait = register_wait, - send_signal = send_signal, - terminate = terminate, - kill = kill_proc, - close = close_state, - is_supported = is_supported, + spawn = spawn, + poll = poll, + register_wait = register_wait, + send_signal = send_signal, + terminate = terminate, + kill = kill_proc, + close = close_state, + is_supported = is_supported, } return core.build_backend(ops) diff --git a/src/fibers/io/exec_backend/stdio.lua b/src/fibers/io/exec_backend/stdio.lua index 4254430..7e22c70 100644 --- a/src/fibers/io/exec_backend/stdio.lua +++ b/src/fibers/io/exec_backend/stdio.lua @@ -15,14 +15,14 @@ local M = {} ---@param s any ---@return integer|nil fd, string|nil err local function stream_fileno(s) - if type(s) ~= "table" then - return nil, "stream is not a table" - end - local io_backend = s.io - if type(io_backend) ~= "table" or type(io_backend.fileno) ~= "function" then - return nil, "stream backend does not support fileno()" - end - return io_backend:fileno() + if type(s) ~= 'table' then + return nil, 'stream is not a table' + end + local io_backend = s.io + if type(io_backend) ~= 'table' or type(io_backend.fileno) ~= 'function' then + return nil, 'stream backend does not support fileno()' + end + return io_backend:fileno() end --- Build child stdio fd mapping and parent pipe ends from a high-level spec. @@ -40,165 +40,165 @@ end ---@param close_fd fun(fd: integer) ---@return table|nil child_spec, table|nil child_only, table|nil parent_fds, string|nil err function M.build_child_stdio(spec, open_dev_null, make_pipe, set_cloexec, close_fd) - assert(type(spec) == "table", "build_child_stdio: spec must be a table") - assert(type(open_dev_null) == "function", "build_child_stdio: open_dev_null must be a function") - assert(type(make_pipe) == "function", "build_child_stdio: make_pipe must be a function") - assert(type(set_cloexec) == "function", "build_child_stdio: set_cloexec must be a function") - assert(type(close_fd) == "function", "build_child_stdio: close_fd must be a function") - - local child_only = {} -- [fd] = true (used only in child) - local parent_fds = {} -- stdin/stdout/stderr -> parent end for pipes - - local child_spec = { - argv = spec.argv, - cwd = spec.cwd, - env = spec.env, - flags = spec.flags, - stdin_fd = nil, - stdout_fd = nil, - stderr_fd = nil, - } - - local function fail(msg) - for fd in pairs(child_only) do - close_fd(fd) - end - for _, fd in pairs(parent_fds) do - if fd then - close_fd(fd) - end - end - return nil, nil, nil, msg - end - - --- Configure a single stdio stream. - --- kind: "stdin" | "stdout" | "stderr" - --- cfg : ExecStreamConfig|nil - local function configure_stream(kind, cfg) - -- Allow callers to omit stdin/stdout/stderr in the spec; treat as inherit. - cfg = cfg or { mode = "inherit" } - - local is_output = (kind ~= "stdin") - local field = kind .. "_fd" - local mode = cfg.mode or "inherit" - - -- inherit - if mode == "inherit" then - child_spec[field] = nil - return true - - -- /dev/null - elseif mode == "null" then - local fd, err = open_dev_null(is_output) - if not fd then - return false, err - end - child_only[fd] = true - child_spec[field] = fd - return true - - -- pipe (child ↔ parent) - elseif mode == "pipe" then - local rd, wr, err = make_pipe() - if not (rd and wr) then - return false, err - end - - local child_fd, parent_fd - if is_output then - -- child writes, parent reads - child_fd, parent_fd = wr, rd - else - -- child reads, parent writes - child_fd, parent_fd = rd, wr - end - - child_only[child_fd] = true - child_spec[field] = child_fd - parent_fds[kind] = parent_fd - - local ok, cerr = set_cloexec(parent_fd) - if not ok then - return false, cerr or ("set_cloexec(" .. kind .. " parent fd) failed") - end - return true - - -- user-supplied stream: just borrow the underlying fd - elseif mode == "stream" then - local fd, err = stream_fileno(cfg.stream) - if not fd then - return false, err - end - child_spec[field] = fd - return true - - -- stderr = "stdout" - elseif kind == "stderr" and mode == "stdout" then - -- stdout config may itself be missing; default to inherit in that case. - local out_cfg = spec.stdout or { mode = "inherit" } - local out_mode = out_cfg.mode or "inherit" - - if out_mode == "inherit" and child_spec.stdout_fd == nil then - -- Share inherited stdout (fd 1). - child_spec.stderr_fd = 1 - elseif child_spec.stdout_fd ~= nil then - -- Share whatever stdout was configured to use. - child_spec.stderr_fd = child_spec.stdout_fd - else - return false, "stderr='stdout' but stdout not configured" - end - return true - - else - return false, "invalid " .. kind .. " mode: " .. tostring(mode) - end - end - - - do - local ok, err = configure_stream("stdin", spec.stdin) - if not ok then - return fail(err) - end - end - - do - local ok, err = configure_stream("stdout", spec.stdout) - if not ok then - return fail(err) - end - end - - do - local ok, err = configure_stream("stderr", spec.stderr) - if not ok then - return fail(err) - end - end - - return child_spec, child_only, parent_fds, nil + assert(type(spec) == 'table', 'build_child_stdio: spec must be a table') + assert(type(open_dev_null) == 'function', 'build_child_stdio: open_dev_null must be a function') + assert(type(make_pipe) == 'function', 'build_child_stdio: make_pipe must be a function') + assert(type(set_cloexec) == 'function', 'build_child_stdio: set_cloexec must be a function') + assert(type(close_fd) == 'function', 'build_child_stdio: close_fd must be a function') + + local child_only = {} -- [fd] = true (used only in child) + local parent_fds = {} -- stdin/stdout/stderr -> parent end for pipes + + local child_spec = { + argv = spec.argv, + cwd = spec.cwd, + env = spec.env, + flags = spec.flags, + stdin_fd = nil, + stdout_fd = nil, + stderr_fd = nil, + } + + local function fail(msg) + for fd in pairs(child_only) do + close_fd(fd) + end + for _, fd in pairs(parent_fds) do + if fd then + close_fd(fd) + end + end + return nil, nil, nil, msg + end + + --- Configure a single stdio stream. + --- kind: "stdin" | "stdout" | "stderr" + --- cfg : ExecStreamConfig|nil + local function configure_stream(kind, cfg) + -- Allow callers to omit stdin/stdout/stderr in the spec; treat as inherit. + cfg = cfg or { mode = 'inherit' } + + local is_output = (kind ~= 'stdin') + local field = kind .. '_fd' + local mode = cfg.mode or 'inherit' + + -- inherit + if mode == 'inherit' then + child_spec[field] = nil + return true + + -- /dev/null + elseif mode == 'null' then + local fd, err = open_dev_null(is_output) + if not fd then + return false, err + end + child_only[fd] = true + child_spec[field] = fd + return true + + -- pipe (child ↔ parent) + elseif mode == 'pipe' then + local rd, wr, err = make_pipe() + if not (rd and wr) then + return false, err + end + + local child_fd, parent_fd + if is_output then + -- child writes, parent reads + child_fd, parent_fd = wr, rd + else + -- child reads, parent writes + child_fd, parent_fd = rd, wr + end + + child_only[child_fd] = true + child_spec[field] = child_fd + parent_fds[kind] = parent_fd + + local ok, cerr = set_cloexec(parent_fd) + if not ok then + return false, cerr or ('set_cloexec(' .. kind .. ' parent fd) failed') + end + return true + + -- user-supplied stream: just borrow the underlying fd + elseif mode == 'stream' then + local fd, err = stream_fileno(cfg.stream) + if not fd then + return false, err + end + child_spec[field] = fd + return true + + -- stderr = "stdout" + elseif kind == 'stderr' and mode == 'stdout' then + -- stdout config may itself be missing; default to inherit in that case. + local out_cfg = spec.stdout or { mode = 'inherit' } + local out_mode = out_cfg.mode or 'inherit' + + if out_mode == 'inherit' and child_spec.stdout_fd == nil then + -- Share inherited stdout (fd 1). + child_spec.stderr_fd = 1 + elseif child_spec.stdout_fd ~= nil then + -- Share whatever stdout was configured to use. + child_spec.stderr_fd = child_spec.stdout_fd + else + return false, "stderr='stdout' but stdout not configured" + end + return true + + else + return false, 'invalid ' .. kind .. ' mode: ' .. tostring(mode) + end + end + + + do + local ok, err = configure_stream('stdin', spec.stdin) + if not ok then + return fail(err) + end + end + + do + local ok, err = configure_stream('stdout', spec.stdout) + if not ok then + return fail(err) + end + end + + do + local ok, err = configure_stream('stderr', spec.stderr) + if not ok then + return fail(err) + end + end + + return child_spec, child_only, parent_fds, nil end --- Best-effort close of fds that are only needed in the child. ---@param child_only table|nil ---@param close_fd fun(fd: integer) function M.close_child_only(child_only, close_fd) - if not child_only then return end - for fd in pairs(child_only) do - close_fd(fd) - end + if not child_only then return end + for fd in pairs(child_only) do + close_fd(fd) + end end --- Best-effort close of parent pipe ends (used on error paths). ---@param parent_fds table|nil ---@param close_fd fun(fd: integer) function M.close_parent_fds(parent_fds, close_fd) - if not parent_fds then return end - for _, fd in pairs(parent_fds) do - if fd then - close_fd(fd) - end - end + if not parent_fds then return end + for _, fd in pairs(parent_fds) do + if fd then + close_fd(fd) + end + end end --- Map parent pipe fds back into Streams, given an open_stream callback. @@ -209,25 +209,25 @@ end ---@param open_stream fun(role: '"stdin"'|'"stdout"'|'"stderr"', fd: integer): Stream ---@return { stdin: Stream|nil, stdout: Stream|nil, stderr: Stream|nil } function M.build_parent_streams(parent_fds, open_stream) - parent_fds = parent_fds or {} - - local stdin, stdout, stderr - - if parent_fds.stdin then - stdin = open_stream("stdin", parent_fds.stdin) - end - if parent_fds.stdout then - stdout = open_stream("stdout", parent_fds.stdout) - end - if parent_fds.stderr then - stderr = open_stream("stderr", parent_fds.stderr) - end - - return { - stdin = stdin, - stdout = stdout, - stderr = stderr, - } + parent_fds = parent_fds or {} + + local stdin, stdout, stderr + + if parent_fds.stdin then + stdin = open_stream('stdin', parent_fds.stdin) + end + if parent_fds.stdout then + stdout = open_stream('stdout', parent_fds.stdout) + end + if parent_fds.stderr then + stderr = open_stream('stderr', parent_fds.stderr) + end + + return { + stdin = stdin, + stdout = stdout, + stderr = stderr, + } end return M diff --git a/src/fibers/io/fd_backend.lua b/src/fibers/io/fd_backend.lua index b48c1b4..d7c34c7 100644 --- a/src/fibers/io/fd_backend.lua +++ b/src/fibers/io/fd_backend.lua @@ -8,16 +8,16 @@ ---@module 'fibers.io.fd_backend' local candidates = { - 'fibers.io.fd_backend.ffi', -- FFI / libc - 'fibers.io.fd_backend.posix', -- luaposix - 'fibers.io.fd_backend.nixio', -- nixio + 'fibers.io.fd_backend.ffi', -- FFI / libc + 'fibers.io.fd_backend.posix', -- luaposix + 'fibers.io.fd_backend.nixio', -- nixio } for _, name in ipairs(candidates) do - local ok, mod = pcall(require, name) - if ok and type(mod) == "table" and mod.is_supported and mod.is_supported() then - return mod - end + local ok, mod = pcall(require, name) + if ok and type(mod) == 'table' and mod.is_supported and mod.is_supported() then + return mod + end end -error("fibers.io.fd_backend: no suitable fd backend available on this platform") +error('fibers.io.fd_backend: no suitable fd backend available on this platform') diff --git a/src/fibers/io/fd_backend/core.lua b/src/fibers/io/fd_backend/core.lua index 44c5a62..db2de75 100644 --- a/src/fibers/io/fd_backend/core.lua +++ b/src/fibers/io/fd_backend/core.lua @@ -10,63 +10,63 @@ local FdBackend = {} FdBackend.__index = FdBackend function FdBackend:kind() - return "fd" + return 'fd' end function FdBackend:fileno() - return self._fd + return self._fd end function FdBackend:read_string(max) - if not self._fd then - return nil, "closed" - end + if not self._fd then + return nil, 'closed' + end - max = max or 4096 - if max <= 0 then - return "", nil - end + max = max or 4096 + if max <= 0 then + return '', nil + end - return self._ops.read(self._fd, max) + return self._ops.read(self._fd, max) end function FdBackend:write_string(str) - if not self._fd then - return nil, "closed" - end + if not self._fd then + return nil, 'closed' + end - local len = #str - if len == 0 then - return 0, nil - end + local len = #str + if len == 0 then + return 0, nil + end - return self._ops.write(self._fd, str, len) + return self._ops.write(self._fd, str, len) end function FdBackend:seek(whence, off) - if not self._fd then - return nil, "closed" - end - return self._ops.seek(self._fd, whence, off) + if not self._fd then + return nil, 'closed' + end + return self._ops.seek(self._fd, whence, off) end function FdBackend:on_readable(task) - return poller.get():wait(assert(self._fd, "closed fd"), "rd", task) + return poller.get():wait(assert(self._fd, 'closed fd'), 'rd', task) end function FdBackend:on_writable(task) - return poller.get():wait(assert(self._fd, "closed fd"), "wr", task) + return poller.get():wait(assert(self._fd, 'closed fd'), 'wr', task) end function FdBackend:close() - if self._fd == nil then - return true, nil - end + if self._fd == nil then + return true, nil + end - local fd = self._fd - self._fd = nil + local fd = self._fd + self._fd = nil - return self._ops.close(fd) + return self._ops.close(fd) end ---------------------------------------------------------------------- @@ -110,186 +110,186 @@ end ---@param ops table ---@return table backend_module local function build_backend(ops) - local required = { "set_nonblock", "read", "write", "seek", "close" } - for _, k in ipairs(required) do - assert(type(ops[k]) == "function", - "fd_backend ops." .. k .. " must be a function") - end - - local function new(fd, opts) - opts = opts or {} - - if fd ~= nil then - local ok, err = ops.set_nonblock(fd) - if not ok then - error("fd_backend: set_nonblock(" .. tostring(fd) .. ") failed: " - .. tostring(err)) - end - end - - local self = { - _fd = fd, - _ops = ops, - filename = opts.filename, - } - return setmetatable(self, FdBackend) - end - - local function is_supported() - if type(ops.is_supported) == "function" then - return not not ops.is_supported() - end - return true - end - - -------------------------------------------------------------------- - -- File-level helpers - -------------------------------------------------------------------- - - local function open_file(path, mode, perms) - assert(type(ops.open_file) == "function", - "fd_backend backend does not implement open_file") - return ops.open_file(path, mode, perms) - end - - local function pipe() - assert(type(ops.pipe) == "function", - "fd_backend backend does not implement pipe") - return ops.pipe() - end - - local function mktemp(prefix, perms) - assert(type(ops.mktemp) == "function", - "fd_backend backend does not implement mktemp") - return ops.mktemp(prefix, perms) - end - - local function fsync(fd) - if not ops.fsync then - return true, nil - end - return ops.fsync(fd) - end - - local function rename(oldpath, newpath) - assert(type(ops.rename) == "function", - "fd_backend backend does not implement rename") - return ops.rename(oldpath, newpath) - end - - local function unlink(path) - assert(type(ops.unlink) == "function", - "fd_backend backend does not implement unlink") - return ops.unlink(path) - end - - local function decode_access(flags) - if not ops.decode_access then - error("fd_backend backend does not implement decode_access") - end - return ops.decode_access(flags) - end - - local function ignore_sigpipe() - if ops.ignore_sigpipe then - return ops.ignore_sigpipe() - end - return true, nil - end - - local function init_nonblocking(fd) - return ops.set_nonblock(fd) - end - - local function close_fd(fd) - return ops.close(fd) - end - - -------------------------------------------------------------------- - -- Socket-level helpers (optional) - -------------------------------------------------------------------- - - local function socket(domain, stype, protocol) - if not ops.socket then - error("fd_backend backend does not implement socket()") - end - return ops.socket(domain, stype, protocol or 0) - end - - local function bind(fd, sa) - if not ops.bind then - error("fd_backend backend does not implement bind()") - end - return ops.bind(fd, sa) - end - - local function listen(fd) - if not ops.listen then - error("fd_backend backend does not implement listen()") - end - return ops.listen(fd) - end - - --- accept(fd) -> newfd|nil, err|nil, again:boolean - local function accept(fd) - if not ops.accept then - error("fd_backend backend does not implement accept()") - end - local newfd, err, again = ops.accept(fd) - return newfd, err, again - end - - --- connect_start(fd, sa) -> ok|nil, err|nil, inprogress:boolean - local function connect_start(fd, sa) - if not ops.connect_start then - error("fd_backend backend does not implement connect_start()") - end - local ok, err, inprogress = ops.connect_start(fd, sa) - return ok, err, inprogress - end - - --- connect_finish(fd) -> ok:boolean, err|nil - local function connect_finish(fd) - if not ops.connect_finish then - error("fd_backend backend does not implement connect_finish()") - end - return ops.connect_finish(fd) - end - - return { - new = new, - is_supported = is_supported, - - -- low-level helper - set_nonblock = init_nonblocking, - close_fd = close_fd, - - -- file-level helpers - open_file = open_file, - pipe = pipe, - mktemp = mktemp, - fsync = fsync, - rename = rename, - unlink = unlink, - decode_access = decode_access, - ignore_sigpipe = ignore_sigpipe, - - -- socket-level helpers - socket = socket, - bind = bind, - listen = listen, - accept = accept, - connect_start = connect_start, - connect_finish = connect_finish, - - -- metadata (if provided) - modes = ops.modes or {}, - permissions = ops.permissions or {}, - AF_UNIX = ops.AF_UNIX, - SOCK_STREAM = ops.SOCK_STREAM, - } + local required = { 'set_nonblock', 'read', 'write', 'seek', 'close' } + for _, k in ipairs(required) do + assert(type(ops[k]) == 'function', + 'fd_backend ops.' .. k .. ' must be a function') + end + + local function new(fd, opts) + opts = opts or {} + + if fd ~= nil then + local ok, err = ops.set_nonblock(fd) + if not ok then + error('fd_backend: set_nonblock(' .. tostring(fd) .. ') failed: ' + .. tostring(err)) + end + end + + local self = { + _fd = fd, + _ops = ops, + filename = opts.filename, + } + return setmetatable(self, FdBackend) + end + + local function is_supported() + if type(ops.is_supported) == 'function' then + return not not ops.is_supported() + end + return true + end + + -------------------------------------------------------------------- + -- File-level helpers + -------------------------------------------------------------------- + + local function open_file(path, mode, perms) + assert(type(ops.open_file) == 'function', + 'fd_backend backend does not implement open_file') + return ops.open_file(path, mode, perms) + end + + local function pipe() + assert(type(ops.pipe) == 'function', + 'fd_backend backend does not implement pipe') + return ops.pipe() + end + + local function mktemp(prefix, perms) + assert(type(ops.mktemp) == 'function', + 'fd_backend backend does not implement mktemp') + return ops.mktemp(prefix, perms) + end + + local function fsync(fd) + if not ops.fsync then + return true, nil + end + return ops.fsync(fd) + end + + local function rename(oldpath, newpath) + assert(type(ops.rename) == 'function', + 'fd_backend backend does not implement rename') + return ops.rename(oldpath, newpath) + end + + local function unlink(path) + assert(type(ops.unlink) == 'function', + 'fd_backend backend does not implement unlink') + return ops.unlink(path) + end + + local function decode_access(flags) + if not ops.decode_access then + error('fd_backend backend does not implement decode_access') + end + return ops.decode_access(flags) + end + + local function ignore_sigpipe() + if ops.ignore_sigpipe then + return ops.ignore_sigpipe() + end + return true, nil + end + + local function init_nonblocking(fd) + return ops.set_nonblock(fd) + end + + local function close_fd(fd) + return ops.close(fd) + end + + -------------------------------------------------------------------- + -- Socket-level helpers (optional) + -------------------------------------------------------------------- + + local function socket(domain, stype, protocol) + if not ops.socket then + error('fd_backend backend does not implement socket()') + end + return ops.socket(domain, stype, protocol or 0) + end + + local function bind(fd, sa) + if not ops.bind then + error('fd_backend backend does not implement bind()') + end + return ops.bind(fd, sa) + end + + local function listen(fd) + if not ops.listen then + error('fd_backend backend does not implement listen()') + end + return ops.listen(fd) + end + + --- accept(fd) -> newfd|nil, err|nil, again:boolean + local function accept(fd) + if not ops.accept then + error('fd_backend backend does not implement accept()') + end + local newfd, err, again = ops.accept(fd) + return newfd, err, again + end + + --- connect_start(fd, sa) -> ok|nil, err|nil, inprogress:boolean + local function connect_start(fd, sa) + if not ops.connect_start then + error('fd_backend backend does not implement connect_start()') + end + local ok, err, inprogress = ops.connect_start(fd, sa) + return ok, err, inprogress + end + + --- connect_finish(fd) -> ok:boolean, err|nil + local function connect_finish(fd) + if not ops.connect_finish then + error('fd_backend backend does not implement connect_finish()') + end + return ops.connect_finish(fd) + end + + return { + new = new, + is_supported = is_supported, + + -- low-level helper + set_nonblock = init_nonblocking, + close_fd = close_fd, + + -- file-level helpers + open_file = open_file, + pipe = pipe, + mktemp = mktemp, + fsync = fsync, + rename = rename, + unlink = unlink, + decode_access = decode_access, + ignore_sigpipe = ignore_sigpipe, + + -- socket-level helpers + socket = socket, + bind = bind, + listen = listen, + accept = accept, + connect_start = connect_start, + connect_finish = connect_finish, + + -- metadata (if provided) + modes = ops.modes or {}, + permissions = ops.permissions or {}, + AF_UNIX = ops.AF_UNIX, + SOCK_STREAM = ops.SOCK_STREAM, + } end return { - build_backend = build_backend, + build_backend = build_backend, } diff --git a/src/fibers/io/fd_backend/ffi.lua b/src/fibers/io/fd_backend/ffi.lua index 94d2604..3834e6a 100644 --- a/src/fibers/io/fd_backend/ffi.lua +++ b/src/fibers/io/fd_backend/ffi.lua @@ -5,11 +5,11 @@ -- ---@module 'fibers.io.fd_backend.ffi' -local core = require 'fibers.io.fd_backend.core' -local ffi_c = require 'fibers.utils.ffi_compat' +local core = require 'fibers.io.fd_backend.core' +local ffi_c = require 'fibers.utils.ffi_compat' if not ffi_c.is_supported() then - return { is_supported = function() return false end } + return { is_supported = function () return false end } end local ffi = ffi_c.ffi @@ -17,15 +17,19 @@ local C = ffi_c.C local toint = ffi_c.tonumber local get_errno = ffi_c.errno -local ok_bit, bit_mod = pcall(function() - return rawget(_G, "bit") or require 'bit32' +local ok_bit, bit_mod = pcall(function () + return rawget(_G, 'bit') or require 'bit32' end) if not ok_bit or not bit_mod then - return { is_supported = function() return false end } + return { is_supported = function () return false end } end local bit = bit_mod -ffi.cdef[[ +---@class sockaddr_un_cdata : ffi.cdata* +---@field sun_family integer +---@field sun_path string|integer[] + +ffi.cdef [[ typedef long ssize_t; typedef long off_t; @@ -68,18 +72,18 @@ ffi.cdef[[ ]] -- POSIX fcntl command numbers on Linux. -local F_GETFL = 3 -local F_SETFL = 4 +local F_GETFL = 3 +local F_SETFL = 4 -- Linux O_* constants (values as on glibc/Linux; adjust if you support other ABIs). -local O_RDONLY = 0x0000 -local O_WRONLY = 0x0001 -local O_RDWR = 0x0002 -local O_ACCMODE = 0x0003 -local O_CREAT = 0x0040 -local O_EXCL = 0x0080 -local O_TRUNC = 0x0200 -local O_APPEND = 0x0400 +local O_RDONLY = 0x0000 +local O_WRONLY = 0x0001 +local O_RDWR = 0x0002 +local O_ACCMODE = 0x0003 +local O_CREAT = 0x0040 +local O_EXCL = 0x0080 +local O_TRUNC = 0x0200 +local O_APPEND = 0x0400 local O_NONBLOCK = 0x00000800 -- Permission bits (standard POSIX values). @@ -105,116 +109,116 @@ local SOMAXCONN = 128 local SIGPIPE = 13 local function strerror(e) - local s = C.strerror(e) - if s == nil then - return "errno " .. tostring(e) - end - return ffi.string(s) + local s = C.strerror(e) + if s == nil then + return 'errno ' .. tostring(e) + end + return ffi.string(s) end ---------------------------------------------------------------------- -- fcntl helpers (casted to avoid varargs issues) ---------------------------------------------------------------------- -local getfl_fp = ffi.cast("int (*)(int, int)", C.fcntl) -local setfl_fp = ffi.cast("int (*)(int, int, int)", C.fcntl) +local getfl_fp = ffi.cast('int (*)(int, int)', C.fcntl) +local setfl_fp = ffi.cast('int (*)(int, int, int)', C.fcntl) local function set_nonblock(fd) - local before = toint(getfl_fp(fd, F_GETFL)) - if before < 0 then - local e = get_errno() - return false, ("F_GETFL failed: %s"):format(strerror(e)), e - end - - local new_flags = bit.bor(before, O_NONBLOCK) - local rc = toint(setfl_fp(fd, F_SETFL, new_flags)) - if rc < 0 then - local e = get_errno() - return false, ("F_SETFL failed: %s"):format(strerror(e)), e - end - - -- Optional sanity check. - local after = toint(getfl_fp(fd, F_GETFL)) - if after < 0 then - local e = get_errno() - return false, ("F_GETFL (post) failed: %s"):format(strerror(e)), e - end - - if bit.band(after, O_NONBLOCK) == 0 then - return false, - ("set_nonblock: O_NONBLOCK not set after F_SETFL; before=0x%x after=0x%x") - :format(before, after), - nil - end - - return true, nil, nil + local before = assert(toint(getfl_fp(fd, F_GETFL))) + if before < 0 then + local e = get_errno() + return false, ('F_GETFL failed: %s'):format(strerror(e)), e + end + + local new_flags = bit.bor(before, O_NONBLOCK) + local rc = toint(setfl_fp(fd, F_SETFL, new_flags)) + if rc < 0 then + local e = get_errno() + return false, ('F_SETFL failed: %s'):format(strerror(e)), e + end + + -- Optional sanity check. + local after = assert(toint(getfl_fp(fd, F_GETFL))) + if after < 0 then + local e = get_errno() + return false, ('F_GETFL (post) failed: %s'):format(strerror(e)), e + end + + if bit.band(after, O_NONBLOCK) == 0 then + return false, + ('set_nonblock: O_NONBLOCK not set after F_SETFL; before=0x%x after=0x%x') + :format(before, after), + nil + end + + return true, nil, nil end ---------------------------------------------------------------------- -- Low-level ops implementing the core contract ---------------------------------------------------------------------- -local SEEK = { set = 0, cur = 1, ["end"] = 2 } +local SEEK = { set = 0, cur = 1, ['end'] = 2 } local function read_fd(fd, max) - local buf = ffi.new("char[?]", max) - local n = toint(C.read(fd, buf, max)) - - if n < 0 then - local e = get_errno() - if e == EAGAIN or e == EWOULDBLOCK then - return nil, nil -- would block - end - return nil, strerror(e) - end - - if n == 0 then - return "", nil -- EOF - end - - if n > max then - return nil, "read returned " .. tostring(n) .. " bytes (max " .. tostring(max) .. ")" - end - - return ffi.string(buf, n), nil + local buf = ffi.new('char[?]', max) + local n = toint(C.read(fd, buf, max)) + + if n < 0 then + local e = get_errno() + if e == EAGAIN or e == EWOULDBLOCK then + return nil, nil -- would block + end + return nil, strerror(e) + end + + if n == 0 then + return '', nil -- EOF + end + + if n > max then + return nil, 'read returned ' .. tostring(n) .. ' bytes (max ' .. tostring(max) .. ')' + end + + return ffi.string(buf, n), nil end local function write_fd(fd, str, len) - local buf = ffi.new("char[?]", len) - ffi.copy(buf, str, len) - - local n = toint(C.write(fd, buf, len)) - if n < 0 then - local e = get_errno() - if e == EAGAIN or e == EWOULDBLOCK then - return nil, nil -- would block - end - return nil, strerror(e) - end - - return n, nil + local buf = ffi.new('char[?]', len) + ffi.copy(buf, str, len) + + local n = toint(C.write(fd, buf, len)) + if n < 0 then + local e = get_errno() + if e == EAGAIN or e == EWOULDBLOCK then + return nil, nil -- would block + end + return nil, strerror(e) + end + + return n, nil end local function seek_fd(fd, whence, off) - local w = SEEK[whence] - if not w then - return nil, "bad whence: " .. tostring(whence) - end + local w = SEEK[whence] + if not w then + return nil, 'bad whence: ' .. tostring(whence) + end - local res = toint(C.lseek(fd, off, w)) - if res < 0 then - return nil, strerror(get_errno()) - end + local res = toint(C.lseek(fd, off, w)) + if res < 0 then + return nil, strerror(get_errno()) + end - return res, nil + return res, nil end local function close_fd(fd) - local rc = toint(C.close(fd)) - if rc ~= 0 then - return false, strerror(get_errno()) - end - return true, nil + local rc = toint(C.close(fd)) + if rc ~= 0 then + return false, strerror(get_errno()) + end + return true, nil end ---------------------------------------------------------------------- @@ -222,59 +226,59 @@ end ---------------------------------------------------------------------- local function open_fd(path, flags, perms) - local c_path = ffi.new("char[?]", #path + 1) - ffi.copy(c_path, path) - local fd = toint(C.open(c_path, flags, perms or 0)) - if fd < 0 then - local e = get_errno() - return nil, strerror(e) - end - return fd, nil + local c_path = ffi.new('char[?]', #path + 1) + ffi.copy(c_path, path) + local fd = toint(C.open(c_path, flags, perms or 0)) + if fd < 0 then + local e = get_errno() + return nil, strerror(e) + end + return fd, nil end local function pipe_fd() - local fds = ffi.new("int[2]") - local rc = toint(C.pipe(fds)) - if rc ~= 0 then - local e = get_errno() - return nil, nil, strerror(e) - end - return toint(fds[0]), toint(fds[1]), nil + local fds = ffi.new('int[2]') + local rc = toint(C.pipe(fds)) + if rc ~= 0 then + local e = get_errno() + return nil, nil, strerror(e) + end + return toint(fds[0]), toint(fds[1]), nil end local function fsync_fd(fd) - local rc = toint(C.fsync(fd)) - if rc ~= 0 then - local e = get_errno() - return false, strerror(e) - end - return true, nil + local rc = toint(C.fsync(fd)) + if rc ~= 0 then + local e = get_errno() + return false, strerror(e) + end + return true, nil end local function rename_file(oldpath, newpath) - local c_old = ffi.new("char[?]", #oldpath + 1) - ffi.copy(c_old, oldpath) - local c_new = ffi.new("char[?]", #newpath + 1) - ffi.copy(c_new, newpath) - - local rc = toint(C.rename(c_old, c_new)) - if rc ~= 0 then - local e = get_errno() - return false, strerror(e) - end - return true, nil + local c_old = ffi.new('char[?]', #oldpath + 1) + ffi.copy(c_old, oldpath) + local c_new = ffi.new('char[?]', #newpath + 1) + ffi.copy(c_new, newpath) + + local rc = toint(C.rename(c_old, c_new)) + if rc ~= 0 then + local e = get_errno() + return false, strerror(e) + end + return true, nil end local function unlink_file(path) - local c_path = ffi.new("char[?]", #path + 1) - ffi.copy(c_path, path) - - local rc = toint(C.unlink(c_path)) - if rc ~= 0 then - local e = get_errno() - return false, strerror(e) - end - return true, nil + local c_path = ffi.new('char[?]', #path + 1) + ffi.copy(c_path, path) + + local rc = toint(C.unlink(c_path)) + if rc ~= 0 then + local e = get_errno() + return false, strerror(e) + end + return true, nil end -- Mode and permission tables mirror the old file.lua behaviour, @@ -282,98 +286,98 @@ end ---@type table local modes = { - r = O_RDONLY, - w = bit.bor(O_WRONLY, O_CREAT, O_TRUNC), - a = bit.bor(O_WRONLY, O_CREAT, O_APPEND), - ["r+"] = O_RDWR, - ["w+"] = bit.bor(O_RDWR, O_CREAT, O_TRUNC), - ["a+"] = bit.bor(O_RDWR, O_CREAT, O_APPEND), + r = O_RDONLY, + w = bit.bor(O_WRONLY, O_CREAT, O_TRUNC), + a = bit.bor(O_WRONLY, O_CREAT, O_APPEND), + ['r+'] = O_RDWR, + ['w+'] = bit.bor(O_RDWR, O_CREAT, O_TRUNC), + ['a+'] = bit.bor(O_RDWR, O_CREAT, O_APPEND), } do - local binary_modes = {} - for k, v in pairs(modes) do - binary_modes[k .. "b"] = v - end - for k, v in pairs(binary_modes) do - modes[k] = v - end + local binary_modes = {} + for k, v in pairs(modes) do + binary_modes[k .. 'b'] = v + end + for k, v in pairs(binary_modes) do + modes[k] = v + end end ---@type table local permissions = {} -permissions["rw-r--r--"] = bit.bor(S_IRUSR, S_IWUSR, S_IRGRP, S_IROTH) -permissions["rw-rw-rw-"] = bit.bor(permissions["rw-r--r--"], S_IWGRP, S_IWOTH) +permissions['rw-r--r--'] = bit.bor(S_IRUSR, S_IWUSR, S_IRGRP, S_IROTH) +permissions['rw-rw-rw-'] = bit.bor(permissions['rw-r--r--'], S_IWGRP, S_IWOTH) local function open_file(path, mode, perms) - mode = mode or "r" - local flags = modes[mode] - if not flags then - return nil, "invalid mode: " .. tostring(mode) - end - - local p - if perms == nil then - p = permissions["rw-rw-rw-"] - elseif type(perms) == "string" then - p = permissions[perms] or perms - else - p = perms - end - - return open_fd(path, flags, p) + mode = mode or 'r' + local flags = modes[mode] + if not flags then + return nil, 'invalid mode: ' .. tostring(mode) + end + + local p + if perms == nil then + p = permissions['rw-rw-rw-'] + elseif type(perms) == 'string' then + p = permissions[perms] or perms + else + p = perms + end + + return open_fd(path, flags, p) end local function mktemp(prefix, perms) - -- Normalise perms: nil -> default, string -> lookup in permissions table. - if perms == nil then - perms = permissions["rw-r--r--"] - elseif type(perms) == "string" then - perms = permissions[perms] or perms - end - - -- Caller is responsible for seeding math.random appropriately. - local start = math.random(1e7) - local tmpnam, fd, err - - for i = start, start + 10 do - tmpnam = prefix .. "." .. i - fd, err = open_fd(tmpnam, bit.bor(O_CREAT, O_RDWR, O_EXCL), perms) - if fd then - return fd, tmpnam - end - end - - return nil, ("failed to create temporary file %s: %s"):format( - tostring(tmpnam), - tostring(err) - ) + -- Normalise perms: nil -> default, string -> lookup in permissions table. + if perms == nil then + perms = permissions['rw-r--r--'] + elseif type(perms) == 'string' then + perms = permissions[perms] or perms + end + + -- Caller is responsible for seeding math.random appropriately. + local start = math.random(1e7) + local tmpnam, fd, err + + for i = start, start + 10 do + tmpnam = prefix .. '.' .. i + fd, err = open_fd(tmpnam, bit.bor(O_CREAT, O_RDWR, O_EXCL), perms) + if fd then + return fd, tmpnam + end + end + + return nil, ('failed to create temporary file %s: %s'):format( + tostring(tmpnam), + tostring(err) + ) end local function decode_access(flags) - local acc = bit.band(flags, O_ACCMODE) - if acc == O_RDONLY then - return true, false - elseif acc == O_WRONLY then - return false, true - elseif acc == O_RDWR then - return true, true - end - -- Fallback: if we cannot interpret, assume read/write. - return true, true + local acc = bit.band(flags, O_ACCMODE) + if acc == O_RDONLY then + return true, false + elseif acc == O_WRONLY then + return false, true + elseif acc == O_RDWR then + return true, true + end + -- Fallback: if we cannot interpret, assume read/write. + return true, true end local function ignore_sigpipe() - -- Best-effort ignore of SIGPIPE. If this fails, we treat it as non-fatal. - local handler_t = ffi.typeof("sighandler_t") - local SIG_IGN = ffi.cast(handler_t, 1) - - local old = C.signal(SIGPIPE, SIG_IGN) - if old == nil then - local e = get_errno() - return false, strerror(e) - end - return true, nil + -- Best-effort ignore of SIGPIPE. If this fails, we treat it as non-fatal. + local handler_t = ffi.typeof('sighandler_t') + local SIG_IGN = ffi.cast(handler_t, 1) + + local old = C.signal(SIGPIPE, SIG_IGN) + if old == nil then + local e = get_errno() + return false, strerror(e) + end + return true, nil end ---------------------------------------------------------------------- @@ -381,98 +385,99 @@ end ---------------------------------------------------------------------- local function make_sockaddr_un(path) - local sa = ffi.new("struct sockaddr_un") - sa.sun_family = AF_UNIX - - local maxlen = 108 - 1 - local p = path - if #p > maxlen then - p = p:sub(1, maxlen) - end - ffi.fill(sa.sun_path, 108) - ffi.copy(sa.sun_path, p) - - -- Full struct size is fine for bind/connect. - local len = ffi.sizeof("struct sockaddr_un") - return sa, len + local sa = ffi.new('struct sockaddr_un') + ---@cast sa sockaddr_un_cdata + sa.sun_family = AF_UNIX + + local maxlen = 108 - 1 + local p = path + if #p > maxlen then + p = p:sub(1, maxlen) + end + ffi.fill(sa.sun_path, 108) + ffi.copy(sa.sun_path, p) + + -- Full struct size is fine for bind/connect. + local len = ffi.sizeof('struct sockaddr_un') + return sa, len end local function socket_fd(domain, stype, protocol) - local fd = toint(C.socket(domain, stype, protocol or 0)) - if fd < 0 then - local e = get_errno() - return nil, strerror(e), e - end - return fd, nil, nil + local fd = toint(C.socket(domain, stype, protocol or 0)) + if fd < 0 then + local e = get_errno() + return nil, strerror(e), e + end + return fd, nil, nil end local function bind_fd(fd, sa) - -- For now, sa is expected to be a UNIX-domain path string. - if type(sa) ~= "string" then - return false, "unsupported sockaddr representation", nil - end - local c_sa, len = make_sockaddr_un(sa) - local rc = toint(C.bind(fd, ffi.cast("struct sockaddr *", c_sa), len)) - if rc ~= 0 then - local e = get_errno() - return false, strerror(e), e - end - return true, nil, nil + -- For now, sa is expected to be a UNIX-domain path string. + if type(sa) ~= 'string' then + return false, 'unsupported sockaddr representation', nil + end + local c_sa, len = make_sockaddr_un(sa) + local rc = toint(C.bind(fd, ffi.cast('struct sockaddr *', c_sa), len)) + if rc ~= 0 then + local e = get_errno() + return false, strerror(e), e + end + return true, nil, nil end local function listen_fd(fd) - local rc = toint(C.listen(fd, SOMAXCONN)) - if rc ~= 0 then - local e = get_errno() - return false, strerror(e), e - end - return true, nil, nil + local rc = toint(C.listen(fd, SOMAXCONN)) + if rc ~= 0 then + local e = get_errno() + return false, strerror(e), e + end + return true, nil, nil end --- accept(fd) -> newfd|nil, err|nil, again:boolean local function accept_fd(fd) - local new_fd = toint(C.accept(fd, nil, nil)) - if new_fd < 0 then - local e = get_errno() - if e == EAGAIN or e == EWOULDBLOCK then - return nil, nil, true - end - return nil, strerror(e), false - end - return new_fd, nil, false + local new_fd = toint(C.accept(fd, nil, nil)) + if new_fd < 0 then + local e = get_errno() + if e == EAGAIN or e == EWOULDBLOCK then + return nil, nil, true + end + return nil, strerror(e), false + end + return new_fd, nil, false end --- connect_start(fd, sa) -> ok|nil, err|nil, inprogress:boolean local function connect_start_fd(fd, sa) - if type(sa) ~= "string" then - return nil, "unsupported sockaddr representation", false - end - local c_sa, len = make_sockaddr_un(sa) - local rc = toint(C.connect(fd, ffi.cast("struct sockaddr *", c_sa), len)) - if rc == 0 then - return true, nil, false - end - local e = get_errno() - if e == EINPROGRESS then - return nil, nil, true - end - return nil, strerror(e), false + if type(sa) ~= 'string' then + return nil, 'unsupported sockaddr representation', false + end + local c_sa, len = make_sockaddr_un(sa) + local rc = toint(C.connect(fd, ffi.cast('struct sockaddr *', c_sa), len)) + if rc == 0 then + return true, nil, false + end + local e = get_errno() + if e == EINPROGRESS then + return nil, nil, true + end + return nil, strerror(e), false end --- connect_finish(fd) -> ok:boolean, err|nil local function connect_finish_fd(fd) - local errval = ffi.new("int[1]") - local sz = ffi.new("socklen_t[1]", ffi.sizeof("int")) - local rc = toint(C.getsockopt(fd, SOL_SOCKET, SO_ERROR, errval, sz)) - if rc ~= 0 then - local e = get_errno() - return false, strerror(e) - end - local soerr = errval[0] - if soerr == 0 then - return true, nil - end - return false, strerror(soerr) + local errval = ffi.new('int[1]') + local sz = ffi.new('socklen_t[1]', ffi.sizeof('int')) + local rc = toint(C.getsockopt(fd, SOL_SOCKET, SO_ERROR, errval, sz)) + if rc ~= 0 then + local e = get_errno() + return false, strerror(e) + end + local soerr = errval[0] + if soerr == 0 then + return true, nil + end + return false, strerror(soerr) end ---------------------------------------------------------------------- @@ -480,40 +485,40 @@ end ---------------------------------------------------------------------- local function is_supported() - return true + return true end local ops = { - set_nonblock = set_nonblock, - read = read_fd, - write = write_fd, - seek = seek_fd, - close = close_fd, - - open_file = open_file, - pipe = pipe_fd, - mktemp = mktemp, - fsync = fsync_fd, - rename = rename_file, - unlink = unlink_file, - decode_access = decode_access, - ignore_sigpipe = ignore_sigpipe, - - -- socket ops - socket = socket_fd, - bind = bind_fd, - listen = listen_fd, - accept = accept_fd, - connect_start = connect_start_fd, - connect_finish = connect_finish_fd, - - modes = modes, - permissions = permissions, - - AF_UNIX = AF_UNIX, - SOCK_STREAM = SOCK_STREAM, - - is_supported = is_supported, + set_nonblock = set_nonblock, + read = read_fd, + write = write_fd, + seek = seek_fd, + close = close_fd, + + open_file = open_file, + pipe = pipe_fd, + mktemp = mktemp, + fsync = fsync_fd, + rename = rename_file, + unlink = unlink_file, + decode_access = decode_access, + ignore_sigpipe = ignore_sigpipe, + + -- socket ops + socket = socket_fd, + bind = bind_fd, + listen = listen_fd, + accept = accept_fd, + connect_start = connect_start_fd, + connect_finish = connect_finish_fd, + + modes = modes, + permissions = permissions, + + AF_UNIX = AF_UNIX, + SOCK_STREAM = SOCK_STREAM, + + is_supported = is_supported, } return core.build_backend(ops) diff --git a/src/fibers/io/fd_backend/nixio.lua b/src/fibers/io/fd_backend/nixio.lua index d507409..fa5bcaf 100644 --- a/src/fibers/io/fd_backend/nixio.lua +++ b/src/fibers/io/fd_backend/nixio.lua @@ -11,28 +11,28 @@ local fs = require 'nixio.fs' local const = nixio.const or {} -local EAGAIN = const.EAGAIN or 11 +local EAGAIN = const.EAGAIN or 11 local EWOULDBLOCK = const.EWOULDBLOCK or EAGAIN local EINPROGRESS = const.EINPROGRESS or 115 -local EALREADY = const.EALREADY or 114 +local EALREADY = const.EALREADY or 114 -- Where available, reuse nixio’s numeric constants so callers see -- sensible AF_* / SOCK_* values. Fall back to standard-ish defaults. -local AF_UNIX = const.AF_UNIX or 1 +local AF_UNIX = const.AF_UNIX or 1 local AF_INET = const.AF_INET local AF_INET6 = const.AF_INET6 local SOCK_STREAM = const.SOCK_STREAM or 1 -local SOCK_DGRAM = const.SOCK_DGRAM or 2 +local SOCK_DGRAM = const.SOCK_DGRAM or 2 local function errno_msg(default, eno) - if not eno or eno == 0 then - return default - end - local s = nixio.strerror(eno) - if not s or s == "" then - return default .. " (errno " .. tostring(eno) .. ")" - end - return s + if not eno or eno == 0 then + return default + end + local s = nixio.strerror(eno) + if not s or s == '' then + return default .. ' (errno ' .. tostring(eno) .. ')' + end + return s end ---------------------------------------------------------------------- @@ -41,118 +41,118 @@ end -- fd here is a nixio.File or nixio.Socket local function set_nonblock(fd) - if fd and fd.setblocking then - local ok, eno = fd:setblocking(false) - if ok ~= nil and ok ~= false then - return true, nil, eno - end - eno = eno or nixio.errno() - return false, errno_msg("setblocking(false) failed", eno), eno - end - -- If there is no setblocking, treat as already non-blocking. - return true, nil, nil + if fd and fd.setblocking then + local ok, eno = fd:setblocking(false) + if ok ~= nil and ok ~= false then + return true, nil, eno + end + eno = eno or nixio.errno() + return false, errno_msg('setblocking(false) failed', eno), eno + end + -- If there is no setblocking, treat as already non-blocking. + return true, nil, nil end local function read_fd(fd, max) - if not fd then - return nil, "closed" - end - - max = max or const.buffersize or 8192 - if max <= 0 then - return "", nil - end - - -- nixio.File:read / Socket:read both follow the same style: - -- data (success/EOF) - -- nil, msg, errno (error) - local data, msg, eno = fd:read(max) - - if data ~= nil then - -- data may be "" at EOF; that is acceptable to callers. - return data, nil - end - - eno = eno or nixio.errno() - - if eno == EAGAIN or eno == EWOULDBLOCK then - -- Would block, signal “not ready yet”. - return nil, nil - end - - if not eno or eno == 0 then - -- Treat as EOF. - return "", nil - end - - return nil, errno_msg(msg or "read failed", eno) + if not fd then + return nil, 'closed' + end + + max = max or const.buffersize or 8192 + if max <= 0 then + return '', nil + end + + -- nixio.File:read / Socket:read both follow the same style: + -- data (success/EOF) + -- nil, msg, errno (error) + local data, msg, eno = fd:read(max) + + if data ~= nil then + -- data may be "" at EOF; that is acceptable to callers. + return data, nil + end + + eno = eno or nixio.errno() + + if eno == EAGAIN or eno == EWOULDBLOCK then + -- Would block, signal “not ready yet”. + return nil, nil + end + + if not eno or eno == 0 then + -- Treat as EOF. + return '', nil + end + + return nil, errno_msg(msg or 'read failed', eno) end local function write_fd(fd, str, len) - if not fd then - return nil, "closed" - end + if not fd then + return nil, 'closed' + end - len = len or #str - if len == 0 then - return 0, nil - end + len = len or #str + if len == 0 then + return 0, nil + end - -- For files: File.write(buf, offset, length) - -- For sockets: Socket.send / write(buf, offset, length) – same shape. - local n, msg, eno = fd:write(str, 0, len) + -- For files: File.write(buf, offset, length) + -- For sockets: Socket.send / write(buf, offset, length) – same shape. + local n, msg, eno = fd:write(str, 0, len) - if n ~= nil then - return n, nil - end + if n ~= nil then + return n, nil + end - eno = eno or nixio.errno() + eno = eno or nixio.errno() - if eno == EAGAIN or eno == EWOULDBLOCK then - -- Would block. - return nil, nil - end + if eno == EAGAIN or eno == EWOULDBLOCK then + -- Would block. + return nil, nil + end - return nil, errno_msg(msg or "write failed", eno) + return nil, errno_msg(msg or 'write failed', eno) end local SEEK_MAP = { - set = "set", - cur = "cur", - ["end"] = "end", + set = 'set', + cur = 'cur', + ['end'] = 'end', } local function seek_fd(fd, whence, off) - if not fd then - return nil, "closed" - end - - whence = SEEK_MAP[whence] or whence or "cur" - off = off or 0 - - if not fd.seek then - return nil, "seek not supported on this descriptor" - end - - local pos, msg, eno = fd:seek(off, whence) - if pos == nil then - eno = eno or nixio.errno() - return nil, errno_msg(msg or "seek failed", eno) - end - return pos, nil + if not fd then + return nil, 'closed' + end + + whence = SEEK_MAP[whence] or whence or 'cur' + off = off or 0 + + if not fd.seek then + return nil, 'seek not supported on this descriptor' + end + + local pos, msg, eno = fd:seek(off, whence) + if pos == nil then + eno = eno or nixio.errno() + return nil, errno_msg(msg or 'seek failed', eno) + end + return pos, nil end local function close_fd(fd) - if not fd then - return true, nil - end - - local ok, msg, eno = fd:close() - if ok == nil or ok == false then - eno = eno or nixio.errno() - return false, errno_msg(msg or "close failed", eno) - end - return true, nil + if not fd then + return true, nil + end + + local ok, msg, eno = fd:close() + if ok == nil or ok == false then + eno = eno or nixio.errno() + return false, errno_msg(msg or 'close failed', eno) + end + return true, nil end ---------------------------------------------------------------------- @@ -161,84 +161,84 @@ end -- For this backend we rely on nixio.open’s mode strings. local function open_file(path, mode, perms) - mode = mode or "r" + mode = mode or 'r' - local f, eno = nixio.open(path, mode, perms) - if not f then - return nil, errno_msg("open failed", eno) - end - return f, nil + local f, eno = nixio.open(path, mode, perms) + if not f then + return nil, errno_msg('open failed', eno) + end + return f, nil end local function pipe_fds() - local r, w, eno = nixio.pipe() - if not r then - return nil, nil, errno_msg("pipe failed", eno) - end - return r, w, nil + local r, w, eno = nixio.pipe() + if not r then + return nil, nil, errno_msg('pipe failed', eno) + end + return r, w, nil end local function mktemp(prefix, perms) - -- Very simple mktemp: we try a few names and rely on low collision - -- probability. This mirrors the earlier “simple” backend you tested. - local start = math.random(1e7) - local last_err - - for i = start, start + 10 do - local tmpnam = prefix .. "." .. i - local f, eno = nixio.open(tmpnam, "w+", perms) - if f then - return f, tmpnam - end - last_err = errno_msg("mktemp open failed", eno) - end - - return nil, last_err or "mktemp: failed to create temporary file" + -- Very simple mktemp: we try a few names and rely on low collision + -- probability. This mirrors the earlier “simple” backend you tested. + local start = math.random(1e7) + local last_err + + for i = start, start + 10 do + local tmpnam = prefix .. '.' .. i + local f, eno = nixio.open(tmpnam, 'w+', perms) + if f then + return f, tmpnam + end + last_err = errno_msg('mktemp open failed', eno) + end + + return nil, last_err or 'mktemp: failed to create temporary file' end local function fsync_fd(fd) - if not fd or not fd.sync then - return true, nil - end - local ok, msg, eno = fd:sync(false) - if ok == nil or ok == false then - eno = eno or nixio.errno() - return false, errno_msg(msg or "fsync failed", eno) - end - return true, nil + if not fd or not fd.sync then + return true, nil + end + local ok, msg, eno = fd:sync(false) + if ok == nil or ok == false then + eno = eno or nixio.errno() + return false, errno_msg(msg or 'fsync failed', eno) + end + return true, nil end local function rename_file(oldpath, newpath) - local ok, msg, eno = fs.rename(oldpath, newpath) - if ok == nil or ok == false then - return false, errno_msg(msg or "rename failed", eno) - end - return true, nil + local ok, msg, eno = fs.rename(oldpath, newpath) + if ok == nil or ok == false then + return false, errno_msg(msg or 'rename failed', eno) + end + return true, nil end local function unlink_file(path) - local ok, msg, eno = fs.unlink(path) - if ok == nil or ok == false then - return false, errno_msg(msg or "unlink failed", eno) - end - return true, nil + local ok, msg, eno = fs.unlink(path) + if ok == nil or ok == false then + return false, errno_msg(msg or 'unlink failed', eno) + end + return true, nil end -- For this backend, integer open flags are not used; when decode_access -- is called we can conservatively assume read/write. local function decode_access(_) - return true, true + return true, true end local function ignore_sigpipe() - -- Best-effort ignore of SIGPIPE. - if nixio.signal and nixio.SIGPIPE then - local ok, eno = nixio.signal(nixio.SIGPIPE, "ign") - if ok == nil or ok == false then - return false, errno_msg("signal(SIGPIPE) failed", eno) - end - end - return true, nil + -- Best-effort ignore of SIGPIPE. + if nixio.signal and nixio.SIGPIPE then + local ok, eno = nixio.signal(nixio.SIGPIPE, 'ign') + if ok == nil or ok == false then + return false, errno_msg('signal(SIGPIPE) failed', eno) + end + end + return true, nil end ---------------------------------------------------------------------- @@ -246,148 +246,148 @@ end ---------------------------------------------------------------------- local function domain_to_str(domain) - if domain == AF_UNIX then - return "unix" - end - if AF_INET and domain == AF_INET then - return "inet" - end - if AF_INET6 and domain == AF_INET6 then - return "inet6" - end - error("fd_backend.nixio: unsupported address family: " .. tostring(domain)) + if domain == AF_UNIX then + return 'unix' + end + if AF_INET and domain == AF_INET then + return 'inet' + end + if AF_INET6 and domain == AF_INET6 then + return 'inet6' + end + error('fd_backend.nixio: unsupported address family: ' .. tostring(domain)) end local function stype_to_str(stype) - if stype == SOCK_STREAM then - return "stream" - end - if SOCK_DGRAM and stype == SOCK_DGRAM then - return "dgram" - end - error("fd_backend.nixio: unsupported socket type: " .. tostring(stype)) + if stype == SOCK_STREAM then + return 'stream' + end + if SOCK_DGRAM and stype == SOCK_DGRAM then + return 'dgram' + end + error('fd_backend.nixio: unsupported socket type: ' .. tostring(stype)) end --- socket(domain, stype, protocol) -> fd|nil, err|nil, eno|nil local function socket_fd(domain, stype, _) - local d = domain_to_str(domain) - local t = stype_to_str(stype) - - local s, eno = nixio.socket(d, t) - if not s then - return nil, errno_msg("socket failed", eno), eno - end - -- Returned “fd” is a nixio.Socket object. - return s, nil, nil + local d = domain_to_str(domain) + local t = stype_to_str(stype) + + local s, eno = nixio.socket(d, t) + if not s then + return nil, errno_msg('socket failed', eno), eno + end + -- Returned “fd” is a nixio.Socket object. + return s, nil, nil end --- bind(fd, sa) where fd is nixio.Socket; sa is e.g. UNIX path string. local function bind_fd(fd, sa) - if not fd then - return false, "closed socket", nil - end + if not fd then + return false, 'closed socket', nil + end - local ok, msg, eno + local ok, msg, eno - if type(sa) == "string" then - -- For AF_UNIX, host is path, port is ignored. We pass 0 as a dummy. - ok, msg, eno = fd:bind(sa, 0) - else - return false, "unsupported sockaddr representation", nil - end + if type(sa) == 'string' then + -- For AF_UNIX, host is path, port is ignored. We pass 0 as a dummy. + ok, msg, eno = fd:bind(sa, 0) + else + return false, 'unsupported sockaddr representation', nil + end - if ok == nil or ok == false then - eno = eno or nixio.errno() - return false, errno_msg(msg or "bind failed", eno), eno - end + if ok == nil or ok == false then + eno = eno or nixio.errno() + return false, errno_msg(msg or 'bind failed', eno), eno + end - return true, nil, nil + return true, nil, nil end local function listen_fd(fd) - if not fd then - return false, "closed socket", nil - end - - local backlog = const.SOMAXCONN or 128 - local ok, msg, eno = fd:listen(backlog) - if ok == nil or ok == false then - eno = eno or nixio.errno() - return false, errno_msg(msg or "listen failed", eno), eno - end - return true, nil, nil + if not fd then + return false, 'closed socket', nil + end + + local backlog = const.SOMAXCONN or 128 + local ok, msg, eno = fd:listen(backlog) + if ok == nil or ok == false then + eno = eno or nixio.errno() + return false, errno_msg(msg or 'listen failed', eno), eno + end + return true, nil, nil end --- accept(fd) -> newfd|nil, err|nil, again:boolean local function accept_fd(fd) - if not fd then - return nil, "closed socket", false - end - - -- nixio.Socket.accept() -> newsock, host, port | nil, msg, errno - local newsock, _, _, msg, eno = fd:accept() - if newsock then - return newsock, nil, false - end - - eno = eno or nixio.errno() - if eno == EAGAIN or eno == EWOULDBLOCK then - return nil, nil, true - end - - return nil, errno_msg(msg or "accept failed", eno), false + if not fd then + return nil, 'closed socket', false + end + + -- nixio.Socket.accept() -> newsock, host, port | nil, msg, errno + local newsock, _, _, msg, eno = fd:accept() + if newsock then + return newsock, nil, false + end + + eno = eno or nixio.errno() + if eno == EAGAIN or eno == EWOULDBLOCK then + return nil, nil, true + end + + return nil, errno_msg(msg or 'accept failed', eno), false end --- connect_start(fd, sa) -> ok|nil, err|nil, inprogress:boolean local function connect_start_fd(fd, sa) - if not fd then - return nil, "closed socket", false - end - - local ok, msg, eno - - if type(sa) == "string" then - -- For AF_UNIX, host is path, port is ignored. - ok, msg, eno = fd:connect(sa, 0) - else - return nil, "unsupported sockaddr representation", false - end - - if ok then - return true, nil, false - end - - eno = eno or nixio.errno() - if eno == EINPROGRESS or eno == EALREADY or eno == EAGAIN then - -- Non-blocking connect in progress. - return nil, nil, true - end - - return nil, errno_msg(msg or "connect failed", eno), false + if not fd then + return nil, 'closed socket', false + end + + local ok, msg, eno + + if type(sa) == 'string' then + -- For AF_UNIX, host is path, port is ignored. + ok, msg, eno = fd:connect(sa, 0) + else + return nil, 'unsupported sockaddr representation', false + end + + if ok then + return true, nil, false + end + + eno = eno or nixio.errno() + if eno == EINPROGRESS or eno == EALREADY or eno == EAGAIN then + -- Non-blocking connect in progress. + return nil, nil, true + end + + return nil, errno_msg(msg or 'connect failed', eno), false end --- connect_finish(fd) -> ok:boolean, err|nil local function connect_finish_fd(fd) - if not fd then - return false, "closed socket" - end - - if not fd.getopt then - -- Fallback: if we cannot inspect SO_ERROR, assume success. - return true, nil - end - - local soerr, msg, eno = fd:getopt("socket", "error") - if soerr == nil then - eno = eno or nixio.errno() - return false, errno_msg(msg or "getsockopt(SO_ERROR) failed", eno) - end - - if soerr == 0 then - return true, nil - end - - return false, errno_msg("connect error", soerr) + if not fd then + return false, 'closed socket' + end + + if not fd.getopt then + -- Fallback: if we cannot inspect SO_ERROR, assume success. + return true, nil + end + + local soerr, msg, eno = fd:getopt('socket', 'error') + if soerr == nil then + eno = eno or nixio.errno() + return false, errno_msg(msg or 'getsockopt(SO_ERROR) failed', eno) + end + + if soerr == 0 then + return true, nil + end + + return false, errno_msg('connect error', soerr) end ---------------------------------------------------------------------- @@ -395,8 +395,8 @@ end ---------------------------------------------------------------------- local function is_supported() - -- If this module loaded, nixio was already required successfully. - return true + -- If this module loaded, nixio was already required successfully. + return true end ---------------------------------------------------------------------- @@ -404,39 +404,39 @@ end ---------------------------------------------------------------------- local ops = { - -- Core file/socket descriptor ops - set_nonblock = set_nonblock, - read = read_fd, - write = write_fd, - seek = seek_fd, - close = close_fd, - - -- File-level helpers - open_file = open_file, - pipe = pipe_fds, - mktemp = mktemp, - fsync = fsync_fd, - rename = rename_file, - unlink = unlink_file, - decode_access = decode_access, - ignore_sigpipe = ignore_sigpipe, - - -- Socket-level helpers - socket = socket_fd, - bind = bind_fd, - listen = listen_fd, - accept = accept_fd, - connect_start = connect_start_fd, - connect_finish = connect_finish_fd, - - -- Metadata for callers (fibers.io.socket re-exports these) - modes = {}, -- not used for nixio; kept for compatibility - permissions = {}, - - AF_UNIX = AF_UNIX, - SOCK_STREAM = SOCK_STREAM, - - is_supported = is_supported, + -- Core file/socket descriptor ops + set_nonblock = set_nonblock, + read = read_fd, + write = write_fd, + seek = seek_fd, + close = close_fd, + + -- File-level helpers + open_file = open_file, + pipe = pipe_fds, + mktemp = mktemp, + fsync = fsync_fd, + rename = rename_file, + unlink = unlink_file, + decode_access = decode_access, + ignore_sigpipe = ignore_sigpipe, + + -- Socket-level helpers + socket = socket_fd, + bind = bind_fd, + listen = listen_fd, + accept = accept_fd, + connect_start = connect_start_fd, + connect_finish = connect_finish_fd, + + -- Metadata for callers (fibers.io.socket re-exports these) + modes = {}, -- not used for nixio; kept for compatibility + permissions = {}, + + AF_UNIX = AF_UNIX, + SOCK_STREAM = SOCK_STREAM, + + is_supported = is_supported, } return core.build_backend(ops) diff --git a/src/fibers/io/fd_backend/posix.lua b/src/fibers/io/fd_backend/posix.lua index 982e526..5f08232 100644 --- a/src/fibers/io/fd_backend/posix.lua +++ b/src/fibers/io/fd_backend/posix.lua @@ -5,26 +5,26 @@ -- ---@module 'fibers.io.fd_backend.posix' -local core = require 'fibers.io.fd_backend.core' - -local unistd = require 'posix.unistd' -local stdio = require 'posix.stdio' -local fcntl = require 'posix.fcntl' -local pstat = require 'posix.sys.stat' -local errno = require 'posix.errno' -local psig = require 'posix.signal' +local core = require 'fibers.io.fd_backend.core' + +local unistd = require 'posix.unistd' +local stdio = require 'posix.stdio' +local fcntl = require 'posix.fcntl' +local pstat = require 'posix.sys.stat' +local errno = require 'posix.errno' +local psig = require 'posix.signal' local socket_mod = require 'posix.sys.socket' -local bit = rawget(_G, "bit") or require 'bit32' +local bit = rawget(_G, 'bit') or require 'bit32' local function errno_msg(prefix, err, eno) - if err and err ~= "" then - return err - end - if eno then - return ("%s (errno %d)"):format(prefix, eno) - end - return prefix + if err and err ~= '' then + return err + end + if eno then + return ('%s (errno %d)'):format(prefix, eno) + end + return prefix end ---------------------------------------------------------------------- @@ -32,60 +32,60 @@ end ---------------------------------------------------------------------- local function set_nonblock(fd) - local flags, err, eno = fcntl.fcntl(fd, fcntl.F_GETFL) - if flags == nil then - return false, errno_msg("fcntl(F_GETFL)", err, eno), eno - end - local newflags = bit.bor(flags, fcntl.O_NONBLOCK) - local ok, err2, eno2 = fcntl.fcntl(fd, fcntl.F_SETFL, newflags) - if ok == nil then - return false, errno_msg("fcntl(F_SETFL)", err2, eno2), eno2 - end - return true, nil, nil + local flags, err, eno = fcntl.fcntl(fd, fcntl.F_GETFL) + if flags == nil then + return false, errno_msg('fcntl(F_GETFL)', err, eno), eno + end + local newflags = bit.bor(flags, fcntl.O_NONBLOCK) + local ok, err2, eno2 = fcntl.fcntl(fd, fcntl.F_SETFL, newflags) + if ok == nil then + return false, errno_msg('fcntl(F_SETFL)', err2, eno2), eno2 + end + return true, nil, nil end local function read_fd(fd, max) - local s, err, eno = unistd.read(fd, max) - if s == nil then - if eno == errno.EAGAIN or eno == errno.EWOULDBLOCK then - return nil, nil - end - return nil, errno_msg("read failed", err, eno) - end - return s, nil + local s, err, eno = unistd.read(fd, max) + if s == nil then + if eno == errno.EAGAIN or eno == errno.EWOULDBLOCK then + return nil, nil + end + return nil, errno_msg('read failed', err, eno) + end + return s, nil end local function write_fd(fd, str, _) - local n, err, eno = unistd.write(fd, str) - if n == nil then - if eno == errno.EAGAIN or eno == errno.EWOULDBLOCK then - return nil, nil - end - return nil, errno_msg("write failed", err, eno) - end - return n, nil + local n, err, eno = unistd.write(fd, str) + if n == nil then + if eno == errno.EAGAIN or eno == errno.EWOULDBLOCK then + return nil, nil + end + return nil, errno_msg('write failed', err, eno) + end + return n, nil end -local SEEK = { set = unistd.SEEK_SET, cur = unistd.SEEK_CUR, ["end"] = unistd.SEEK_END } +local SEEK = { set = unistd.SEEK_SET, cur = unistd.SEEK_CUR, ['end'] = unistd.SEEK_END } local function seek_fd(fd, whence, off) - local w = SEEK[whence] - if not w then - return nil, "bad whence: " .. tostring(whence) - end - local pos, err, eno = unistd.lseek(fd, off, w) - if pos == nil then - return nil, errno_msg("lseek failed", err, eno) - end - return pos, nil + local w = SEEK[whence] + if not w then + return nil, 'bad whence: ' .. tostring(whence) + end + local pos, err, eno = unistd.lseek(fd, off, w) + if pos == nil then + return nil, errno_msg('lseek failed', err, eno) + end + return pos, nil end local function close_fd(fd) - local ok, err, eno = unistd.close(fd) - if ok == nil then - return false, errno_msg("close failed", err, eno) - end - return true, nil + local ok, err, eno = unistd.close(fd) + if ok == nil then + return false, errno_msg('close failed', err, eno) + end + return true, nil end ---------------------------------------------------------------------- @@ -95,143 +95,143 @@ end -- Mode and permission tables as before, but using POSIX constants. local modes = { - r = fcntl.O_RDONLY, - w = bit.bor(fcntl.O_WRONLY, fcntl.O_CREAT, fcntl.O_TRUNC), - a = bit.bor(fcntl.O_WRONLY, fcntl.O_CREAT, fcntl.O_APPEND), - ["r+"] = fcntl.O_RDWR, - ["w+"] = bit.bor(fcntl.O_RDWR, fcntl.O_CREAT, fcntl.O_TRUNC), - ["a+"] = bit.bor(fcntl.O_RDWR, fcntl.O_CREAT, fcntl.O_APPEND), + r = fcntl.O_RDONLY, + w = bit.bor(fcntl.O_WRONLY, fcntl.O_CREAT, fcntl.O_TRUNC), + a = bit.bor(fcntl.O_WRONLY, fcntl.O_CREAT, fcntl.O_APPEND), + ['r+'] = fcntl.O_RDWR, + ['w+'] = bit.bor(fcntl.O_RDWR, fcntl.O_CREAT, fcntl.O_TRUNC), + ['a+'] = bit.bor(fcntl.O_RDWR, fcntl.O_CREAT, fcntl.O_APPEND), } do - local binary_modes = {} - for k, v in pairs(modes) do - binary_modes[k .. "b"] = v - end - for k, v in pairs(binary_modes) do - modes[k] = v - end + local binary_modes = {} + for k, v in pairs(modes) do + binary_modes[k .. 'b'] = v + end + for k, v in pairs(binary_modes) do + modes[k] = v + end end local permissions = {} -permissions["rw-r--r--"] = bit.bor(pstat.S_IRUSR, pstat.S_IWUSR, pstat.S_IRGRP, pstat.S_IROTH) -permissions["rw-rw-rw-"] = bit.bor(permissions["rw-r--r--"], pstat.S_IWGRP, pstat.S_IWOTH) +permissions['rw-r--r--'] = bit.bor(pstat.S_IRUSR, pstat.S_IWUSR, pstat.S_IRGRP, pstat.S_IROTH) +permissions['rw-rw-rw-'] = bit.bor(permissions['rw-r--r--'], pstat.S_IWGRP, pstat.S_IWOTH) local function open_file(path, mode, perms) - mode = mode or "r" - local flags = modes[mode] - if not flags then - return nil, "invalid mode: " .. tostring(mode) - end - - local p - if perms == nil then - p = permissions["rw-rw-rw-"] - elseif type(perms) == "string" then - p = permissions[perms] or perms - else - p = perms - end - - local fd, err, eno = fcntl.open(path, flags, p) - if not fd then - return nil, errno_msg("open failed", err, eno) - end - return fd, nil + mode = mode or 'r' + local flags = modes[mode] + if not flags then + return nil, 'invalid mode: ' .. tostring(mode) + end + + local p + if perms == nil then + p = permissions['rw-rw-rw-'] + elseif type(perms) == 'string' then + p = permissions[perms] or perms + else + p = perms + end + + local fd, err, eno = fcntl.open(path, flags, p) + if not fd then + return nil, errno_msg('open failed', err, eno) + end + return fd, nil end local function pipe_fds() - local rd, wr, err, eno = unistd.pipe() - if not rd then - return nil, nil, errno_msg("pipe failed", err, eno) - end - return rd, wr, nil + local rd, wr, err, eno = unistd.pipe() + if not rd then + return nil, nil, errno_msg('pipe failed', err, eno) + end + return rd, wr, nil end local function mktemp(prefix, perms) - -- Normalise perms: nil -> default, string -> lookup in permissions table. - if perms == nil then - perms = permissions["rw-r--r--"] - elseif type(perms) == "string" then - perms = permissions[perms] or perms - end - - local start = math.random(1e7) - local tmpnam, fd, err, eno - - for i = start, start + 10 do - tmpnam = prefix .. "." .. i - fd, err, eno = fcntl.open( - tmpnam, - bit.bor(fcntl.O_CREAT, fcntl.O_RDWR, fcntl.O_EXCL), - perms - ) - if fd then - return fd, tmpnam - end - end - - return nil, ("failed to create temporary file %s: %s"):format( - tostring(tmpnam), - tostring(errno_msg("open", err, eno)) - ) + -- Normalise perms: nil -> default, string -> lookup in permissions table. + if perms == nil then + perms = permissions['rw-r--r--'] + elseif type(perms) == 'string' then + perms = permissions[perms] or perms + end + + local start = math.random(1e7) + local tmpnam, fd, err, eno + + for i = start, start + 10 do + tmpnam = prefix .. '.' .. i + fd, err, eno = fcntl.open( + tmpnam, + bit.bor(fcntl.O_CREAT, fcntl.O_RDWR, fcntl.O_EXCL), + perms + ) + if fd then + return fd, tmpnam + end + end + + return nil, ('failed to create temporary file %s: %s'):format( + tostring(tmpnam), + tostring(errno_msg('open', err, eno)) + ) end local function fsync_fd(fd) - local ok, err, eno = unistd.fsync(fd) - if ok == nil then - return false, errno_msg("fsync failed", err, eno) - end - return true, nil + local ok, err, eno = unistd.fsync(fd) + if ok == nil then + return false, errno_msg('fsync failed', err, eno) + end + return true, nil end local function rename_file(oldpath, newpath) - local ok, err, eno = stdio.rename(oldpath, newpath) - if ok == nil then - return false, errno_msg("rename failed", err, eno) - end - return true, nil + local ok, err, eno = stdio.rename(oldpath, newpath) + if ok == nil then + return false, errno_msg('rename failed', err, eno) + end + return true, nil end local function unlink_file(path) - local ok, err, eno = unistd.unlink(path) - if ok == nil then - return false, errno_msg("unlink failed", err, eno) - end - return true, nil + local ok, err, eno = unistd.unlink(path) + if ok == nil then + return false, errno_msg('unlink failed', err, eno) + end + return true, nil end local function decode_access(flags) - local o_wr = fcntl.O_WRONLY or 0 - local o_rdwr = fcntl.O_RDWR or 0 - local readable - if o_wr ~= 0 then - readable = (bit.band(flags, o_wr) ~= o_wr) - else - readable = true - end - - local writable = false - if o_wr ~= 0 and bit.band(flags, o_wr) ~= 0 then - writable = true - end - if o_rdwr ~= 0 and bit.band(flags, o_rdwr) ~= 0 then - writable = true - end - - if not readable and not writable then - readable = true - end - - return readable, writable + local o_wr = fcntl.O_WRONLY or 0 + local o_rdwr = fcntl.O_RDWR or 0 + local readable + if o_wr ~= 0 then + readable = (bit.band(flags, o_wr) ~= o_wr) + else + readable = true + end + + local writable = false + if o_wr ~= 0 and bit.band(flags, o_wr) ~= 0 then + writable = true + end + if o_rdwr ~= 0 and bit.band(flags, o_rdwr) ~= 0 then + writable = true + end + + if not readable and not writable then + readable = true + end + + return readable, writable end local function ignore_sigpipe() - local ok, err, eno = psig.signal(psig.SIGPIPE, psig.SIG_IGN) - if ok == nil then - return false, errno_msg("signal(SIGPIPE)", err, eno) - end - return true, nil + local ok, err, eno = psig.signal(psig.SIGPIPE, psig.SIG_IGN) + if ok == nil then + return false, errno_msg('signal(SIGPIPE)', err, eno) + end + return true, nil end ---------------------------------------------------------------------- @@ -244,11 +244,11 @@ end ---@param protocol? integer ---@return integer|nil fd, string|nil err, integer|nil eno local function socket_fd(domain, stype, protocol) - local fd, err, eno = socket_mod.socket(domain, stype, protocol or 0) - if fd == nil then - return nil, errno_msg("socket failed", err, eno), eno - end - return fd, nil, nil + local fd, err, eno = socket_mod.socket(domain, stype, protocol or 0) + if fd == nil then + return nil, errno_msg('socket failed', err, eno), eno + end + return fd, nil, nil end --- Bind a socket to an address token. @@ -258,51 +258,51 @@ end ---@param sa any ---@return boolean ok, string|nil err, integer|nil eno local function bind_fd(fd, sa) - local addr - if type(sa) == "string" then - addr = { family = socket_mod.AF_UNIX, path = sa } - elseif type(sa) == "table" then - addr = sa - else - return false, "unsupported sockaddr representation", nil - end - - local ok, err, eno = socket_mod.bind(fd, addr) - -- LuaPosix returns 0 on success, nil on error. - if ok == nil then - return false, errno_msg("bind failed", err, eno), eno - end - return true, nil, nil + local addr + if type(sa) == 'string' then + addr = { family = socket_mod.AF_UNIX, path = sa } + elseif type(sa) == 'table' then + addr = sa + else + return false, 'unsupported sockaddr representation', nil + end + + local ok, err, eno = socket_mod.bind(fd, addr) + -- LuaPosix returns 0 on success, nil on error. + if ok == nil then + return false, errno_msg('bind failed', err, eno), eno + end + return true, nil, nil end --- Put a listening socket into listen state. ---@param fd integer ---@return boolean ok, string|nil err, integer|nil eno local function listen_fd(fd) - local backlog = socket_mod.SOMAXCONN or 128 - local ok, err, eno = socket_mod.listen(fd, backlog) - if ok == nil then - return false, errno_msg("listen failed", err, eno), eno - end - return true, nil, nil + local backlog = socket_mod.SOMAXCONN or 128 + local ok, err, eno = socket_mod.listen(fd, backlog) + if ok == nil then + return false, errno_msg('listen failed', err, eno), eno + end + return true, nil, nil end --- accept(fd) -> newfd|nil, err|nil, again:boolean ---@param fd integer ---@return integer|nil newfd, string|nil err, boolean again local function accept_fd(fd) - -- LuaPosix: accept(fd) -> connfd, addr | nil, errmsg, errnum - local newfd, addr_or_err, errnum = socket_mod.accept(fd) - if newfd ~= nil then - return newfd, nil, false - end - - local eno = errnum - if eno == errno.EAGAIN or eno == errno.EWOULDBLOCK then - return nil, nil, true - end - - return nil, errno_msg("accept failed", addr_or_err, eno), false + -- LuaPosix: accept(fd) -> connfd, addr | nil, errmsg, errnum + local newfd, addr_or_err, errnum = socket_mod.accept(fd) + if newfd ~= nil then + return newfd, nil, false + end + + local eno = errnum + if eno == errno.EAGAIN or eno == errno.EWOULDBLOCK then + return nil, nil, true + end + + return nil, errno_msg('accept failed', addr_or_err, eno), false end --- Start a non-blocking connect. @@ -311,48 +311,48 @@ end ---@param sa any ---@return boolean|nil ok, string|nil err, boolean inprogress local function connect_start_fd(fd, sa) - local addr - if type(sa) == "string" then - addr = { family = socket_mod.AF_UNIX, path = sa } - elseif type(sa) == "table" then - addr = sa - else - return nil, "unsupported sockaddr representation", false - end - - -- LuaPosix: connect(fd, addr) -> 0 | nil, errmsg, errnum - local ok, err, eno = socket_mod.connect(fd, addr) - if ok ~= nil then - -- Successful connect (may still be non-blocking socket, but connect has completed). - return true, nil, false - end - - if eno == errno.EINPROGRESS then - return nil, nil, true - end - - return nil, errno_msg("connect failed", err, eno), false + local addr + if type(sa) == 'string' then + addr = { family = socket_mod.AF_UNIX, path = sa } + elseif type(sa) == 'table' then + addr = sa + else + return nil, 'unsupported sockaddr representation', false + end + + -- LuaPosix: connect(fd, addr) -> 0 | nil, errmsg, errnum + local ok, err, eno = socket_mod.connect(fd, addr) + if ok ~= nil then + -- Successful connect (may still be non-blocking socket, but connect has completed). + return true, nil, false + end + + if eno == errno.EINPROGRESS then + return nil, nil, true + end + + return nil, errno_msg('connect failed', err, eno), false end --- Complete a non-blocking connect using SO_ERROR. ---@param fd integer ---@return boolean ok, string|nil err local function connect_finish_fd(fd) - local soerr, err, eno = socket_mod.getsockopt( - fd, socket_mod.SOL_SOCKET, socket_mod.SO_ERROR - ) - if soerr == nil then - return false, errno_msg("getsockopt(SO_ERROR) failed", err, eno) - end - if soerr == 0 then - return true, nil - end - return false, "connect error errno " .. tostring(soerr) + local soerr, err, eno = socket_mod.getsockopt( + fd, socket_mod.SOL_SOCKET, socket_mod.SO_ERROR + ) + if soerr == nil then + return false, errno_msg('getsockopt(SO_ERROR) failed', err, eno) + end + if soerr == 0 then + return true, nil + end + return false, 'connect error errno ' .. tostring(soerr) end local function is_supported() - -- If we reached here, luaposix is present; assume support. - return true + -- If we reached here, luaposix is present; assume support. + return true end ---------------------------------------------------------------------- @@ -360,35 +360,35 @@ end ---------------------------------------------------------------------- local ops = { - set_nonblock = set_nonblock, - read = read_fd, - write = write_fd, - seek = seek_fd, - close = close_fd, - - open_file = open_file, - pipe = pipe_fds, - mktemp = mktemp, - fsync = fsync_fd, - rename = rename_file, - unlink = unlink_file, - decode_access = decode_access, - ignore_sigpipe = ignore_sigpipe, - - socket = socket_fd, - bind = bind_fd, - listen = listen_fd, - accept = accept_fd, - connect_start = connect_start_fd, - connect_finish = connect_finish_fd, - - modes = modes, - permissions = permissions, - - AF_UNIX = socket_mod.AF_UNIX, - SOCK_STREAM = socket_mod.SOCK_STREAM, - - is_supported = is_supported, + set_nonblock = set_nonblock, + read = read_fd, + write = write_fd, + seek = seek_fd, + close = close_fd, + + open_file = open_file, + pipe = pipe_fds, + mktemp = mktemp, + fsync = fsync_fd, + rename = rename_file, + unlink = unlink_file, + decode_access = decode_access, + ignore_sigpipe = ignore_sigpipe, + + socket = socket_fd, + bind = bind_fd, + listen = listen_fd, + accept = accept_fd, + connect_start = connect_start_fd, + connect_finish = connect_finish_fd, + + modes = modes, + permissions = permissions, + + AF_UNIX = socket_mod.AF_UNIX, + SOCK_STREAM = socket_mod.SOCK_STREAM, + + is_supported = is_supported, } return core.build_backend(ops) diff --git a/src/fibers/io/file.lua b/src/fibers/io/file.lua index 61b7d90..10e3ca0 100644 --- a/src/fibers/io/file.lua +++ b/src/fibers/io/file.lua @@ -17,9 +17,9 @@ local fd_back = require 'fibers.io.fd_backend' -- Best-effort: ignore SIGPIPE so write failures report via errno/return codes. do - if fd_back.ignore_sigpipe then - fd_back.ignore_sigpipe() -- errors are treated as non-fatal - end + if fd_back.ignore_sigpipe then + fd_back.ignore_sigpipe() -- errors are treated as non-fatal + end end ---------------------------------------------------------------------- @@ -27,27 +27,29 @@ end ---------------------------------------------------------------------- -- We keep the string conventions, but leave numeric mapping to the backend. ----@type table +---@param mode string The file mode string (e.g., "r", "w", "a", "r+", "w+", "a+") +---@return boolean readable Returns true if the mode allows reading +---@return boolean writable Returns true if the mode allows writing local function mode_access(mode) - assert(type(mode) == "string", "mode must be a string") - local plus = mode:find("+", 1, true) ~= nil - local c = mode:sub(1, 1) - - if c == "r" then - if plus then - return true, true - else - return true, false - end - elseif c == "w" or c == "a" then - if plus then - return true, true - else - return false, true - end - else - error("invalid mode: " .. tostring(mode)) - end + assert(type(mode) == 'string', 'mode must be a string') + local plus = mode:find('+', 1, true) ~= nil + local c = mode:sub(1, 1) + + if c == 'r' then + if plus then + return true, true + else + return true, false + end + elseif c == 'w' or c == 'a' then + if plus then + return true, true + else + return false, true + end + else + error('invalid mode: ' .. tostring(mode)) + end end ---------------------------------------------------------------------- @@ -66,29 +68,29 @@ end ---@param filename? string ---@return Stream local function fdopen(fd, flags_or_mode, filename) - -- assert(type(fd) == "number", "fdopen: fd must be a number") - assert(type(fd) ~= nil, "fdopen: fd must be non-nil") - - local readable, writable - - local t = type(flags_or_mode) - if t == "number" then - assert(fd_back.decode_access, "backend does not implement decode_access") - readable, writable = fd_back.decode_access(flags_or_mode) - elseif t == "string" then - readable, writable = mode_access(flags_or_mode) - elseif t == "table" then - readable = not not flags_or_mode.readable - writable = not not flags_or_mode.writable - else - error("fdopen: invalid flags_or_mode: " .. tostring(flags_or_mode)) - end - - local io = fd_back.new(fd, { filename = filename }) - - -- We no longer try to adjust buffer size based on fstat; stream.open - -- will apply its default buffer sizes. - return stream.open(io, readable, writable) + -- assert(type(fd) == "number", "fdopen: fd must be a number") + assert(type(fd) ~= nil, 'fdopen: fd must be non-nil') + + local readable, writable + + local t = type(flags_or_mode) + if t == 'number' then + assert(fd_back.decode_access, 'backend does not implement decode_access') + readable, writable = fd_back.decode_access(flags_or_mode) + elseif t == 'string' then + readable, writable = mode_access(flags_or_mode) + elseif t == 'table' then + readable = not not flags_or_mode.readable + writable = not not flags_or_mode.writable + else + error('fdopen: invalid flags_or_mode: ' .. tostring(flags_or_mode)) + end + + local io = fd_back.new(fd, { filename = filename }) + + -- We no longer try to adjust buffer size based on fstat; stream.open + -- will apply its default buffer sizes. + return stream.open(io, readable, writable) end ---------------------------------------------------------------------- @@ -105,14 +107,14 @@ end ---@param perms? integer|string ---@return Stream|nil f, string|nil err local function open_file(filename, mode, perms) - mode = mode or "r" + mode = mode or 'r' - local fd, err = fd_back.open_file(filename, mode, perms) - if not fd then - return nil, err - end + local fd, err = fd_back.open_file(filename, mode, perms) + if not fd then + return nil, err + end - return fdopen(fd, mode, filename) + return fdopen(fd, mode, filename) end ---------------------------------------------------------------------- @@ -122,14 +124,14 @@ end --- Create a unidirectional pipe as two Streams (read, write). ---@return Stream r_stream, Stream w_stream local function pipe() - local rd, wr, err = fd_back.pipe() - if not rd then - error(err or "pipe() failed") - end - - local r_stream = fdopen(rd, "r") - local w_stream = fdopen(wr, "w") - return r_stream, w_stream + local rd, wr, err = fd_back.pipe() + if not rd then + error(err or 'pipe() failed') + end + + local r_stream = fdopen(rd, 'r') + local w_stream = fdopen(wr, 'w') + return r_stream, w_stream end ---------------------------------------------------------------------- @@ -143,12 +145,12 @@ end ---@param perms? integer|string ---@return integer|nil fd, string tmpname_or_err local function mktemp(prefix, perms) - perms = perms or "rw-r--r--" - local fd, tmpnam_or_err = fd_back.mktemp(prefix, perms) - if not fd then - return nil, tmpnam_or_err - end - return fd, tmpnam_or_err + perms = perms or 'rw-r--r--' + local fd, tmpnam_or_err = fd_back.mktemp(prefix, perms) + if not fd then + return nil, tmpnam_or_err + end + return fd, tmpnam_or_err end --- Create a temporary file wrapped as a Stream, with unlink-on-close semantics. @@ -156,79 +158,79 @@ end ---@param tmpdir? string ---@return Stream|nil f, string|nil err local function tmpfile(perms, tmpdir) - perms = perms or "rw-r--r--" - tmpdir = tmpdir or os.getenv("TMPDIR") or "/tmp" - ---@cast tmpdir string - - local fd, tmpnam_or_err = mktemp(tmpdir .. "/tmp", perms) - if not fd then - return nil, tmpnam_or_err - end - - ---@type Stream - local f = fdopen(fd, "r+", tmpnam_or_err) - - -- We want unlink-on-close semantics by default, with a way to - -- disable that via :rename(). - local io = f.io - assert(io, "tmpfile backend missing") - ---@cast io StreamBackend - - local old_close = assert(io.close, "tmpfile backend missing close()") - - --- Rename the temporary file and disable unlink-on-close behaviour. - ---@param newname string - ---@return boolean|nil ok, string|nil err - function f:rename(newname) - -- Flush buffered data first (various stream flavours). - if self.flush_output then - self:flush_output() - elseif self.flush then - self:flush() - end - - local real_fd = io.fileno and io:fileno() or fd - if real_fd then - fd_back.fsync(real_fd) - end - - local fname = assert(io.filename, "tmpfile has no filename") - local ok, err = fd_back.rename(fname, newname) - if not ok then - return nil, ("failed to rename %s to %s: %s"):format( - tostring(fname), - tostring(newname), - tostring(err) - ) - end - - io.filename = newname - -- Disable remove-on-close: restore original close. - io.close = old_close - return true - end - - --- Close the fd and unlink the temporary file. - ---@return boolean ok, string|nil err - function io:close() - local ok, err = old_close(self) - if not ok then - return ok, err - end - - local fname = assert(self.filename, "tmpfile has no filename") - local ok2, err2 = fd_back.unlink(fname) - if not ok2 then - return false, ("failed to remove %s: %s"):format( - tostring(fname), - tostring(err2) - ) - end - - return true, nil - end - - return f + perms = perms or 'rw-r--r--' + tmpdir = tmpdir or os.getenv('TMPDIR') or '/tmp' + ---@cast tmpdir string + + local fd, tmpnam_or_err = mktemp(tmpdir .. '/tmp', perms) + if not fd then + return nil, tmpnam_or_err + end + + ---@type Stream + local f = fdopen(fd, 'r+', tmpnam_or_err) + + -- We want unlink-on-close semantics by default, with a way to + -- disable that via :rename(). + local io = f.io + assert(io, 'tmpfile backend missing') + ---@cast io StreamBackend + + local old_close = assert(io.close, 'tmpfile backend missing close()') + + --- Rename the temporary file and disable unlink-on-close behaviour. + ---@param newname string + ---@return boolean|nil ok, string|nil err + function f:rename(newname) + -- Flush buffered data first (various stream flavours). + if self.flush_output then + self:flush_output() + elseif self.flush then + self:flush() + end + + local real_fd = io.fileno and io:fileno() or fd + if real_fd then + fd_back.fsync(real_fd) + end + + local fname = assert(io.filename, 'tmpfile has no filename') + local ok, err = fd_back.rename(fname, newname) + if not ok then + return nil, ('failed to rename %s to %s: %s'):format( + tostring(fname), + tostring(newname), + tostring(err) + ) + end + + io.filename = newname + -- Disable remove-on-close: restore original close. + io.close = old_close + return true + end + + --- Close the fd and unlink the temporary file. + ---@return boolean ok, string|nil err + function io:close() + local ok, err = old_close(self) + if not ok then + return ok, err + end + + local fname = assert(self.filename, 'tmpfile has no filename') + local ok2, err2 = fd_back.unlink(fname) + if not ok2 then + return false, ('failed to remove %s: %s'):format( + tostring(fname), + tostring(err2) + ) + end + + return true, nil + end + + return f end ---------------------------------------------------------------------- @@ -239,7 +241,7 @@ end ---@param fd integer ---@return boolean ok, string|nil err local function init_nonblocking(fd) - return fd_back.set_nonblock(fd) + return fd_back.set_nonblock(fd) end ---------------------------------------------------------------------- @@ -247,15 +249,15 @@ end ---------------------------------------------------------------------- return { - fdopen = fdopen, - open = open_file, - pipe = pipe, - mktemp = mktemp, - tmpfile = tmpfile, - init_nonblocking = init_nonblocking, - - -- For callers that previously used file.modes / file.permissions, - -- re-export backend metadata if present. - modes = fd_back.modes or {}, - permissions = fd_back.permissions or {}, + fdopen = fdopen, + open = open_file, + pipe = pipe, + mktemp = mktemp, + tmpfile = tmpfile, + init_nonblocking = init_nonblocking, + + -- For callers that previously used file.modes / file.permissions, + -- re-export backend metadata if present. + modes = fd_back.modes or {}, + permissions = fd_back.permissions or {}, } diff --git a/src/fibers/io/mem_backend.lua b/src/fibers/io/mem_backend.lua index f7a106e..50dfb2f 100644 --- a/src/fibers/io/mem_backend.lua +++ b/src/fibers/io/mem_backend.lua @@ -21,138 +21,138 @@ local bytes = require 'fibers.utils.bytes' local RingBuf = bytes.RingBuf local function new_half(bufsize) - local H = { - rx = RingBuf.new(bufsize or 4096), - r_wait = wait.new_waitset(), - w_wait = wait.new_waitset(), - peer = nil, - rx_closed = false, -- peer has closed its write side - closed = false, -- this half has been closed - } - - local function schedule_all(waitset, key) - local sched = runtime.current_scheduler - waitset:notify_all(key, sched) - end - - function H:kind() - return "mem" - end - - function H:fileno() - return nil - end - - -------------------------------------------------------------------- - -- String-oriented I/O, for use by fibers.io.stream - -------------------------------------------------------------------- - - -- Read up to max bytes as a Lua string. - -- * returns "" when peer has closed and no more data → EOF. - -- * returns nil, nil when no data and not closed yet → would block. - function H:read_string(max) - local have = self.rx:read_avail() - if have == 0 then - if self.rx_closed then - -- EOF - return "", nil - end - -- Would block - return nil, nil - end - - local n = math.min(have, max or have) - local chunk = self.rx:take(n) - - -- Space freed → wake peer writers. - local peer = self.peer - if peer then - schedule_all(peer.w_wait, peer) - end - - return chunk, nil - end - - -- Write a Lua string into the peer's receive buffer. - -- * returns nil, "closed" if peer is gone. - -- * returns nil, nil when no space → would block. - function H:write_string(str) - local peer = self.peer - if not peer or peer.rx_closed then - return nil, "closed" - end - - local len = #str - if len == 0 then - return 0, nil - end - - local room = peer.rx:write_avail() - if room == 0 then - if peer.rx_closed then - return nil, "closed" - end - -- Would block. - return nil, nil - end - - local n = math.min(room, len) - peer.rx:put(str:sub(1, n)) - - -- Data available → wake peer readers. - schedule_all(peer.r_wait, peer) - - return n, nil - end - - -------------------------------------------------------------------- - -- Readiness registration - -------------------------------------------------------------------- - - function H:on_readable(task) - -- Key by this half; effectively one bucket. - return self.r_wait:add(self, task) - end - - function H:on_writable(task) - return self.w_wait:add(self, task) - end - - -------------------------------------------------------------------- - -- Lifecycle - -------------------------------------------------------------------- - - function H:close() - if self.closed then - return true - end - self.closed = true - - local peer = self.peer - self.peer = nil - - -- Wake any local waiters so they can observe closure. - schedule_all(self.r_wait, self) - schedule_all(self.w_wait, self) - - if peer then - -- Indicate EOF to the peer's read side and wake its waiters. - peer.rx_closed = true - schedule_all(peer.r_wait, peer) - schedule_all(peer.w_wait, peer) - peer.peer = nil - end - - return true - end - - return H + local H = { + rx = RingBuf.new(bufsize or 4096), + r_wait = wait.new_waitset(), + w_wait = wait.new_waitset(), + peer = nil, + rx_closed = false, -- peer has closed its write side + closed = false, -- this half has been closed + } + + local function schedule_all(waitset, key) + local sched = runtime.current_scheduler + waitset:notify_all(key, sched) + end + + function H:kind() + return 'mem' + end + + function H:fileno() + return nil + end + + -------------------------------------------------------------------- + -- String-oriented I/O, for use by fibers.io.stream + -------------------------------------------------------------------- + + -- Read up to max bytes as a Lua string. + -- * returns "" when peer has closed and no more data → EOF. + -- * returns nil, nil when no data and not closed yet → would block. + function H:read_string(max) + local have = self.rx:read_avail() + if have == 0 then + if self.rx_closed then + -- EOF + return '', nil + end + -- Would block + return nil, nil + end + + local n = math.min(have, max or have) + local chunk = self.rx:take(n) + + -- Space freed → wake peer writers. + local peer = self.peer + if peer then + schedule_all(peer.w_wait, peer) + end + + return chunk, nil + end + + -- Write a Lua string into the peer's receive buffer. + -- * returns nil, "closed" if peer is gone. + -- * returns nil, nil when no space → would block. + function H:write_string(str) + local peer = self.peer + if not peer or peer.rx_closed then + return nil, 'closed' + end + + local len = #str + if len == 0 then + return 0, nil + end + + local room = peer.rx:write_avail() + if room == 0 then + if peer.rx_closed then + return nil, 'closed' + end + -- Would block. + return nil, nil + end + + local n = math.min(room, len) + peer.rx:put(str:sub(1, n)) + + -- Data available → wake peer readers. + schedule_all(peer.r_wait, peer) + + return n, nil + end + + -------------------------------------------------------------------- + -- Readiness registration + -------------------------------------------------------------------- + + function H:on_readable(task) + -- Key by this half; effectively one bucket. + return self.r_wait:add(self, task) + end + + function H:on_writable(task) + return self.w_wait:add(self, task) + end + + -------------------------------------------------------------------- + -- Lifecycle + -------------------------------------------------------------------- + + function H:close() + if self.closed then + return true + end + self.closed = true + + local peer = self.peer + self.peer = nil + + -- Wake any local waiters so they can observe closure. + schedule_all(self.r_wait, self) + schedule_all(self.w_wait, self) + + if peer then + -- Indicate EOF to the peer's read side and wake its waiters. + peer.rx_closed = true + schedule_all(peer.r_wait, peer) + schedule_all(peer.w_wait, peer) + peer.peer = nil + end + + return true + end + + return H end local function pipe(bufsize) - local a, b = new_half(bufsize), new_half(bufsize) - a.peer, b.peer = b, a - return a, b + local a, b = new_half(bufsize), new_half(bufsize) + a.peer, b.peer = b, a + return a, b end return { pipe = pipe } diff --git a/src/fibers/io/poller.lua b/src/fibers/io/poller.lua index 27da2ef..16c22bc 100644 --- a/src/fibers/io/poller.lua +++ b/src/fibers/io/poller.lua @@ -5,16 +5,16 @@ -- to a pure-luaposix select/poll implementation. local candidates = { - 'fibers.io.poller.epoll', -- Linux + FFI/epoll - 'fibers.io.poller.select', -- luaposix poll/select - 'fibers.io.poller.nixio', -- nixio poll/select + 'fibers.io.poller.epoll', -- Linux + FFI/epoll + 'fibers.io.poller.select', -- luaposix poll/select + 'fibers.io.poller.nixio', -- nixio poll/select } for _, name in ipairs(candidates) do - local ok, mod = pcall(require, name) - if ok and type(mod) == "table" and mod.is_supported and mod.is_supported() then - return mod - end + local ok, mod = pcall(require, name) + if ok and type(mod) == 'table' and mod.is_supported and mod.is_supported() then + return mod + end end -error("fibers.io.poller: no suitable poller backend available on this platform") +error('fibers.io.poller: no suitable poller backend available on this platform') diff --git a/src/fibers/io/poller/core.lua b/src/fibers/io/poller/core.lua index 2f3f690..d9da138 100644 --- a/src/fibers/io/poller/core.lua +++ b/src/fibers/io/poller/core.lua @@ -32,25 +32,25 @@ Poller.__index = Poller ---------------------------------------------------------------------- local function recompute_mask(self, fd) - local ops = self._ops - if not ops.on_wait_change then - return - end - local want_rd = not self.rd:is_empty(fd) - local want_wr = not self.wr:is_empty(fd) - ops.on_wait_change(self.backend_state, fd, want_rd, want_wr) + local ops = self._ops + if not ops.on_wait_change then + return + end + local want_rd = not self.rd:is_empty(fd) + local want_wr = not self.wr:is_empty(fd) + ops.on_wait_change(self.backend_state, fd, want_rd, want_wr) end local function seconds_to_ms(timeout) - if timeout == nil then - -- Non-blocking poll. - return 0 - elseif timeout < 0 then - -- “Infinite” block. - return -1 - else - return math.floor(timeout * 1e3 + 0.5) - end + if timeout == nil then + -- Non-blocking poll. + return 0 + elseif timeout < 0 then + -- “Infinite” block. + return -1 + else + return math.floor(timeout * 1e3 + 0.5) + end end ---------------------------------------------------------------------- @@ -63,29 +63,29 @@ end ---@param task Task ---@return WaitToken function Poller:wait(fd, dir, task) - -- assert(type(fd) == "number", "fd must be number") - assert(type(fd) ~= nil, "fd must be non-nil") - assert(dir == "rd" or dir == "wr", "dir must be 'rd' or 'wr'") + -- assert(type(fd) == "number", "fd must be number") + assert(type(fd) ~= nil, 'fd must be non-nil') + assert(dir == 'rd' or dir == 'wr', "dir must be 'rd' or 'wr'") - local ws = (dir == "rd") and self.rd or self.wr - local token = ws:add(fd, task) + local ws = (dir == 'rd') and self.rd or self.wr + local token = ws:add(fd, task) - -- Update backend subscription / mask. - recompute_mask(self, fd) + -- Update backend subscription / mask. + recompute_mask(self, fd) - -- Wrap unlink to keep backend state in sync with waitsets. - local original_unlink = token.unlink - local owner = self + -- Wrap unlink to keep backend state in sync with waitsets. + local original_unlink = token.unlink + local owner = self - function token.unlink(tok) - local emptied = original_unlink(tok) - if emptied then - recompute_mask(owner, fd) - end - return emptied - end + function token.unlink(tok) + local emptied = original_unlink(tok) + if emptied then + recompute_mask(owner, fd) + end + return emptied + end - return token + return token end --- TaskSource hook: wait for events and schedule ready tasks. @@ -93,36 +93,36 @@ end ---@param _ number|nil -- current monotonic time (unused) ---@param timeout number|nil -- seconds function Poller:schedule_tasks(sched, _, timeout) - local ops = self._ops - - local timeout_ms = seconds_to_ms(timeout) - local events = ops.poll(self.backend_state, timeout_ms, self.rd, self.wr) - if not events then - -- Backend chose to do nothing (e.g. no fds registered). - return - end - - for fd, flags in pairs(events) do - if flags.rd or flags.err then - self.rd:notify_all(fd, sched) - end - if flags.wr or flags.err then - self.wr:notify_all(fd, sched) - end - - -- Re-arm / update backend subscription after delivering events. - recompute_mask(self, fd) - end + local ops = self._ops + + local timeout_ms = seconds_to_ms(timeout) + local events = ops.poll(self.backend_state, timeout_ms, self.rd, self.wr) + if not events then + -- Backend chose to do nothing (e.g. no fds registered). + return + end + + for fd, flags in pairs(events) do + if flags.rd or flags.err then + self.rd:notify_all(fd, sched) + end + if flags.wr or flags.err then + self.wr:notify_all(fd, sched) + end + + -- Re-arm / update backend subscription after delivering events. + recompute_mask(self, fd) + end end -- Used by the scheduler. Poller.wait_for_events = Poller.schedule_tasks function Poller:close() - if self.backend_state and self._ops.close_backend then - self._ops.close_backend(self.backend_state) - end - self.backend_state = nil + if self.backend_state and self._ops.close_backend then + self._ops.close_backend(self.backend_state) + end + self.backend_state = nil end ---------------------------------------------------------------------- @@ -136,48 +136,48 @@ end ---@param ops table ---@return table poller_module -- { get = fn, Poller = Poller, is_supported = fn } local function build_poller(ops) - assert(type(ops) == "table", "poller ops must be a table") - assert(type(ops.new_backend) == "function", "ops.new_backend must be a function") - assert(type(ops.poll) == "function", "ops.poll must be a function") - - local function new_poller() - local backend_state = ops.new_backend() - local self = { - backend_state = backend_state, - rd = wait.new_waitset(), - wr = wait.new_waitset(), - _ops = ops, - } - return setmetatable(self, Poller) - end - - local singleton - - local function get() - if singleton then - return singleton - end - singleton = new_poller() - local sched = runtime.current_scheduler - assert(sched.add_task_source, "scheduler must implement add_task_source") - sched:add_task_source(singleton) - return singleton - end - - local function is_supported() - if type(ops.is_supported) == "function" then - return not not ops.is_supported() - end - return true - end - - return { - get = get, - Poller = Poller, - is_supported = is_supported, - } + assert(type(ops) == 'table', 'poller ops must be a table') + assert(type(ops.new_backend) == 'function', 'ops.new_backend must be a function') + assert(type(ops.poll) == 'function', 'ops.poll must be a function') + + local function new_poller() + local backend_state = ops.new_backend() + local self = { + backend_state = backend_state, + rd = wait.new_waitset(), + wr = wait.new_waitset(), + _ops = ops, + } + return setmetatable(self, Poller) + end + + local singleton + + local function get() + if singleton then + return singleton + end + singleton = new_poller() + local sched = runtime.current_scheduler + assert(sched.add_task_source, 'scheduler must implement add_task_source') + sched:add_task_source(singleton) + return singleton + end + + local function is_supported() + if type(ops.is_supported) == 'function' then + return not not ops.is_supported() + end + return true + end + + return { + get = get, + Poller = Poller, + is_supported = is_supported, + } end return { - build_poller = build_poller, + build_poller = build_poller, } diff --git a/src/fibers/io/poller/epoll.lua b/src/fibers/io/poller/epoll.lua index 101a691..4b3853a 100644 --- a/src/fibers/io/poller/epoll.lua +++ b/src/fibers/io/poller/epoll.lua @@ -6,35 +6,35 @@ -- ---@module 'fibers.io.poller.epoll' -local core = require 'fibers.io.poller.core' -local safe = require 'coxpcall' -local bit = rawget(_G, "bit") or require 'bit32' -local ffi_c = require 'fibers.utils.ffi_compat' +local core = require 'fibers.io.poller.core' +local safe = require 'coxpcall' +local bit = rawget(_G, 'bit') or require 'bit32' +local ffi_c = require 'fibers.utils.ffi_compat' ---------------------------------------------------------------------- -- FFI / CFFI setup via ffi_compat ---------------------------------------------------------------------- if not (ffi_c.is_supported and ffi_c.is_supported()) then - return { is_supported = function() return false end } + return { is_supported = function () return false end } end -local ffi = ffi_c.ffi -local C = ffi_c.C +local ffi = ffi_c.ffi +local C = ffi_c.C local ffi_tonumber = ffi_c.tonumber -local get_errno = ffi_c.errno +local get_errno = ffi_c.errno local EINTR = 4 local ENOENT = 2 local EBADF = 9 -local ARCH = ffi.arch or ((jit and jit.arch) or "x64") +local ARCH = ffi.arch or ((jit and jit.arch) or 'x64') ---------------------------------------------------------------------- -- Low-level epoll bindings ---------------------------------------------------------------------- -ffi.cdef[[ +ffi.cdef [[ typedef unsigned char uint8_t; typedef unsigned int uint32_t; typedef unsigned long long uint64_t; @@ -48,21 +48,21 @@ ffi.cdef[[ ]] -- epoll_event layout differs by architecture. -if ARCH == "x64" or ARCH == "x86" then - ffi.cdef[[ +if ARCH == 'x64' or ARCH == 'x86' then + ffi.cdef [[ typedef struct epoll_event { uint8_t raw[12]; // 4 bytes for events + 8 bytes for data } epoll_event; ]] -elseif ARCH == "mips" or ARCH == "mipsel" or ARCH == "arm64" then - ffi.cdef[[ +elseif ARCH == 'mips' or ARCH == 'mipsel' or ARCH == 'arm64' then + ffi.cdef [[ typedef struct epoll_event { uint32_t events; uint64_t data; } epoll_event; ]] else - error("fibers.io.poller.epoll: unsupported architecture " .. tostring(ARCH)) + error('fibers.io.poller.epoll: unsupported architecture ' .. tostring(ARCH)) end -- Event bits. @@ -80,94 +80,94 @@ local EPOLL_CTL_MOD = 3 -- Architecture-dependent field access. local get_event, set_event, get_data, set_data -if ARCH == "x64" or ARCH == "x86" then - get_event = function(ev) - return ffi.cast("uint32_t*", ev.raw)[0] - end - set_event = function(ev, value) - ffi.cast("uint32_t*", ev.raw)[0] = value - end - get_data = function(ev) - return ffi.cast("uint64_t*", ev.raw + 4)[0] - end - set_data = function(ev, value) - ffi.cast("uint64_t*", ev.raw + 4)[0] = value - end +if ARCH == 'x64' or ARCH == 'x86' then + get_event = function (ev) + return ffi.cast('uint32_t*', ev.raw)[0] + end + set_event = function (ev, value) + ffi.cast('uint32_t*', ev.raw)[0] = value + end + get_data = function (ev) + return ffi.cast('uint64_t*', ev.raw + 4)[0] + end + set_data = function (ev, value) + ffi.cast('uint64_t*', ev.raw + 4)[0] = value + end else - get_event = function(ev) - return ev.events - end - set_event = function(ev, value) - ev.events = value - end - get_data = function(ev) - return ev.data - end - set_data = function(ev, value) - ev.data = value - end + get_event = function (ev) + return ev.events + end + set_event = function (ev, value) + ev.events = value + end + get_data = function (ev) + return ev.data + end + set_data = function (ev, value) + ev.data = value + end end local function wrap_error(ret) - if ret == -1 then - local errno = get_errno() - local err = ffi.string(C.strerror(errno)) - return nil, err, errno - end - return ret, nil, nil + if ret == -1 then + local errno = get_errno() + local err = ffi.string(C.strerror(errno)) + return nil, err, errno + end + return ret, nil, nil end local function epoll_create() - local fd, err, errno = wrap_error(C.epoll_create(1)) - if not fd then - error(err or ("epoll_create failed (errno " .. tostring(errno) .. ")")) - end - return fd + local fd, err, errno = wrap_error(C.epoll_create(1)) + if not fd then + error(err or ('epoll_create failed (errno ' .. tostring(errno) .. ')')) + end + return fd end local function epoll_ctl_add(epfd, fd, mask) - local ev = ffi.new("struct epoll_event") - set_event(ev, mask) - set_data(ev, fd) - return wrap_error(C.epoll_ctl(epfd, EPOLL_CTL_ADD, fd, ev)) + local ev = ffi.new('struct epoll_event') + set_event(ev, mask) + set_data(ev, fd) + return wrap_error(C.epoll_ctl(epfd, EPOLL_CTL_ADD, fd, ev)) end local function epoll_ctl_mod(epfd, fd, mask) - local ev = ffi.new("struct epoll_event") - set_event(ev, mask) - set_data(ev, fd) - return wrap_error(C.epoll_ctl(epfd, EPOLL_CTL_MOD, fd, ev)) + local ev = ffi.new('struct epoll_event') + set_event(ev, mask) + set_data(ev, fd) + return wrap_error(C.epoll_ctl(epfd, EPOLL_CTL_MOD, fd, ev)) end local function epoll_ctl_del(epfd, fd) - return wrap_error(C.epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nil)) + return wrap_error(C.epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nil)) end local function epoll_wait(epfd, timeout_ms, max_events) - local events = ffi.new("struct epoll_event[?]", max_events) - local n = C.epoll_wait(epfd, events, max_events, timeout_ms or 0) - if n == -1 then - local errno = get_errno() - if errno == EINTR then - -- Benign interruption: report “no events”. - return {}, nil, errno - end - local err = ffi.string(C.strerror(errno)) - return nil, err, errno - end - - local res = {} - for i = 0, n - 1 do - local fd = assert(ffi_tonumber(get_data(events[i]))) - local event = assert(ffi_tonumber(get_event(events[i]))) - res[fd] = event - end - - return res, nil, nil + local events = ffi.new('struct epoll_event[?]', max_events) + local n = C.epoll_wait(epfd, events, max_events, timeout_ms or 0) + if n == -1 then + local errno = get_errno() + if errno == EINTR then + -- Benign interruption: report “no events”. + return {}, nil, errno + end + local err = ffi.string(C.strerror(errno)) + return nil, err, errno + end + + local res = {} + for i = 0, n - 1 do + local fd = assert(ffi_tonumber(get_data(events[i]))) + local event = assert(ffi_tonumber(get_event(events[i]))) + res[fd] = event + end + + return res, nil, nil end local function epoll_close(epfd) - return wrap_error(C.close(epfd)) + return wrap_error(C.close(epfd)) end ---------------------------------------------------------------------- @@ -184,12 +184,12 @@ Epoll.__index = Epoll local INITIAL_MAXEVENTS = 8 local function new_epoll() - local ret = { - epfd = epoll_create(), - active_events = {}, - maxevents = INITIAL_MAXEVENTS, - } - return setmetatable(ret, Epoll) + local ret = { + epfd = epoll_create(), + active_events = {}, + maxevents = INITIAL_MAXEVENTS, + } + return setmetatable(ret, Epoll) end local RD = EPOLLIN + EPOLLRDHUP @@ -197,50 +197,50 @@ local WR = EPOLLOUT local ERR = EPOLLERR + EPOLLHUP function Epoll:add(fd, events) - local active = self.active_events[fd] or 0 - local eventmask = bit.bor(events, active, EPOLLONESHOT) - local ok = epoll_ctl_mod(self.epfd, fd, eventmask) - if not ok then - assert(epoll_ctl_add(self.epfd, fd, eventmask)) - end - self.active_events[fd] = eventmask + local active = self.active_events[fd] or 0 + local eventmask = bit.bor(events, active, EPOLLONESHOT) + local ok = epoll_ctl_mod(self.epfd, fd, eventmask) + if not ok then + assert(epoll_ctl_add(self.epfd, fd, eventmask)) + end + self.active_events[fd] = eventmask end function Epoll:poll(timeout_ms) - local events, err, errno = epoll_wait(self.epfd, timeout_ms or 0, self.maxevents) - if not events then - error(err or ("epoll_wait failed (errno " .. tostring(errno) .. ")")) - end - - local count = 0 - for fd, _ in pairs(events) do - count = count + 1 - self.active_events[fd] = nil - end - - if count == self.maxevents then - self.maxevents = self.maxevents * 2 - end - - return events + local events, err, errno = epoll_wait(self.epfd, timeout_ms or 0, self.maxevents) + if not events then + error(err or ('epoll_wait failed (errno ' .. tostring(errno) .. ')')) + end + + local count = 0 + for fd, _ in pairs(events) do + count = count + 1 + self.active_events[fd] = nil + end + + if count == self.maxevents then + self.maxevents = self.maxevents * 2 + end + + return events end function Epoll:del(fd) - local ok, err, errno = epoll_ctl_del(self.epfd, fd) - if not ok then - -- ENOENT/EBADF: fd already closed or never registered; just clear. - if errno == ENOENT or errno == EBADF then - self.active_events[fd] = nil - return - end - error(err or ("epoll_ctl(DEL) failed (errno " .. tostring(errno) .. ")")) - end - self.active_events[fd] = nil + local ok, err, errno = epoll_ctl_del(self.epfd, fd) + if not ok then + -- ENOENT/EBADF: fd already closed or never registered; just clear. + if errno == ENOENT or errno == EBADF then + self.active_events[fd] = nil + return + end + error(err or ('epoll_ctl(DEL) failed (errno ' .. tostring(errno) .. ')')) + end + self.active_events[fd] = nil end function Epoll:close() - epoll_close(self.epfd) - self.epfd = nil + epoll_close(self.epfd) + self.epfd = nil end ---------------------------------------------------------------------- @@ -248,56 +248,56 @@ end ---------------------------------------------------------------------- local function new_backend() - return new_epoll() + return new_epoll() end local function on_wait_change(ep, fd, want_rd, want_wr) - local mask = 0 - if want_rd then mask = bit.bor(mask, RD) end - if want_wr then mask = bit.bor(mask, WR) end - - if mask ~= 0 then - ep:add(fd, mask) - else - ep:del(fd) - end + local mask = 0 + if want_rd then mask = bit.bor(mask, RD) end + if want_wr then mask = bit.bor(mask, WR) end + + if mask ~= 0 then + ep:add(fd, mask) + else + ep:del(fd) + end end -local function poll_backend(ep, timeout_ms, _rd_ws, _wr_ws) - -- ep:poll already returns fd -> epoll event bits. - local evmap = ep:poll(timeout_ms) - local events = {} - - for fd, ev in pairs(evmap) do - local flags = { - rd = bit.band(ev, RD + ERR) ~= 0, - wr = bit.band(ev, WR + ERR) ~= 0, - err = bit.band(ev, ERR) ~= 0, - } - events[fd] = flags - end - - return events +local function poll_backend(ep, timeout_ms, _, _) + -- ep:poll already returns fd -> epoll event bits. + local evmap = ep:poll(timeout_ms) + local events = {} + + for fd, ev in pairs(evmap) do + local flags = { + rd = bit.band(ev, RD + ERR) ~= 0, + wr = bit.band(ev, WR + ERR) ~= 0, + err = bit.band(ev, ERR) ~= 0, + } + events[fd] = flags + end + + return events end local function close_backend(ep) - ep:close() + ep:close() end local function is_supported() - local ok = safe.pcall(function() - local e = new_epoll() - e:close() - end) - return ok + local ok = safe.pcall(function () + local e = new_epoll() + e:close() + end) + return ok end local ops = { - new_backend = new_backend, - on_wait_change = on_wait_change, - poll = poll_backend, - close_backend = close_backend, - is_supported = is_supported, + new_backend = new_backend, + on_wait_change = on_wait_change, + poll = poll_backend, + close_backend = close_backend, + is_supported = is_supported, } return core.build_poller(ops) diff --git a/src/fibers/io/poller/nixio.lua b/src/fibers/io/poller/nixio.lua index 45fca09..de3cae4 100644 --- a/src/fibers/io/poller/nixio.lua +++ b/src/fibers/io/poller/nixio.lua @@ -13,9 +13,9 @@ local nixio = require 'nixio' ---------------------------------------------------------------------- local function new_backend() - -- No persistent kernel state required; everything is derived from the - -- current waitsets on each poll call. - return {} + -- No persistent kernel state required; everything is derived from the + -- current waitsets on each poll call. + return {} end --- Build the fds table in the shape expected by nixio.poll: @@ -23,83 +23,83 @@ end --- --- Here the "fd" field is the nixio object itself; poll() accepts that. local function build_fds(rd_waitset, wr_waitset) - local fds = {} - local index = 1 - - -- We need to iterate over the union of keys in both waitsets. - local seen = {} - - for fd, list in pairs(rd_waitset.buckets) do - if list and #list > 0 then - local events = nixio.poll_flags("in") - fds[index] = { fd = fd, events = events } - seen[fd] = index - index = index + 1 - end - end - - for fd, list in pairs(wr_waitset.buckets) do - if list and #list > 0 then - local pos = seen[fd] - if pos then - local e = fds[pos] - e.events = nixio.poll_flags(e.events, "out") - else - local events = nixio.poll_flags("out") - fds[index] = { fd = fd, events = events } - seen[fd] = index - index = index + 1 - end - end - end - - return fds + local fds = {} + local index = 1 + + -- We need to iterate over the union of keys in both waitsets. + local seen = {} + + for fd, list in pairs(rd_waitset.buckets) do + if list and #list > 0 then + local events = nixio.poll_flags('in') + fds[index] = { fd = fd, events = events } + seen[fd] = index + index = index + 1 + end + end + + for fd, list in pairs(wr_waitset.buckets) do + if list and #list > 0 then + local pos = seen[fd] + if pos then + local e = fds[pos] + e.events = nixio.poll_flags(e.events, 'out') + else + local events = nixio.poll_flags('out') + fds[index] = { fd = fd, events = events } + seen[fd] = index + index = index + 1 + end + end + end + + return fds end local function poll_backend(_, timeout_ms, rd_waitset, wr_waitset) - local fds = build_fds(rd_waitset, wr_waitset) - - -- nixio.poll(fds, timeout_ms) -> nready, fds' - local nready, fds_ret, _, _ = nixio.poll(fds, timeout_ms) - - if not nready then - -- Treat EINTR as benign; anything else can reasonably surface. - -- For a "simple" backend you can choose to treat all errors as - -- "no events"; if you prefer you can error() here instead. - return {} - end - - if nready == 0 then - return {} - end - - local events = {} - - for _, info in pairs(fds_ret) do - local revents = info.revents or 0 - if revents ~= 0 then - local flags = nixio.poll_flags(revents) - events[info.fd] = { - rd = not not (flags["in"] or flags.hup or flags.err or flags.nval), - wr = not not (flags.out or flags.err or flags.nval), - err = not not (flags.err or flags.nval), - } - end - end - - return events + local fds = build_fds(rd_waitset, wr_waitset) + + -- nixio.poll(fds, timeout_ms) -> nready, fds' + local nready, fds_ret, _, _ = nixio.poll(fds, timeout_ms) + + if not nready then + -- Treat EINTR as benign; anything else can reasonably surface. + -- For a "simple" backend you can choose to treat all errors as + -- "no events"; if you prefer you can error() here instead. + return {} + end + + if nready == 0 then + return {} + end + + local events = {} + + for _, info in pairs(fds_ret) do + local revents = info.revents or 0 + if revents ~= 0 then + local flags = nixio.poll_flags(revents) + events[info.fd] = { + rd = not not (flags['in'] or flags.hup or flags.err or flags.nval), + wr = not not (flags.out or flags.err or flags.nval), + err = not not (flags.err or flags.nval), + } + end + end + + return events end local function is_supported() - local ok = pcall(require, 'nixio') - return ok + local ok = pcall(require, 'nixio') + return ok end local ops = { - new_backend = new_backend, - poll = poll_backend, - -- on_wait_change not needed; state is rebuilt each poll. - is_supported = is_supported, + new_backend = new_backend, + poll = poll_backend, + -- on_wait_change not needed; state is rebuilt each poll. + is_supported = is_supported, } return core.build_poller(ops) diff --git a/src/fibers/io/poller/select.lua b/src/fibers/io/poller/select.lua index f9ecedc..6aaa382 100644 --- a/src/fibers/io/poller/select.lua +++ b/src/fibers/io/poller/select.lua @@ -9,10 +9,10 @@ local core = require 'fibers.io.poller.core' -- Try to load luaposix poll support. local ok, poll_mod = pcall(require, 'posix.poll') -if not ok or type(poll_mod) ~= "table" or type(poll_mod.poll) ~= "function" then - return { - is_supported = function() return false end, - } +if not ok or type(poll_mod) ~= 'table' or type(poll_mod.poll) ~= 'function' then + return { + is_supported = function () return false end, + } end local errno_mod = require 'posix.errno' @@ -23,88 +23,88 @@ local poll_fn = poll_mod.poll ---------------------------------------------------------------------- local function new_backend() - -- No persistent kernel state required for poll(); everything is - -- derived from the current waitsets on each poll call. - return {} + -- No persistent kernel state required for poll(); everything is + -- derived from the current waitsets on each poll call. + return {} end --- Build the fds table in the shape expected by posix.poll.poll: --- fds[fd] = { events = { IN = true, OUT = true } } local function build_fds(rd_waitset, wr_waitset) - local fds = {} - - -- Any fd with one or more read waiters gets IN. - for fd, list in pairs(rd_waitset.buckets) do - if list and #list > 0 then - local e = fds[fd] - if not e then - e = { events = {} } - fds[fd] = e - end - e.events.IN = true - end - end - - -- Any fd with one or more write waiters gets OUT. - for fd, list in pairs(wr_waitset.buckets) do - if list and #list > 0 then - local e = fds[fd] - if not e then - e = { events = {} } - fds[fd] = e - end - e.events.OUT = true - end - end - - return fds + local fds = {} + + -- Any fd with one or more read waiters gets IN. + for fd, list in pairs(rd_waitset.buckets) do + if list and #list > 0 then + local e = fds[fd] + if not e then + e = { events = {} } + fds[fd] = e + end + e.events.IN = true + end + end + + -- Any fd with one or more write waiters gets OUT. + for fd, list in pairs(wr_waitset.buckets) do + if list and #list > 0 then + local e = fds[fd] + if not e then + e = { events = {} } + fds[fd] = e + end + e.events.OUT = true + end + end + + return fds end local function poll_backend(_, timeout_ms, rd_waitset, wr_waitset) - local fds = build_fds(rd_waitset, wr_waitset) - - -- poll() with nfds == 0 is defined and just sleeps for timeout. - local nready, err, eno = poll_fn(fds, timeout_ms) - if nready == nil then - -- Treat EINTR as a benign interruption (e.g. SIGCHLD), same as epoll backend. - if eno == errno_mod.EINTR then - return {} - end - error(("%s (errno %s)"):format(tostring(err), tostring(eno))) - end - if nready == 0 then - return {} - end - - local events = {} - - -- luaposix reports readiness in fds[fd].revents with flags - -- such as IN, OUT, ERR, HUP, NVAL. - for fd, info in pairs(fds) do - local re = info.revents - if re then - local rd_flag = re.IN or re.HUP or re.ERR or re.NVAL - local wr_flag = re.OUT or re.ERR or re.NVAL - local err_flag = re.ERR or re.NVAL - - if rd_flag or wr_flag or err_flag then - events[fd] = { - rd = not not rd_flag, - wr = not not wr_flag, - err = not not err_flag, - } - end - end - end - - return events + local fds = build_fds(rd_waitset, wr_waitset) + + -- poll() with nfds == 0 is defined and just sleeps for timeout. + local nready, err, eno = poll_fn(fds, timeout_ms) + if nready == nil then + -- Treat EINTR as a benign interruption (e.g. SIGCHLD), same as epoll backend. + if eno == errno_mod.EINTR then + return {} + end + error(('%s (errno %s)'):format(tostring(err), tostring(eno))) + end + if nready == 0 then + return {} + end + + local events = {} + + -- luaposix reports readiness in fds[fd].revents with flags + -- such as IN, OUT, ERR, HUP, NVAL. + for fd, info in pairs(fds) do + local re = info.revents + if re then + local rd_flag = re.IN or re.HUP or re.ERR or re.NVAL + local wr_flag = re.OUT or re.ERR or re.NVAL + local err_flag = re.ERR or re.NVAL + + if rd_flag or wr_flag or err_flag then + events[fd] = { + rd = not not rd_flag, + wr = not not wr_flag, + err = not not err_flag, + } + end + end + end + + return events end local ops = { - new_backend = new_backend, - poll = poll_backend, - -- on_wait_change: not needed for poll(); state is rebuilt each time. - is_supported = function() return true end, + new_backend = new_backend, + poll = poll_backend, + -- on_wait_change: not needed for poll(); state is rebuilt each time. + is_supported = function () return true end, } return core.build_poller(ops) diff --git a/src/fibers/io/socket.lua b/src/fibers/io/socket.lua index 97a8dae..997c10a 100644 --- a/src/fibers/io/socket.lua +++ b/src/fibers/io/socket.lua @@ -39,30 +39,30 @@ Socket.__index = Socket ---@param filename? string ---@return Stream local function fd_to_stream(fd, filename) - local io = fd_backend.new(fd, { filename = filename }) - -- For sockets we assume readable + writable. - return stream_mod.open(io, true, true) + local io = fd_backend.new(fd, { filename = filename }) + -- For sockets we assume readable + writable. + return stream_mod.open(io, true, true) end --- Create a new non-blocking socket object from an fd. ---@param fd integer ---@return Socket local function new_socket(fd) - -- Ensure non-blocking behaviour. - local ok, err = fd_backend.set_nonblock(fd) - if not ok then - fd_backend.close_fd(fd) - error("set_nonblock(socket fd) failed: " .. tostring(err)) - end - return setmetatable({ fd = fd }, Socket) + -- Ensure non-blocking behaviour. + local ok, err = fd_backend.set_nonblock(fd) + if not ok then + fd_backend.close_fd(fd) + error('set_nonblock(socket fd) failed: ' .. tostring(err)) + end + return setmetatable({ fd = fd }, Socket) end --- Return underlying fd or error if closed. ---@return integer function Socket:_fd() - local fd = self.fd - assert(fd, "socket is closed") - return fd + local fd = self.fd + assert(fd, 'socket is closed') + return fd end ---------------------------------------------------------------------- @@ -75,16 +75,16 @@ end ---@param protocol? integer ---@return Socket|nil s, any err local function socket(domain, stype, protocol) - local fd, err = fd_backend.socket(domain, stype, protocol or 0) - if not fd then - return nil, err - end - local ok, nerr = fd_backend.set_nonblock(fd) - if not ok then - fd_backend.close_fd(fd) - return nil, nerr - end - return new_socket(fd) + local fd, err = fd_backend.socket(domain, stype, protocol or 0) + if not fd then + return nil, err + end + local ok, nerr = fd_backend.set_nonblock(fd) + if not ok then + fd_backend.close_fd(fd) + return nil, nerr + end + return new_socket(fd) end ---------------------------------------------------------------------- @@ -95,19 +95,19 @@ end ---@param path string ---@return boolean|nil ok, any err function Socket:listen_unix(path) - local fd = self:_fd() + local fd = self:_fd() - local ok, err = fd_backend.bind(fd, path) - if not ok then - return nil, ("bind failed: %s"):format(tostring(err)) - end + local ok, err = fd_backend.bind(fd, path) + if not ok then + return nil, ('bind failed: %s'):format(tostring(err)) + end - ok, err = fd_backend.listen(fd) - if not ok then - return nil, ("listen failed: %s"):format(tostring(err)) - end + ok, err = fd_backend.listen(fd) + if not ok then + return nil, ('listen failed: %s'):format(tostring(err)) + end - return true + return true end ---------------------------------------------------------------------- @@ -117,42 +117,42 @@ end --- Build an Op that accepts a connection and returns a Stream. ---@return Op function Socket:accept_op() - local P = poller_mod.get() - local fd = self:_fd() - - local function step() - local new_fd, err, again = fd_backend.accept(fd) - if new_fd then - return true, new_fd, nil - end - if again then - -- Would block: wait for readability. - return false - end - -- Hard error. - return true, nil, err - end - - local function register(task) - -- poller wait on listening fd for read readiness. - return P:wait(fd, "rd", task) - end - - local function wrap(new_fd, err) - if not new_fd then - return nil, err - end - -- fd_to_stream will mark it non-blocking via fd_backend.new(). - return fd_to_stream(new_fd) - end - - return wait.waitable(register, step, wrap) + local P = poller_mod.get() + local fd = self:_fd() + + local function step() + local new_fd, err, again = fd_backend.accept(fd) + if new_fd then + return true, new_fd, nil + end + if again then + -- Would block: wait for readability. + return false + end + -- Hard error. + return true, nil, err + end + + local function register(task) + -- poller wait on listening fd for read readiness. + return P:wait(fd, 'rd', task) + end + + local function wrap(new_fd, err) + if not new_fd then + return nil, err + end + -- fd_to_stream will mark it non-blocking via fd_backend.new(). + return fd_to_stream(new_fd) + end + + return wait.waitable(register, step, wrap) end --- Accept a connection synchronously into a Stream. ---@return Stream|nil client, any err function Socket:accept() - return perform(self:accept_op()) + return perform(self:accept_op()) end ---------------------------------------------------------------------- @@ -164,55 +164,55 @@ end ---@param sa any ---@return Op function Socket:connect_op(sa) - local P = poller_mod.get() - local fd = self:_fd() - local state = "initial" - - local function step() - if state == "initial" then - local ok, err, inprogress = fd_backend.connect_start(fd, sa) - if ok then - return true, true, nil - end - if inprogress then - state = "waiting" - return false - end - return true, false, err - elseif state == "waiting" then - local ok, err = fd_backend.connect_finish(fd) - if not ok then - return true, false, err - end - return true, true, nil - else - return true, false, "invalid connect state" - end - end - - local function register(task) - -- Non-blocking connect completion is signalled via writability. - return P:wait(fd, "wr", task) - end - - local function wrap(ok, err) - if not ok then - return nil, err - end - local new_fd = fd - -- Hand ownership of the fd to the Stream; prevent double-close in Socket:close(). - self.fd = nil - return fd_to_stream(new_fd) - end - - return wait.waitable(register, step, wrap) + local P = poller_mod.get() + local fd = self:_fd() + local state = 'initial' + + local function step() + if state == 'initial' then + local ok, err, inprogress = fd_backend.connect_start(fd, sa) + if ok then + return true, true, nil + end + if inprogress then + state = 'waiting' + return false + end + return true, false, err + elseif state == 'waiting' then + local ok, err = fd_backend.connect_finish(fd) + if not ok then + return true, false, err + end + return true, true, nil + else + return true, false, 'invalid connect state' + end + end + + local function register(task) + -- Non-blocking connect completion is signalled via writability. + return P:wait(fd, 'wr', task) + end + + local function wrap(ok, err) + if not ok then + return nil, err + end + local new_fd = fd + -- Hand ownership of the fd to the Stream; prevent double-close in Socket:close(). + self.fd = nil + return fd_to_stream(new_fd) + end + + return wait.waitable(register, step, wrap) end --- Connect synchronously and return a Stream. ---@param sa any ---@return Stream|nil stream, any err function Socket:connect(sa) - return perform(self:connect_op(sa)) + return perform(self:connect_op(sa)) end ---------------------------------------------------------------------- @@ -223,14 +223,14 @@ end ---@param path string ---@return Op function Socket:connect_unix_op(path) - return self:connect_op(path) + return self:connect_op(path) end --- Connect synchronously to a UNIX-domain path. ---@param path string ---@return Stream|nil stream, any err function Socket:connect_unix(path) - return perform(self:connect_unix_op(path)) + return perform(self:connect_unix_op(path)) end --- Listen on a UNIX-domain path and return a listening Socket. @@ -238,43 +238,43 @@ end ---@param opts? { stype?: integer, protocol?: integer, ephemeral?: boolean } ---@return Socket|nil s, any err local function listen_unix(path, opts) - opts = opts or {} - - local stype = opts.stype or fd_backend.SOCK_STREAM - local protocol = opts.protocol or 0 - - local s, err = socket(fd_backend.AF_UNIX, stype, protocol) - if not s then - return nil, err - end - - local ok, lerr = s:listen_unix(path) - if not ok then - s:close() - return nil, lerr - end - - if opts.ephemeral then - local parent_close = s.close - function s:close() - local ok1, err1 = parent_close(self) - - local ok2, err2 = fd_backend.unlink(path) - if not ok2 then - return false, ("failed to remove %s: %s"):format( - tostring(path), - tostring(err2) - ) - end - - if ok1 == false then - return false, err1 - end - return true, nil - end - end - - return s + opts = opts or {} + + local stype = opts.stype or fd_backend.SOCK_STREAM + local protocol = opts.protocol or 0 + + local s, err = socket(fd_backend.AF_UNIX, stype, protocol) + if not s then + return nil, err + end + + local ok, lerr = s:listen_unix(path) + if not ok then + s:close() + return nil, lerr + end + + if opts.ephemeral then + local parent_close = s.close + function s:close() + local ok1, err1 = parent_close(self) + + local ok2, err2 = fd_backend.unlink(path) + if not ok2 then + return false, ('failed to remove %s: %s'):format( + tostring(path), + tostring(err2) + ) + end + + if ok1 == false then + return false, err1 + end + return true, nil + end + end + + return s end --- Connect to a UNIX-domain socket path and return a Stream. @@ -283,20 +283,20 @@ end ---@param protocol? integer ---@return Stream|nil stream, any err local function connect_unix(path, stype, protocol) - stype = stype or fd_backend.SOCK_STREAM - protocol = protocol or 0 - - local s, err = socket(fd_backend.AF_UNIX, stype, protocol) - if not s then - return nil, err - end - - local stream, cerr = s:connect_unix(path) - if not stream then - s:close() - return nil, cerr - end - return stream + stype = stype or fd_backend.SOCK_STREAM + protocol = protocol or 0 + + local s, err = socket(fd_backend.AF_UNIX, stype, protocol) + if not s then + return nil, err + end + + local stream, cerr = s:connect_unix(path) + if not stream then + s:close() + return nil, cerr + end + return stream end ---------------------------------------------------------------------- @@ -306,12 +306,12 @@ end --- Close the underlying socket fd. ---@return boolean ok, any err function Socket:close() - if self.fd then - local ok, err = fd_backend.close_fd(self.fd) - self.fd = nil - return ok, err - end - return true, nil + if self.fd then + local ok, err = fd_backend.close_fd(self.fd) + self.fd = nil + return ok, err + end + return true, nil end ---------------------------------------------------------------------- @@ -319,12 +319,12 @@ end ---------------------------------------------------------------------- return { - socket = socket, - listen_unix = listen_unix, - connect_unix = connect_unix, - Socket = Socket, - - -- re-export useful constants for callers - AF_UNIX = fd_backend.AF_UNIX, - SOCK_STREAM = fd_backend.SOCK_STREAM, + socket = socket, + listen_unix = listen_unix, + connect_unix = connect_unix, + Socket = Socket, + + -- re-export useful constants for callers + AF_UNIX = fd_backend.AF_UNIX, + SOCK_STREAM = fd_backend.SOCK_STREAM, } diff --git a/src/fibers/io/stream.lua b/src/fibers/io/stream.lua index 36f5b4f..63ab79a 100644 --- a/src/fibers/io/stream.lua +++ b/src/fibers/io/stream.lua @@ -35,7 +35,7 @@ local LinearBuf = bytes.LinearBuf local Stream = {} Stream.__index = Stream -local DEFAULT_BUFFER_SIZE = 2^12 +local DEFAULT_BUFFER_SIZE = 2 ^ 12 --- Open a new Stream over a backend. ---@param io_backend StreamBackend @@ -44,38 +44,38 @@ local DEFAULT_BUFFER_SIZE = 2^12 ---@param bufsize? integer # per-direction buffer size ---@return Stream local function open(io_backend, readable, writable, bufsize) - local s = setmetatable({ - io = io_backend, - line_buffering = false, - }, Stream) - - if readable ~= false then - s.rx = RingBuf.new(bufsize or DEFAULT_BUFFER_SIZE) - end - if writable ~= false then - s.tx = RingBuf.new(bufsize or DEFAULT_BUFFER_SIZE) - end - - return s + local s = setmetatable({ + io = io_backend, + line_buffering = false, + }, Stream) + + if readable ~= false then + s.rx = RingBuf.new(bufsize or DEFAULT_BUFFER_SIZE) + end + if writable ~= false then + s.tx = RingBuf.new(bufsize or DEFAULT_BUFFER_SIZE) + end + + return s end --- Check whether a value is a Stream instance. ---@param x any ---@return boolean local function is_stream(x) - return type(x) == 'table' and getmetatable(x) == Stream + return type(x) == 'table' and getmetatable(x) == Stream end function Stream:nonblock() - if self.io and self.io.nonblock then - self.io:nonblock() - end + if self.io and self.io.nonblock then + self.io:nonblock() + end end function Stream:block() - if self.io and self.io.block then - self.io:block() - end + if self.io and self.io.block then + self.io:block() + end end ---------------------------------------------------------------------- @@ -89,99 +89,99 @@ end ---@param terminator string|nil ---@return fun(): boolean, ... # step() local function make_read_step(stream, buf, min, max, terminator) - local tally = 0 - - local function adjust_for_terminator() - if not terminator then return end - local loc = stream.rx:find(terminator) - if loc then - local final = tally + loc + #terminator - min, max = final, final - end - end - - return function() - while true do - adjust_for_terminator() - - local avail = stream.rx:read_avail() - if avail > 0 and tally < max then - local need = math.min(avail, max - tally) - local chunk = stream.rx:take(need) - if #chunk > 0 then - buf:append(chunk) - tally = tally + #chunk - if tally >= min then - return true, buf, tally - end - end - end - - if not (stream.io and stream.io.read_string) then - return true, buf, tally, "backend does not support read_string" - end - - local room = stream.rx:write_avail() - if room <= 0 then - if tally >= min then - return true, buf, tally - end - return true, buf, tally, "buffer capacity exhausted" - end - - local data, err = stream.io:read_string(room) - if err then - return true, buf, tally, err - end - if not data then - if tally >= min then - return true, buf, tally - end - return false - end - if #data == 0 then - return true, buf, tally - end - - stream.rx:put(data) - end - end + local tally = 0 + + local function adjust_for_terminator() + if not terminator then return end + local loc = stream.rx:find(terminator) + if loc then + local final = tally + loc + #terminator + min, max = final, final + end + end + + return function () + while true do + adjust_for_terminator() + + local avail = stream.rx:read_avail() + if avail > 0 and tally < max then + local need = math.min(avail, max - tally) + local chunk = stream.rx:take(need) + if #chunk > 0 then + buf:append(chunk) + tally = tally + #chunk + if tally >= min then + return true, buf, tally + end + end + end + + if not (stream.io and stream.io.read_string) then + return true, buf, tally, 'backend does not support read_string' + end + + local room = stream.rx:write_avail() + if room <= 0 then + if tally >= min then + return true, buf, tally + end + return true, buf, tally, 'buffer capacity exhausted' + end + + local data, err = stream.io:read_string(room) + if err then + return true, buf, tally, err + end + if not data then + if tally >= min then + return true, buf, tally + end + return false + end + if #data == 0 then + return true, buf, tally + end + + stream.rx:put(data) + end + end end ---@param stream Stream ---@param src_str string ---@return fun(): boolean, ... # step() local function make_write_step(stream, src_str) - local offset = 0 - local len = #src_str - - return function() - if offset == len then - return true, len - end - - if not (stream.io and stream.io.write_string) then - return true, offset, "backend does not support write_string" - end - - local chunk = src_str:sub(offset + 1) - local n, err = stream.io:write_string(chunk) - if err then - return true, offset, err - end - if n == nil then - return false - end - if n == 0 then - return true, offset - end - - offset = offset + n - if offset >= len then - return true, offset - end - return false - end + local offset = 0 + local len = #src_str + + return function () + if offset == len then + return true, len + end + + if not (stream.io and stream.io.write_string) then + return true, offset, 'backend does not support write_string' + end + + local chunk = src_str:sub(offset + 1) + local n, err = stream.io:write_string(chunk) + if err then + return true, offset, err + end + if n == nil then + return false + end + if n == 0 then + return true, offset + end + + offset = offset + n + if offset >= len then + return true, offset + end + return false + end end ---------------------------------------------------------------------- @@ -192,76 +192,76 @@ end ---@param opts? { min?: integer, max?: integer, terminator?: string, eof_ok?: boolean } ---@return Op function Stream:read_into_op(buf, opts) - assert(self.rx, "stream is not readable") - - opts = opts or {} - local min = opts.min or 1 - local max = opts.max or min - local terminator = opts.terminator - local eof_ok = not not opts.eof_ok - - local step = make_read_step(self, buf, min, max, terminator) - - local function wrap(ret_buf, cnt, err) - if cnt == 0 and not eof_ok then - return nil, cnt, err - end - return ret_buf, cnt, err - end - - return wait.waitable( - function(task) - return self.io:on_readable(task) - end, - step, - wrap - ) + assert(self.rx, 'stream is not readable') + + opts = opts or {} + local min = opts.min or 1 + local max = opts.max or min + local terminator = opts.terminator + local eof_ok = not not opts.eof_ok + + local step = make_read_step(self, buf, min, max, terminator) + + local function wrap(ret_buf, cnt, err) + if cnt == 0 and not eof_ok then + return nil, cnt, err + end + return ret_buf, cnt, err + end + + return wait.waitable( + function (task) + return self.io:on_readable(task) + end, + step, + wrap + ) end ---@param opts? { min?: integer, max?: integer, terminator?: string, eof_ok?: boolean } ---@return Op function Stream:read_string_op(opts) - local buf = LinearBuf.new() - local ev = self:read_into_op(buf, opts) - - return ev:wrap(function(ret_buf, cnt, err) - if not ret_buf then - return nil, cnt, err - end - local s = ret_buf:tostring() - if cnt == 0 and s == "" then - return nil, 0, err - end - return s, cnt, err - end) + local buf = LinearBuf.new() + local ev = self:read_into_op(buf, opts) + + return ev:wrap(function (ret_buf, cnt, err) + if not ret_buf then + return nil, cnt, err + end + local s = ret_buf:tostring() + if cnt == 0 and s == '' then + return nil, 0, err + end + return s, cnt, err + end) end ---@param str string ---@return Op function Stream:write_string_op(str) - assert(self.tx, "stream is not writable") - assert(type(str) == "string", "write_string_op expects a string") - - local step = make_write_step(self, str) - - local function wrap(bytes_written, err) - return bytes_written, err - end - - return wait.waitable( - function(task) - return self.io:on_writable(task) - end, - step, - wrap - ) + assert(self.tx, 'stream is not writable') + assert(type(str) == 'string', 'write_string_op expects a string') + + local step = make_write_step(self, str) + + local function wrap(bytes_written, err) + return bytes_written, err + end + + return wait.waitable( + function (task) + return self.io:on_writable(task) + end, + step, + wrap + ) end ---@return Op function Stream:flush_output_op() - -- Unbuffered write path: there is nothing to flush at the Stream level. - -- Writes only return once the backend has accepted the data (or errored). - return op.always(0, nil) + -- Unbuffered write path: there is nothing to flush at the Stream level. + -- Writes only return once the backend has accepted the data (or errored). + return op.always(0, nil) end ---------------------------------------------------------------------- @@ -271,70 +271,70 @@ end ---@param opts? { terminator?: string, keep_terminator?: boolean, max?: integer } ---@return Op -- when performed: line:string|nil, err:string|nil function Stream:read_line_op(opts) - assert(self.rx, "stream is not readable") + assert(self.rx, 'stream is not readable') - opts = opts or {} - local term = opts.terminator or "\n" - local keep_term = not not opts.keep_terminator - local max_bytes = opts.max or math.huge + opts = opts or {} + local term = opts.terminator or '\n' + local keep_term = not not opts.keep_terminator + local max_bytes = opts.max or math.huge - local ev = self:read_string_op{ - min = max_bytes, - max = max_bytes, - terminator = term, - eof_ok = true, - } + local ev = self:read_string_op { + min = max_bytes, + max = max_bytes, + terminator = term, + eof_ok = true, + } - return ev:wrap(function(s, cnt, err) - if err then return nil, err end + return ev:wrap(function (s, cnt, err) + if err then return nil, err end - if not s or cnt == 0 then return nil, nil end + if not s or cnt == 0 then return nil, nil end - if not keep_term and #term > 0 and s:sub(-#term) == term then - s = s:sub(1, -#term - 1) - end + if not keep_term and #term > 0 and s:sub(- #term) == term then + s = s:sub(1, - #term - 1) + end - return s, nil - end) + return s, nil + end) end ---@param n integer ---@return Op -- when performed: data:string|nil, err:string|nil function Stream:read_exactly_op(n) - assert(type(n) == "number" and n >= 0, "read_exactly_op: n must be non-negative") + assert(type(n) == 'number' and n >= 0, 'read_exactly_op: n must be non-negative') - return self:read_string_op{ - min = n, - max = n, - eof_ok = false, - }:wrap(function(s, cnt, err) - if err then return nil, err end + return self:read_string_op { + min = n, + max = n, + eof_ok = false, + }:wrap(function (s, cnt, err) + if err then return nil, err end - if not s or cnt ~= n then return nil, "short read" end + if not s or cnt ~= n then return nil, 'short read' end - return s, nil - end) + return s, nil + end) end ---@return Op -- when performed: data:string, err:string|nil function Stream:read_all_op() - assert(self.rx, "stream is not readable") - - -- Read until EOF or error in a single op. - local ev = self:read_string_op{ - min = math.huge, - max = math.huge, - eof_ok = true, - } - - return ev:wrap(function(s, _, err) - -- read_string_op returns: - -- s == nil, cnt == 0 : no data at all (EOF or error-before-data) - -- s ~= nil, cnt > 0 : some data read, possibly with err - if not s then return "", err end -- Normalise “no data” to empty string. - - return s, err - end) + assert(self.rx, 'stream is not readable') + + -- Read until EOF or error in a single op. + local ev = self:read_string_op { + min = math.huge, + max = math.huge, + eof_ok = true, + } + + return ev:wrap(function (s, _, err) + -- read_string_op returns: + -- s == nil, cnt == 0 : no data at all (EOF or error-before-data) + -- s ~= nil, cnt > 0 : some data read, possibly with err + if not s then return '', err end -- Normalise “no data” to empty string. + + return s, err + end) end ---------------------------------------------------------------------- @@ -342,54 +342,54 @@ end ---------------------------------------------------------------------- function Stream:flush_input() - if self.rx then - self.rx:reset() - end + if self.rx then + self.rx:reset() + end end ---@return boolean ok, string|nil err function Stream:close() - local ok, err - if self.io and self.io.close then - ok, err = self.io:close() - else - ok, err = true, nil - end - self.rx, self.tx, self.io = nil, nil, nil - return ok, err + local ok, err + if self.io and self.io.close then + ok, err = self.io:close() + else + ok, err = true, nil + end + self.rx, self.tx, self.io = nil, nil, nil + return ok, err end ---@param whence? string ---@param offset? integer ---@return integer|nil pos, string|nil err function Stream:seek(whence, offset) - if not (self.io and self.io.seek) then - return nil, 'stream is not seekable' - end - whence = whence or "cur" - offset = offset or 0 - return self.io:seek(whence, offset) + if not (self.io and self.io.seek) then + return nil, 'stream is not seekable' + end + whence = whence or 'cur' + offset = offset or 0 + return self.io:seek(whence, offset) end ---@param mode '"no"'|'"line"'|'"full"' ---@param _ any ---@return Stream function Stream:setvbuf(mode, _) - if mode == 'no' then - self.line_buffering = false - elseif mode == 'line' then - self.line_buffering = true - elseif mode == 'full' then - self.line_buffering = false - else - error('bad mode: ' .. tostring(mode)) - end - return self + if mode == 'no' then + self.line_buffering = false + elseif mode == 'line' then + self.line_buffering = true + elseif mode == 'full' then + self.line_buffering = false + else + error('bad mode: ' .. tostring(mode)) + end + return self end ---@return string|nil function Stream:filename() - return self.io and self.io.filename + return self.io and self.io.filename end ---------------------------------------------------------------------- @@ -397,27 +397,27 @@ end ---------------------------------------------------------------------- function Stream:read_string(opts) - return perform(self:read_string_op(opts)) + return perform(self:read_string_op(opts)) end function Stream:read_all() - return perform(self:read_all_op()) + return perform(self:read_all_op()) end function Stream:read_exactly(n) - return perform(self:read_exactly_op(n)) + return perform(self:read_exactly_op(n)) end function Stream:write_string(str) - return perform(self:write_string_op(str)) + return perform(self:write_string_op(str)) end function Stream:flush_output() - return perform(self:flush_output_op()) + return perform(self:flush_output_op()) end function Stream:flush() - return self:flush_output() + return self:flush_output() end ---------------------------------------------------------------------- @@ -427,68 +427,68 @@ end ---@param fmt? string|integer ---@return Op -- when performed: value|nil, err|string|nil function Stream:read_op(fmt) - assert(self.rx, "stream is not readable") + assert(self.rx, 'stream is not readable') - local t = type(fmt) + local t = type(fmt) - -- Default / "*l": line without terminator - if fmt == nil or fmt == "*l" then return self:read_line_op() end + -- Default / "*l": line without terminator + if fmt == nil or fmt == '*l' then return self:read_line_op() end - -- "*L": line with terminator - if fmt == "*L" then return self:read_line_op{ keep_terminator = true } end + -- "*L": line with terminator + if fmt == '*L' then return self:read_line_op { keep_terminator = true } end - -- "*a": read all - if fmt == "*a" then return self:read_all_op() end + -- "*a": read all + if fmt == '*a' then return self:read_all_op() end - -- numeric: read up to n bytes - if t == "number" then - local n = fmt - assert(n >= 0, "read_op: n must be non-negative") + -- numeric: read up to n bytes + if t == 'number' then + local n = fmt + assert(n >= 0, 'read_op: n must be non-negative') - -- Lua: f:read(0) returns "" immediately - if n == 0 then return op.always("", nil) end + -- Lua: f:read(0) returns "" immediately + if n == 0 then return op.always('', nil) end - -- read up to n bytes; allow EOF - local ev = self:read_string_op{ min = 1, max = n, eof_ok = true } + -- read up to n bytes; allow EOF + local ev = self:read_string_op { min = 1, max = n, eof_ok = true } - return ev:wrap(function(s, cnt, err) - if err then return nil, err end - if not s or cnt == 0 then return nil, nil end -- EOF before any data - return s, nil - end) - else - error("read_op: invalid format " .. tostring(fmt)) - end + return ev:wrap(function (s, cnt, err) + if err then return nil, err end + if not s or cnt == 0 then return nil, nil end -- EOF before any data + return s, nil + end) + else + error('read_op: invalid format ' .. tostring(fmt)) + end end function Stream:read(fmt) - return perform(self:read_op(fmt)) + return perform(self:read_op(fmt)) end ---@param ... any ---@return Op -- when performed: bytes_written:integer, err:string|nil function Stream:write_op(...) - assert(self.tx, "stream is not writable") - - local n = select("#", ...) - if n == 0 then - -- Match the “no-op but succeed” flavour. - return op.always(0, nil) - end - - local parts = {} - for i = 1, n do - local v = select(i, ...) - -- Follow Lua’s io.write behaviour: tostring each argument. - parts[i] = (type(v) == "string") and v or tostring(v) - end - - local s = table.concat(parts) - return self:write_string_op(s) + assert(self.tx, 'stream is not writable') + + local n = select('#', ...) + if n == 0 then + -- Match the “no-op but succeed” flavour. + return op.always(0, nil) + end + + local parts = {} + for i = 1, n do + local v = select(i, ...) + -- Follow Lua’s io.write behaviour: tostring each argument. + parts[i] = (type(v) == 'string') and v or tostring(v) + end + + local s = table.concat(parts) + return self:write_string_op(s) end function Stream:write(...) - return perform(self:write_op(...)) + return perform(self:write_op(...)) end ---------------------------------------------------------------------- @@ -505,11 +505,11 @@ end ---@param opts? { terminator?: string, keep_terminator?: boolean, max?: integer } ---@return Op local function merge_lines_op(named_streams, opts) - local arms = {} - for name, s in pairs(named_streams) do - arms[name] = s:read_line_op(opts) - end - return op.named_choice(arms) + local arms = {} + for name, s in pairs(named_streams) do + arms[name] = s:read_line_op(opts) + end + return op.named_choice(arms) end ---------------------------------------------------------------------- @@ -517,7 +517,7 @@ end ---------------------------------------------------------------------- return { - open = open, - is_stream = is_stream, - merge_lines_op = merge_lines_op, + open = open, + is_stream = is_stream, + merge_lines_op = merge_lines_op, } diff --git a/src/fibers/oneshot.lua b/src/fibers/oneshot.lua index a97f7b5..c9fa38c 100644 --- a/src/fibers/oneshot.lua +++ b/src/fibers/oneshot.lua @@ -16,52 +16,52 @@ Oneshot.__index = Oneshot ---@param on_after_signal? fun() # optional callback run after signalling all waiters ---@return Oneshot local function new(on_after_signal) - return setmetatable({ - triggered = false, - waiters = {}, - on_after_signal = on_after_signal, - }, Oneshot) + return setmetatable({ + triggered = false, + waiters = {}, + on_after_signal = on_after_signal, + }, Oneshot) end --- Register a waiter. --- If already triggered, the thunk is run immediately. ---@param thunk OneshotWaiter function Oneshot:add_waiter(thunk) - if self.triggered then - thunk() - return - end + if self.triggered then + thunk() + return + end - local ws = self.waiters - ws[#ws + 1] = thunk + local ws = self.waiters + ws[#ws + 1] = thunk end --- Trigger the one-shot. --- All waiters are run once; the optional callback runs afterwards. --- Idempotent: subsequent calls after the first have no effect. function Oneshot:signal() - if self.triggered then return end - self.triggered = true + if self.triggered then return end + self.triggered = true - local ws = self.waiters - for i = 1, #ws do - local f = ws[i] - ws[i] = nil - if f then f() end - end + local ws = self.waiters + for i = 1, #ws do + local f = ws[i] + ws[i] = nil + if f then f() end + end - local cb = self.on_after_signal - if cb then - cb() - end + local cb = self.on_after_signal + if cb then + cb() + end end --- Check whether the one-shot has fired. ---@return boolean function Oneshot:is_triggered() - return self.triggered + return self.triggered end return { - new = new, + new = new, } diff --git a/src/fibers/op.lua b/src/fibers/op.lua index 256d34f..c5598e8 100644 --- a/src/fibers/op.lua +++ b/src/fibers/op.lua @@ -10,10 +10,8 @@ local runtime = require 'fibers.runtime' local safe = require 'coxpcall' local oneshot = require 'fibers.oneshot' -local unpack = rawget(table, "unpack") or _G.unpack -local pack = rawget(table, "pack") or function(...) - return { n = select("#", ...), ... } -end +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 @@ -41,18 +39,18 @@ CompleteTask.__index = CompleteTask --- Check whether the suspension is still waiting. ---@return boolean function Suspension:waiting() - return self.state == 'waiting' + return self.state == 'waiting' end --- Mark a suspension as complete and enqueue it on the scheduler. ---@param wrap WrapFn ---@param ... any function Suspension:complete(wrap, ...) - assert(self:waiting()) - self.state = 'synchronized' - self.wrap = wrap - self.val = pack(...) - self.sched:schedule(self) + assert(self:waiting()) + self.state = 'synchronized' + self.wrap = wrap + self.val = pack(...) + self.sched:schedule(self) end --- Complete a suspension and resume the fiber immediately. @@ -60,9 +58,9 @@ end ---@param ... any ---@return any function Suspension:complete_and_run(wrap, ...) - assert(self:waiting()) - self.state = 'synchronized' - return self.fiber:resume(wrap, ...) + assert(self:waiting()) + self.state = 'synchronized' + return self.fiber:resume(wrap, ...) end --- Create a task that will complete this suspension when run. @@ -70,40 +68,40 @@ end ---@param ... any ---@return CompleteTask function Suspension:complete_task(wrap, ...) - return setmetatable({ suspension = self, wrap = wrap, val = pack(...) }, CompleteTask) + return setmetatable({ suspension = self, wrap = wrap, val = pack(...) }, CompleteTask) end --- Run the suspension completion task as a scheduled task. function Suspension:run() - assert(not self:waiting()) - return self.fiber:resume(self.wrap, unpack(self.val, 1, self.val.n)) + assert(not self:waiting()) + return self.fiber:resume(self.wrap, unpack(self.val, 1, self.val.n)) end ---@param sched Scheduler ---@param fib any ---@return Suspension 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 CompleteTask completes a suspension (if still waiting) when run. function CompleteTask:run() - if self.suspension:waiting() then - self.suspension:complete_and_run(self.wrap, unpack(self.val, 1, self.val.n)) - end + if self.suspension:waiting() then + self.suspension:complete_and_run(self.wrap, unpack(self.val, 1, self.val.n)) + end end --- Cancel a CompleteTask, completing the suspension with a tagged result. ---@param reason? string function CompleteTask:cancel(reason) - if self.suspension:waiting() then - local msg = reason or 'cancelled' - local function cancelled_wrap() - -- Convention: (ok:boolean, value_or_reason:any) - return false, msg - end - self.suspension:complete(cancelled_wrap) - end + if self.suspension:waiting() then + local msg = reason or 'cancelled' + local function cancelled_wrap() + -- Convention: (ok:boolean, value_or_reason:any) + return false, msg + end + self.suspension:complete(cancelled_wrap) + end end ---------------------------------------------------------------------- @@ -152,15 +150,15 @@ local perform ---@param block_fn BlockFn ---@return Op local function new_primitive(wrap_fn, try_fn, block_fn) - return setmetatable( - { - kind = 'prim', - wrap_fn = wrap_fn or id_wrap, - try_fn = try_fn, - block_fn = block_fn, - }, - Op - ) + return setmetatable( + { + kind = 'prim', + wrap_fn = wrap_fn or id_wrap, + try_fn = try_fn, + block_fn = block_fn, + }, + Op + ) end --- Choice op over a non-empty list of sub-ops. @@ -168,25 +166,25 @@ end ---@param ... Op ---@return Op local function choice(...) - local ops = {} - for _, op in ipairs({ ... }) do - if op.kind == 'choice' then - for _, sub in ipairs(op.ops) do - ops[#ops + 1] = sub - end - else - ops[#ops + 1] = op - end - end - if #ops == 1 then return ops[1] end - return setmetatable({ kind = 'choice', ops = ops }, Op) + local ops = {} + for _, op in ipairs({ ... }) do + if op.kind == 'choice' then + for _, sub in ipairs(op.ops) do + ops[#ops + 1] = sub + end + else + ops[#ops + 1] = op + end + end + if #ops == 1 then return ops[1] end + return setmetatable({ kind = 'choice', ops = ops }, Op) end --- Delayed op builder; executed once per synchronisation. ---@param g fun(): Op ---@return Op local function guard(g) - return setmetatable({ kind = 'guard', builder = g }, Op) + return setmetatable({ kind = 'guard', builder = g }, Op) end --- CML-style with_nack. @@ -194,29 +192,29 @@ end ---@param g fun(nack_op: Op): Op ---@return Op local function with_nack(g) - return setmetatable({ kind = 'with_nack', builder = g }, Op) + return setmetatable({ kind = 'with_nack', builder = g }, Op) end --- Op that is immediately ready with the given results. ---@param ... any ---@return Op local function always(...) - local results = pack(...) - local function try() - return true, unpack(results, 1, results.n) - end - local function block() error("always: block_fn should never run") end - return new_primitive(nil, try, block) + local results = pack(...) + local function try() + return true, unpack(results, 1, results.n) + end + local function block() error('always: block_fn should never run') end + return new_primitive(nil, try, block) end --- Op that never becomes ready. ---@return Op local function never() - return new_primitive( - nil, - function() return false end, - function() end - ) + return new_primitive( + nil, + function () return false end, + function () end + ) end --- Wrap this op with a post-processing function f (commit phase). @@ -224,10 +222,10 @@ end ---@param f WrapFn ---@return Op function Op:wrap(f) - return setmetatable( - { kind = 'wrap', inner = self, wrap_fn = f }, - Op - ) + return setmetatable( + { kind = 'wrap', inner = self, wrap_fn = f }, + Op + ) end --- Attach an abort handler to this op. @@ -235,11 +233,11 @@ end ---@param f fun() ---@return Op function Op:on_abort(f) - assert(type(f) == 'function', "on_abort expects a function") - return setmetatable( - { kind = 'abort', inner = self, abort_fn = f }, - Op - ) + assert(type(f) == 'function', 'on_abort expects a function') + return setmetatable( + { kind = 'abort', inner = self, abort_fn = f }, + Op + ) end ---------------------------------------------------------------------- @@ -250,42 +248,42 @@ end ---@param opts? { abort_fn: fun() } ---@return NackCond local function new_cond(opts) - local abort_fn = opts and opts.abort_fn or nil - - -- Oneshot runs abort_fn (if any) after all waiters have been invoked. - local os = oneshot.new(function() - if abort_fn then - safe.pcall(abort_fn) - end - end) - - local function wait_op() - assert(not abort_fn, "abort-only cond has no wait_op") - - local function try() - return os:is_triggered() - end - - local function block(suspension, wrap_fn) - -- If already triggered, add_waiter will run the thunk immediately. - os:add_waiter(function() - if suspension:waiting() then - suspension:complete(wrap_fn) - end - end) - end - - return new_primitive(nil, try, block) - end - - local function signal() - os:signal() - end - - return { - wait_op = wait_op, - signal = signal, - } + local abort_fn = opts and opts.abort_fn or nil + + -- Oneshot runs abort_fn (if any) after all waiters have been invoked. + local os = oneshot.new(function () + if abort_fn then + safe.pcall(abort_fn) + end + end) + + local function wait_op() + assert(not abort_fn, 'abort-only cond has no wait_op') + + local function try() + return os:is_triggered() + end + + local function block(suspension, wrap_fn) + -- If already triggered, add_waiter will run the thunk immediately. + os:add_waiter(function () + if suspension:waiting() then + suspension:complete(wrap_fn) + end + end) + end + + return new_primitive(nil, try, block) + end + + local function signal() + os:signal() + end + + return { + wait_op = wait_op, + signal = signal, + } end ---------------------------------------------------------------------- @@ -298,59 +296,60 @@ end ---@param nacks? NackCond[] ---@return CompiledLeaf[] local function compile_op(op, outer_wrap, out, nacks) - out = out or {} - outer_wrap = outer_wrap or id_wrap - nacks = nacks or {} - - local kind = op.kind - - if kind == 'choice' then - for _, sub in ipairs(op.ops) do - compile_op(sub, outer_wrap, out, nacks) - end - - elseif kind == 'guard' then - local inner = op.builder() - compile_op(inner, outer_wrap, out, nacks) - - elseif kind == 'with_nack' then - local cond = new_cond() - local nack_op = cond.wait_op() - local inner = op.builder(nack_op) - - local child_nacks = { unpack(nacks) } - child_nacks[#child_nacks + 1] = cond - compile_op(inner, outer_wrap, out, child_nacks) - - elseif kind == 'wrap' then - -- Wraps compose in declaration order: op:wrap(f1):wrap(f2) → f2(f1(...)). - local f = assert(op.wrap_fn) - local new_outer = function(...) - return outer_wrap(f(...)) - end - compile_op(op.inner, new_outer, out, nacks) - - elseif kind == 'abort' then - local cond = new_cond{ abort_fn = op.abort_fn } - local child_nacks = { unpack(nacks) } - child_nacks[#child_nacks + 1] = cond - compile_op(op.inner, outer_wrap, out, child_nacks) - - else -- 'prim' - local function wrapped(...) - -- Any Lua error here is treated as a bug and propagates normally. - return outer_wrap(op.wrap_fn(...)) - end - - out[#out + 1] = { - try_fn = op.try_fn, - block_fn = op.block_fn, - wrap = wrapped, - nacks = nacks, - } - end - - return out + out = out or {} + outer_wrap = outer_wrap or id_wrap + nacks = nacks or {} + + local kind = op.kind + + if kind == 'choice' then + for _, sub in ipairs(op.ops) do + compile_op(sub, outer_wrap, out, nacks) + end + + elseif kind == 'guard' then + local inner = op.builder() + compile_op(inner, outer_wrap, out, nacks) + + elseif kind == 'with_nack' then + local cond = new_cond() + local nack_op = cond.wait_op() + local inner = op.builder(nack_op) + local child_nacks = { unpack(nacks) } + + child_nacks[#child_nacks + 1] = cond + compile_op(inner, outer_wrap, out, child_nacks) + + elseif kind == 'wrap' then + -- Wraps compose in declaration order: op:wrap(f1):wrap(f2) → f2(f1(...)). + local f = assert(op.wrap_fn) + local new_outer = function (...) + return outer_wrap(f(...)) + end + compile_op(op.inner, new_outer, out, nacks) + + elseif kind == 'abort' then + local cond = new_cond { abort_fn = op.abort_fn } + local child_nacks = { unpack(nacks) } + + child_nacks[#child_nacks + 1] = cond + compile_op(op.inner, outer_wrap, out, child_nacks) + + else -- 'prim' + local function wrapped(...) + -- Any Lua error here is treated as a bug and propagates normally. + return outer_wrap(op.wrap_fn(...)) + end + + out[#out + 1] = { + try_fn = op.try_fn, + block_fn = op.block_fn, + wrap = wrapped, + nacks = nacks, + } + end + + return out end ---------------------------------------------------------------------- @@ -361,36 +360,36 @@ end ---@param ops CompiledLeaf[] ---@param winner_index? integer local function trigger_nacks(ops, winner_index) - local winner_set - if winner_index then - winner_set = {} - local wnacks = ops[winner_index].nacks - if wnacks then - for i = 1, #wnacks do - winner_set[wnacks[i]] = true - end - end - end - - local function is_winner_cond(cond) - return winner_set and winner_set[cond] or false - end - - local signalled = {} - for i = 1, #ops do - if not winner_index or i ~= winner_index then - local nacks = ops[i].nacks - if nacks then - for j = #nacks, 1, -1 do - local cond = nacks[j] - if cond and not is_winner_cond(cond) and not signalled[cond] then - signalled[cond] = true - cond.signal() - end - end - end - end - end + local winner_set + if winner_index then + winner_set = {} + local wnacks = ops[winner_index].nacks + if wnacks then + for i = 1, #wnacks do + winner_set[wnacks[i]] = true + end + end + end + + local function is_winner_cond(cond) + return winner_set and winner_set[cond] or false + end + + local signalled = {} + for i = 1, #ops do + if not winner_index or i ~= winner_index then + local nacks = ops[i].nacks + if nacks then + for j = #nacks, 1, -1 do + local cond = nacks[j] + if cond and not is_winner_cond(cond) and not signalled[cond] then + signalled[cond] = true + cond.signal() + end + end + end + end + end end --- Try once to find a ready leaf in ops (random probe order). @@ -398,18 +397,18 @@ end ---@param ops CompiledLeaf[] ---@return integer|nil, table|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 + 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 --- Apply a leaf's wrap to its packed results. @@ -417,9 +416,8 @@ end ---@param retval table|nil ---@return any ... local function apply_wrap(wrap, retval) - assert(retval ~= nil, "apply_wrap: retval must not be nil") - ---@cast retval table - return wrap(unpack(retval, 2, retval.n)) + assert(retval ~= nil, 'apply_wrap: retval must not be nil') + return wrap(unpack(retval, 2, retval.n)) end ---------------------------------------------------------------------- @@ -430,23 +428,23 @@ end ---@param fallback_thunk fun(): any ---@return Op function Op:or_else(fallback_thunk) - assert(type(fallback_thunk) == "function", "or_else expects a function") + assert(type(fallback_thunk) == 'function', 'or_else expects a function') - return guard(function() - local leaves = compile_op(self) + return guard(function () + local leaves = compile_op(self) - local idx, retval = try_ready(leaves) - if idx then - trigger_nacks(leaves, idx) - local results = pack(apply_wrap(leaves[idx].wrap, retval)) - return always(unpack(results, 1, results.n)) - end + local idx, retval = try_ready(leaves) + if idx then + trigger_nacks(leaves, idx) + local results = pack(apply_wrap(leaves[idx].wrap, retval)) + return always(unpack(results, 1, results.n)) + end - trigger_nacks(leaves, nil) + trigger_nacks(leaves, nil) - local results = pack(fallback_thunk()) - return always(unpack(results, 1, results.n)) - end) + local results = pack(fallback_thunk()) + return always(unpack(results, 1, results.n)) + end) end ---------------------------------------------------------------------- @@ -458,10 +456,10 @@ end ---@param fib any ---@param ops CompiledLeaf[] 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) - end + local suspension = new_suspension(sched, fib) + for _, op in ipairs(ops) do + op.block_fn(suspension, op.wrap) + end end ---------------------------------------------------------------------- @@ -472,31 +470,31 @@ end --- Must be called from within a fiber; errors propagate as normal Lua errors. ---@param op Op ---@return any ... -perform = function(op) - assert(runtime.current_fiber(), "perform_raw must be called from inside a fiber (use fibers.run as an entry point)") - local leaves = compile_op(op) - - -- Fast path: non-blocking attempt. - local idx, retval = try_ready(leaves) - if idx then - trigger_nacks(leaves, idx) - return apply_wrap(leaves[idx].wrap, retval) - end - - -- Slow path: block all leaves. - local suspended = pack(runtime.suspend(block_choice_op, leaves)) - local wrap = suspended[1] - - local winner_index - for i, leaf in ipairs(leaves) do - if leaf.wrap == wrap then - winner_index = i - break - end - end - - trigger_nacks(leaves, winner_index) - return wrap(unpack(suspended, 2, suspended.n)) +perform = function (op) + assert(runtime.current_fiber(), 'perform_raw must be called from inside a fiber (use fibers.run as an entry point)') + local leaves = compile_op(op) + + -- Fast path: non-blocking attempt. + local idx, retval = try_ready(leaves) + if idx then + trigger_nacks(leaves, idx) + return apply_wrap(leaves[idx].wrap, retval) + end + + -- Slow path: block all leaves. + local suspended = pack(runtime.suspend(block_choice_op, leaves)) + local wrap = suspended[1] + + local winner_index + for i, leaf in ipairs(leaves) do + if leaf.wrap == wrap then + winner_index = i + break + end + end + + trigger_nacks(leaves, winner_index) + return wrap(unpack(suspended, 2, suspended.n)) end ---------------------------------------------------------------------- @@ -511,23 +509,23 @@ end ---@param use fun(resource: any): Op ---@return Op local function bracket(acquire, release, use) - assert(type(acquire) == "function", "bracket: acquire must be a function") - assert(type(release) == "function", "bracket: release must be a function") - assert(type(use) == "function", "bracket: use must be a function") - - return guard(function() - local res = acquire() - local op = use(res) - - local wrapped = op:wrap(function(...) - release(res, false) - return ... - end) - - return wrapped:on_abort(function() - release(res, true) - end) - end) + assert(type(acquire) == 'function', 'bracket: acquire must be a function') + assert(type(release) == 'function', 'bracket: release must be a function') + assert(type(use) == 'function', 'bracket: use must be a function') + + return guard(function () + local res = acquire() + local op = use(res) + + local wrapped = op:wrap(function (...) + release(res, false) + return ... + end) + + return wrapped:on_abort(function () + release(res, true) + end) + end) end ---------------------------------------------------------------------- @@ -539,13 +537,13 @@ end ---@param cleanup fun(aborted: boolean) ---@return Op function Op:finally(cleanup) - assert(type(cleanup) == "function", "finally expects a function") + assert(type(cleanup) == 'function', 'finally expects a function') - return bracket( - function() return nil end, - function(_, aborted) cleanup(aborted) end, - function() return self end - ) + return bracket( + function () return nil end, + function (_, aborted) cleanup(aborted) end, + function () return self end + ) end ---------------------------------------------------------------------- @@ -557,37 +555,37 @@ end ---@param on_win fun(index: integer, ...: any): ... ---@return Op local function race(ops, on_win) - assert(type(on_win) == "function", "race expects on_win callback") - local wrapped = {} - for i, op in ipairs(ops) do - wrapped[i] = op:wrap(function(...) - return on_win(i, ...) - end) - end - return choice(unpack(wrapped)) + assert(type(on_win) == 'function', 'race expects on_win callback') + local wrapped = {} + for i, op in ipairs(ops) do + wrapped[i] = op:wrap(function (...) + return on_win(i, ...) + end) + end + return choice(unpack(wrapped)) end --- Race ops and return (index, ...results...) of the winner. ---@param ops Op[] ---@return Op local function first_ready(ops) - return race(ops, function(i, ...) - return i, ... - end) + return race(ops, function (i, ...) + return i, ... + end) end --- Choice over a table of namedå ops, returning (name, ...results...). ---@param arms table ---@return Op local function named_choice(arms) - local ops, names = {}, {} - for name, op in pairs(arms) do - names[#names + 1] = name - ops[#ops + 1] = op - end - return race(ops, function(i, ...) - return names[i], ... - end) + local ops, names = {}, {} + for name, op in pairs(arms) do + names[#names + 1] = name + ops[#ops + 1] = op + end + return race(ops, function (i, ...) + return names[i], ... + end) end --- Choice between two ops, returning (boolean, ...results...). @@ -596,13 +594,13 @@ end ---@param op_false Op ---@return Op local function boolean_choice(op_true, op_false) - return race({ op_true, op_false }, function(i, ...) - if i == 1 then - return true, ... - else - return false, ... - end - end) + return race({ op_true, op_false }, function (i, ...) + if i == 1 then + return true, ... + else + return false, ... + end + end) end ---------------------------------------------------------------------- @@ -610,17 +608,17 @@ end ---------------------------------------------------------------------- return { - perform_raw = perform, - new_primitive = new_primitive, - choice = choice, - guard = guard, - with_nack = with_nack, - bracket = bracket, - always = always, - never = never, - Op = Op, - race = race, - first_ready = first_ready, - named_choice = named_choice, - boolean_choice = boolean_choice, + perform_raw = perform, + new_primitive = new_primitive, + choice = choice, + guard = guard, + with_nack = with_nack, + bracket = bracket, + always = always, + never = never, + Op = Op, + race = race, + first_ready = first_ready, + named_choice = named_choice, + boolean_choice = boolean_choice, } diff --git a/src/fibers/performer.lua b/src/fibers/performer.lua index dd92f02..5244e18 100644 --- a/src/fibers/performer.lua +++ b/src/fibers/performer.lua @@ -15,18 +15,18 @@ local scope_mod --- Get the current scope if the scope module has been loaded. ---@return Scope|nil local function current_scope() - if not scope_mod then - scope_mod = require 'fibers.scope' - end - return scope_mod.current and scope_mod.current() or nil + if not scope_mod then + scope_mod = require 'fibers.scope' + end + return scope_mod.current and scope_mod.current() or nil end --- Check that a value is an Op instance. ---@param op any local function assert_op(op) - if type(op) ~= "table" or getmetatable(op) ~= Op.Op then - error(("perform: expected op, got %s (%s)"):format(type(op), tostring(op)), 3) - end + if type(op) ~= 'table' or getmetatable(op) ~= Op.Op then + error(('perform: expected op, got %s (%s)'):format(type(op), tostring(op)), 3) + end end --- Perform an op under the current scope, if any. @@ -34,17 +34,17 @@ end ---@param op Op ---@return any ... local function perform(op) - assert(Runtime.current_fiber(), "perform: must be called from inside a fiber (use fibers.run as an entry point)") - assert_op(op) + assert(Runtime.current_fiber(), 'perform: must be called from inside a fiber (use fibers.run as an entry point)') + assert_op(op) - local s = current_scope() - if s and s.perform then - return s:perform(op) - else - return Op.perform_raw(op) - end + local s = current_scope() + if s and s.perform then + return s:perform(op) + else + return Op.perform_raw(op) + end end return { - perform = perform, + perform = perform, } diff --git a/src/fibers/runtime.lua b/src/fibers/runtime.lua index 57f71d9..2dff83d 100644 --- a/src/fibers/runtime.lua +++ b/src/fibers/runtime.lua @@ -11,7 +11,7 @@ local sched = require 'fibers.sched' ---@param ... T ---@return T ... local function id(...) - return ... + return ... end --- Record of an uncaught fiber error. @@ -24,7 +24,7 @@ end ---@field fiber Fiber ---@type FiberErrorRecord[] -local error_queue = {} +local error_queue = {} ---@type ErrorWaiterRecord[] local error_waiters = {} @@ -39,7 +39,7 @@ WaiterTask.__index = WaiterTask --- Resume the waiting fiber with (wrap, err_fiber, err). function WaiterTask:run() - self.waiter:resume(id, self.err_fiber, self.err) + self.waiter:resume(id, self.err_fiber, self.err) end --- Cooperative fiber object managed by the runtime. @@ -61,19 +61,19 @@ local current_scheduler = sched.new() --- The function is called as fn(wrap, ...), where wrap is typically the identity. ---@param fn fun(wrap: fun(...: any): any, ...: any) local function spawn(fn) - local tb = debug.traceback("", 2):match("\n[^\n]*\n(.*)") or "" - if _current_fiber and _current_fiber.traceback then - tb = tb .. "\n" .. _current_fiber.traceback - end - - current_scheduler:schedule( - setmetatable({ - coroutine = coroutine.create(fn), - alive = true, - sockets = {}, - traceback = tb, - }, Fiber) - ) + local tb = debug.traceback('', 2):match('\n[^\n]*\n(.*)') or '' + if _current_fiber and _current_fiber.traceback then + tb = tb .. '\n' .. _current_fiber.traceback + end + + current_scheduler:schedule( + setmetatable({ + coroutine = coroutine.create(fn), + alive = true, + sockets = {}, + traceback = tb, + }, Fiber) + ) end --- Resume execution of this fiber. @@ -81,32 +81,32 @@ end ---@param wrap fun(...: any): any ---@param ... any function Fiber:resume(wrap, ...) - assert(self.alive, "dead fiber") - local saved_current_fiber = _current_fiber - _current_fiber = self - local ok, err = coroutine.resume(self.coroutine, wrap, ...) - _current_fiber = saved_current_fiber - - if coroutine.status(self.coroutine) == "dead" then - self.alive = false - end - - if not ok then - -- Report uncaught error to any waiting fiber, or queue it. - if #error_waiters > 0 then - local waiter = table.remove(error_waiters, 1) - current_scheduler:schedule(setmetatable({ - waiter = waiter.fiber, - err_fiber = self, - err = err, - }, WaiterTask)) - else - error_queue[#error_queue + 1] = { - fiber = self, - err = err, - } - end - end + assert(self.alive, 'dead fiber') + local saved_current_fiber = _current_fiber + _current_fiber = self + local ok, err = coroutine.resume(self.coroutine, wrap, ...) + _current_fiber = saved_current_fiber + + if coroutine.status(self.coroutine) == 'dead' then + self.alive = false + end + + if not ok then + -- Report uncaught error to any waiting fiber, or queue it. + if #error_waiters > 0 then + local waiter = table.remove(error_waiters, 1) + current_scheduler:schedule(setmetatable({ + waiter = waiter.fiber, + err_fiber = self, + err = err, + }, WaiterTask)) + else + error_queue[#error_queue + 1] = { + fiber = self, + err = err, + } + end + end end --- Alias for :resume, so a Fiber can be scheduled as a Task. @@ -118,27 +118,27 @@ Fiber.run = Fiber.resume ---@param ... any ---@return any ... function Fiber:suspend(block_fn, ...) - assert(_current_fiber == self) - block_fn(current_scheduler, assert(_current_fiber), ...) - return coroutine.yield() + assert(_current_fiber == self) + block_fn(current_scheduler, assert(_current_fiber), ...) + return coroutine.yield() end --- Return the captured creation traceback for this fiber, if any. ---@return string function Fiber:get_traceback() - return self.traceback or "No traceback available" + return self.traceback or 'No traceback available' end --- Return the current Fiber object, or nil if not inside a fiber. ---@return Fiber|nil local function current_fiber() - return _current_fiber + return _current_fiber end --- Current scheduler time in monotonic seconds. ---@return number local function now() - return current_scheduler:now() + return current_scheduler:now() end --- Suspend the current fiber using block_fn. @@ -147,22 +147,22 @@ end ---@param ... any ---@return any ... local function suspend(block_fn, ...) - assert(_current_fiber, "can only suspend from inside a fiber") - return _current_fiber:suspend(block_fn, ...) + assert(_current_fiber, 'can only suspend from inside a fiber') + return _current_fiber:suspend(block_fn, ...) end --- Yield the current fiber and re-queue it as runnable. ---@return any ... local function yield() - assert(current_fiber(), "can only yield from inside a fiber") - return suspend(function(scheduler, fiber) - scheduler:schedule(fiber) - end) + assert(current_fiber(), 'can only yield from inside a fiber') + return suspend(function (scheduler, fiber) + scheduler:schedule(fiber) + end) end --- Request that the global scheduler stops its main loop. local function stop() - current_scheduler:stop() + current_scheduler:stop() end --- Wait for the next uncaught fiber error. @@ -170,36 +170,36 @@ end ---@return Fiber err_fiber ---@return any err local function wait_fiber_error() - if #error_queue > 0 then - local rec = table.remove(error_queue, 1) - return rec.fiber, rec.err - end + if #error_queue > 0 then + local rec = table.remove(error_queue, 1) + return rec.fiber, rec.err + end - assert(_current_fiber, "wait_fiber_error must be called from within a fiber") + assert(_current_fiber, 'wait_fiber_error must be called from within a fiber') - local function block_fn(_, fib) - error_waiters[#error_waiters + 1] = { fiber = fib } - end + local function block_fn(_, fib) + error_waiters[#error_waiters + 1] = { fiber = fib } + end - local _, err_fiber, err = _current_fiber:suspend(block_fn) - return err_fiber, err + local _, err_fiber, err = _current_fiber:suspend(block_fn) + return err_fiber, err end --- Run the main event loop using the global scheduler. local function main() - return current_scheduler:main() + return current_scheduler:main() end return { - current_scheduler = current_scheduler, - current_fiber = current_fiber, - now = now, - suspend = suspend, - yield = yield, - wait_fiber_error = wait_fiber_error, - - -- fiber management - spawn_raw = spawn, - stop = stop, - main = main, + current_scheduler = current_scheduler, + current_fiber = current_fiber, + now = now, + suspend = suspend, + yield = yield, + wait_fiber_error = wait_fiber_error, + + -- fiber management + spawn_raw = spawn, + stop = stop, + main = main, } diff --git a/src/fibers/sched.lua b/src/fibers/sched.lua index b6045f5..8700e4c 100644 --- a/src/fibers/sched.lua +++ b/src/fibers/sched.lua @@ -35,37 +35,37 @@ Scheduler.__index = Scheduler ---@param get_time? fun(): number # monotonic time source (defaults to fibers.utils.time.monotonic) ---@return Scheduler local function new(get_time) - local now_src = get_time or time.monotonic - local now = now_src() - - local ret = setmetatable({ - next = {}, - cur = {}, - sources = {}, - wheel = timer.new(now), - maxsleep = MAX_SLEEP_TIME, - get_time = now_src, - event_waiter = nil, - done = false, - }, Scheduler) - - --- Timer source: advances the wheel and schedules due tasks. - ---@class TimerTaskSource : TaskSource - ---@field wheel Timer - local timer_task_source = { wheel = ret.wheel } - - --- Advance the timer wheel and schedule any due tasks. - ---@param sched Scheduler - ---@param now_ number - function timer_task_source:schedule_tasks(sched, now_) - self.wheel:advance(now_, sched) - end - - function timer_task_source:cancel_all_tasks() - end - - ret:add_task_source(timer_task_source) - return ret + local now_src = get_time or time.monotonic + local now = now_src() + + local ret = setmetatable({ + next = {}, + cur = {}, + sources = {}, + wheel = timer.new(now), + maxsleep = MAX_SLEEP_TIME, + get_time = now_src, + event_waiter = nil, + done = false, + }, Scheduler) + + --- Timer source: advances the wheel and schedules due tasks. + ---@class TimerTaskSource : TaskSource + ---@field wheel Timer + local timer_task_source = { wheel = ret.wheel } + + --- Advance the timer wheel and schedule any due tasks. + ---@param sched Scheduler + ---@param now_ number + function timer_task_source:schedule_tasks(sched, now_) + self.wheel:advance(now_, sched) + end + + function timer_task_source:cancel_all_tasks() + end + + ret:add_task_source(timer_task_source) + return ret end --- Register a task source with this scheduler. @@ -74,69 +74,69 @@ end --- sole event waiter (overwriting any previous one). ---@param source TaskSource function Scheduler:add_task_source(source) - table.insert(self.sources, source) - if source.wait_for_events then - self.event_waiter = source - end + table.insert(self.sources, source) + if source.wait_for_events then + self.event_waiter = source + end end --- Schedule a task to be run on the next turn. ---@param task Task function Scheduler:schedule(task) - table.insert(self.next, task) + table.insert(self.next, task) end --- Get current monotonic time from the scheduler's clock source. ---@return number function Scheduler:monotime() - return self.get_time() + return self.get_time() end --- Get the last time observed by the timer wheel. ---@return number function Scheduler:now() - return self.wheel.now + return self.wheel.now end --- Schedule a task at an absolute time. ---@param t number # absolute time on the scheduler clock ---@param task Task function Scheduler:schedule_at_time(t, task) - self.wheel:add_absolute(t, task) + self.wheel:add_absolute(t, task) end --- Schedule a task after a delay from the wheel's current time. ---@param dt number # delay in seconds ---@param task Task function Scheduler:schedule_after_sleep(dt, task) - self.wheel:add_delta(dt, task) + self.wheel:add_delta(dt, task) end --- Ask all registered sources to enqueue any ready tasks. ---@param now number function Scheduler:schedule_tasks_from_sources(now) - for i = 1, #self.sources do - self.sources[i]:schedule_tasks(self, now) - end + for i = 1, #self.sources do + self.sources[i]:schedule_tasks(self, now) + end end --- Run all tasks currently scheduled as runnable. --- If now is nil, the current monotonic time is used. ---@param now? number function Scheduler:run(now) - if now == nil then - now = self:monotime() - end + if now == nil then + now = self:monotime() + end - self:schedule_tasks_from_sources(now) + self:schedule_tasks_from_sources(now) - self.cur, self.next = self.next, self.cur + self.cur, self.next = self.next, self.cur - for i = 1, #self.cur do - local task = self.cur[i] - self.cur[i] = nil - task:run() - end + for i = 1, #self.cur do + local task = self.cur[i] + self.cur[i] = nil + task:run() + end end --- Compute the next time the scheduler may need to wake. @@ -144,42 +144,42 @@ end --- Otherwise defers to the timer wheel, which returns a time or math.huge. ---@return number function Scheduler:next_wake_time() - if #self.next > 0 then - return self:now() - end - return self.wheel:next_entry_time() + if #self.next > 0 then + return self:now() + end + return self.wheel:next_entry_time() end --- Block until the next event or timeout. --- Uses an event_waiter (e.g. poller) if present, otherwise sleeps. function Scheduler:wait_for_events() - local now = self:monotime() - local next_time = self:next_wake_time() - - local timeout = math.min(self.maxsleep, next_time - now) - if timeout < 0 then timeout = 0 end - - if self.event_waiter then - self.event_waiter:wait_for_events(self, now, timeout) - else - -- No poller installed; fall back to process-blocking sleep. - time._block(timeout) - end + local now = self:monotime() + local next_time = self:next_wake_time() + + local timeout = math.min(self.maxsleep, next_time - now) + if timeout < 0 then timeout = 0 end + + if self.event_waiter then + self.event_waiter:wait_for_events(self, now, timeout) + else + -- No poller installed; fall back to process-blocking sleep. + time._block(timeout) + end end --- Request that the scheduler main loop stops after the current iteration. function Scheduler:stop() - self.done = true + self.done = true end --- Run the scheduler main loop until stopped. --- Repeatedly waits for events and runs ready tasks. function Scheduler:main() - self.done = false - repeat - self:wait_for_events() - self:run(self:monotime()) - until self.done + self.done = false + repeat + self:wait_for_events() + self:run(self:monotime()) + until self.done end --- Attempt to drain runnable work and ask sources to cancel outstanding tasks. @@ -188,21 +188,21 @@ end --- Returns true if the runnable queue is drained within the iteration limit. ---@return boolean drained # true if work queue drained, false on iteration limit function Scheduler:shutdown() - for _ = 1, 100 do - for i = 1, #self.sources do - local src = self.sources[i] - if src.cancel_all_tasks then - src:cancel_all_tasks(self) - end - end - - if #self.next == 0 then return true end - - self:run() - end - return false + for _ = 1, 100 do + for i = 1, #self.sources do + local src = self.sources[i] + if src.cancel_all_tasks then + src:cancel_all_tasks(self) + end + end + + if #self.next == 0 then return true end + + self:run() + end + return false end return { - new = new, + new = new, } diff --git a/src/fibers/scope.lua b/src/fibers/scope.lua index ff966ee..fefa3f9 100644 --- a/src/fibers/scope.lua +++ b/src/fibers/scope.lua @@ -85,10 +85,10 @@ local function new_scope(parent) _parent = parent, _children = setmetatable({}, { __mode = 'k' }), - _status = 'running', - _error = nil, + _status = 'running', + _error = nil, _extra_errors = {}, - failure_mode = 'fail_fast', + failure_mode = 'fail_fast', _wg = waitgroup.new(), _finalisers = {}, diff --git a/src/fibers/sleep.lua b/src/fibers/sleep.lua index 5a21870..4eb9fd4 100644 --- a/src/fibers/sleep.lua +++ b/src/fibers/sleep.lua @@ -13,51 +13,51 @@ local perform = require 'fibers.performer'.perform ---@param t number # absolute time on the runtime clock ---@return Op local function deadline_op(t) - local function try() - return runtime.now() >= t - end - - --- Schedule completion of the suspension at time t. - ---@param suspension Suspension - ---@param wrap_fn WrapFn - local function block(suspension, wrap_fn) - suspension.sched:schedule_at_time(t, suspension:complete_task(wrap_fn)) - end - - return op.new_primitive(nil, try, block) + local function try() + return runtime.now() >= t + end + + --- Schedule completion of the suspension at time t. + ---@param suspension Suspension + ---@param wrap_fn WrapFn + local function block(suspension, wrap_fn) + suspension.sched:schedule_at_time(t, suspension:complete_task(wrap_fn)) + end + + return op.new_primitive(nil, try, block) end --- Op that sleeps until absolute time t. ---@param t number # absolute time on the runtime clock ---@return Op local function sleep_until_op(t) - return deadline_op(t) + return deadline_op(t) end --- Sleep until absolute time t. ---@param t number # absolute time on the runtime clock local function sleep_until(t) - return perform(sleep_until_op(t)) + return perform(sleep_until_op(t)) end --- Op that sleeps for a duration dt. ---@param dt number # delay in seconds ---@return Op local function sleep_op(dt) - return op.guard(function() - return deadline_op(runtime.now() + dt) - end) + return op.guard(function () + return deadline_op(runtime.now() + dt) + end) end --- Sleep for a duration dt. ---@param dt number # delay in seconds local function sleep(dt) - return perform(sleep_op(dt)) + return perform(sleep_op(dt)) end return { - sleep = sleep, - sleep_op = sleep_op, - sleep_until = sleep_until, - sleep_until_op = sleep_until_op, + sleep = sleep, + sleep_op = sleep_op, + sleep_until = sleep_until, + sleep_until_op = sleep_until_op, } diff --git a/src/fibers/timer.lua b/src/fibers/timer.lua index 9092da7..d49ce8f 100644 --- a/src/fibers/timer.lua +++ b/src/fibers/timer.lua @@ -18,78 +18,78 @@ Heap.__index = Heap ---@return Heap local function new_heap() - return setmetatable({ heap = {}, size = 0 }, Heap) + return setmetatable({ heap = {}, size = 0 }, Heap) end ---@param node TimerNode function Heap:push(node) - local size = self.size + 1 - self.size = size - self.heap[size] = node - self:heapify_up(size) + local size = self.size + 1 + self.size = size + self.heap[size] = node + self:heapify_up(size) end ---@return TimerNode|nil function Heap:pop() - local size = self.size - if size == 0 then - return nil - end - - local heap = self.heap - local root = heap[1] - - if size == 1 then - heap[1] = nil - self.size = 0 - return root - end - - heap[1] = heap[size] - heap[size] = nil - self.size = size - 1 - self:heapify_down(1) - - return root + local size = self.size + if size == 0 then + return nil + end + + local heap = self.heap + local root = heap[1] + + if size == 1 then + heap[1] = nil + self.size = 0 + return root + end + + heap[1] = heap[size] + heap[size] = nil + self.size = size - 1 + self:heapify_down(1) + + return root end ---@param idx integer function Heap:heapify_up(idx) - local heap = self.heap - while idx > 1 do - local parent = floor(idx / 2) - if heap[parent].time <= heap[idx].time then - break - end - heap[parent], heap[idx] = heap[idx], heap[parent] - idx = parent - end + local heap = self.heap + while idx > 1 do + local parent = floor(idx / 2) + if heap[parent].time <= heap[idx].time then + break + end + heap[parent], heap[idx] = heap[idx], heap[parent] + idx = parent + end end ---@param idx integer function Heap:heapify_down(idx) - local heap = self.heap - local size = self.size - - while true do - local left = 2 * idx - local right = left + 1 - local smallest = idx - - if left <= size and heap[left].time < heap[smallest].time then - smallest = left - end - if right <= size and heap[right].time < heap[smallest].time then - smallest = right - end - - if smallest == idx then - break - end - - heap[idx], heap[smallest] = heap[smallest], heap[idx] - idx = smallest - end + local heap = self.heap + local size = self.size + + while true do + local left = 2 * idx + local right = left + 1 + local smallest = idx + + if left <= size and heap[left].time < heap[smallest].time then + smallest = left + end + if right <= size and heap[right].time < heap[smallest].time then + smallest = right + end + + if smallest == idx then + break + end + + heap[idx], heap[smallest] = heap[smallest], heap[idx] + idx = smallest + end end ---@class Timer @@ -102,49 +102,49 @@ Timer.__index = Timer ---@param now number # initial monotonic time. ---@return Timer local function new(now) - return setmetatable({ now = now, heap = new_heap() }, Timer) + return setmetatable({ now = now, heap = new_heap() }, Timer) end --- Schedule an object at absolute time t. ---@param t number # absolute due time ---@param obj any # payload to pass to the scheduler function Timer:add_absolute(t, obj) - self.heap:push { time = t, obj = obj } + self.heap:push { time = t, obj = obj } end --- Schedule an object after a delay from the current timer time. ---@param dt number # delay in seconds from self.now ---@param obj any # payload to pass to the scheduler function Timer:add_delta(dt, obj) - self:add_absolute(self.now + dt, obj) + self:add_absolute(self.now + dt, obj) end --- Get the time of the next scheduled entry, or math.huge if none exist. ---@return number function Timer:next_entry_time() - local heap = self.heap - return heap.size > 0 and heap.heap[1].time or huge + local heap = self.heap + return heap.size > 0 and heap.heap[1].time or huge end --- Pop the next scheduled entry without dispatching it. ---@return TimerNode|nil function Timer:pop() - return self.heap:pop() + return self.heap:pop() end --- Advance the timer to time t and dispatch all due entries. ---@param t number # new monotonic time ---@param sched { schedule: fun(self:any, obj:any) } function Timer:advance(t, sched) - local heap = self.heap + local heap = self.heap - while heap.size > 0 and t >= heap.heap[1].time do - local node = assert(heap:pop()) -- non-nil since size>0 - self.now = node.time - sched:schedule(node.obj) - end + while heap.size > 0 and t >= heap.heap[1].time do + local node = assert(heap:pop()) -- non-nil since size>0 + self.now = node.time + sched:schedule(node.obj) + end - self.now = t + self.now = t end return { new = new } diff --git a/src/fibers/utils/bytes.lua b/src/fibers/utils/bytes.lua index ee5ee60..685e495 100644 --- a/src/fibers/utils/bytes.lua +++ b/src/fibers/utils/bytes.lua @@ -36,16 +36,15 @@ ---@field is_supported fun(): boolean local candidates = { - 'fibers.utils.bytes.ffi', - 'fibers.utils.bytes.lua', + 'fibers.utils.bytes.ffi', + 'fibers.utils.bytes.lua', } for _, name in ipairs(candidates) do - local ok, mod = pcall(require, name) - if ok and type(mod) == "table" and mod.is_supported and mod.is_supported() then - return mod - end + local ok, mod = pcall(require, name) + if ok and type(mod) == 'table' and mod.is_supported and mod.is_supported() then + return mod + end end - -error("fibers.utils.bytes: no suitable bytes backend available on this platform") +error('fibers.utils.bytes: no suitable bytes backend available on this platform') diff --git a/src/fibers/utils/bytes/ffi.lua b/src/fibers/utils/bytes/ffi.lua index c087c09..2e09249 100644 --- a/src/fibers/utils/bytes/ffi.lua +++ b/src/fibers/utils/bytes/ffi.lua @@ -6,20 +6,20 @@ ---@module 'fibers.utils.bytes.ffi' -local bit = rawget(_G, "bit") or require 'bit32' -local ffi_c = require 'fibers.utils.ffi_compat' +local bit = rawget(_G, 'bit') or require 'bit32' +local ffi_c = require 'fibers.utils.ffi_compat' -- If there is no usable FFI layer, mark this backend unsupported. if not (ffi_c.is_supported and ffi_c.is_supported()) then - return { - is_supported = function() return false end, - } + return { + is_supported = function () return false end, + } end -local ffi = ffi_c.ffi -local band = bit.band +local ffi = ffi_c.ffi +local band = bit.band -ffi.cdef[[ +ffi.cdef [[ typedef unsigned int uint32_t; typedef unsigned char uint8_t; @@ -32,17 +32,17 @@ ffi.cdef[[ ]] local ring_mt, lin_mt = {}, {} -ring_mt.__index = ring_mt -lin_mt.__index = lin_mt +ring_mt.__index = ring_mt +lin_mt.__index = lin_mt -local ring_ct = ffi.metatype("fibers_ringbuf_t", ring_mt) +local ring_ct = ffi.metatype('fibers_ringbuf_t', ring_mt) local function to_u32(n) - return n % 2^32 + return n % 2 ^ 32 end local function pos(self, idx) - return band(idx, self.size - 1) + return band(idx, self.size - 1) end ---------------------------------------------------------------------- @@ -51,115 +51,115 @@ end --- Initialise ring buffer. function ring_mt:init(size) - assert(type(size) == "number" and size > 0, "RingBuf: positive size required") - assert(band(size, size - 1) == 0, "RingBuf: size must be power of two") - self.size = size - self.read_idx = 0 - self.write_idx = 0 - return self + assert(type(size) == 'number' and size > 0, 'RingBuf: positive size required') + assert(band(size, size - 1) == 0, 'RingBuf: size must be power of two') + self.size = size + self.read_idx = 0 + self.write_idx = 0 + return self end function ring_mt:reset() - self.read_idx, self.write_idx = 0, 0 + self.read_idx, self.write_idx = 0, 0 end function ring_mt:read_avail() - return to_u32(self.write_idx - self.read_idx) + return to_u32(self.write_idx - self.read_idx) end function ring_mt:write_avail() - return self.size - self:read_avail() + return self.size - self:read_avail() end function ring_mt:is_empty() - return self.read_idx == self.write_idx + return self.read_idx == self.write_idx end function ring_mt:is_full() - return self:read_avail() == self.size + return self:read_avail() == self.size end local function copy_out(self, n) - local tmp = ffi.new("uint8_t[?]", n) - local size = self.size - local start = pos(self, self.read_idx) - local first = math.min(n, size - start) + local tmp = ffi.new('uint8_t[?]', n) + local size = self.size + local start = pos(self, self.read_idx) + local first = math.min(n, size - start) - if first > 0 then - ffi.copy(tmp, self.buf + start, first) - end + if first > 0 then + ffi.copy(tmp, self.buf + start, first) + end - local rest = n - first - if rest > 0 then - ffi.copy(tmp + first, self.buf, rest) - end + local rest = n - first + if rest > 0 then + ffi.copy(tmp + first, self.buf, rest) + end - self.read_idx = self.read_idx + ffi.cast("uint32_t", n) - return tmp + self.read_idx = self.read_idx + ffi.cast('uint32_t', n) + return tmp end local function copy_in(self, src, n) - local size = self.size - local start = pos(self, self.write_idx) - local first = math.min(n, size - start) + local size = self.size + local start = pos(self, self.write_idx) + local first = math.min(n, size - start) - if first > 0 then - ffi.copy(self.buf + start, src, first) - end + if first > 0 then + ffi.copy(self.buf + start, src, first) + end - local rest = n - first - if rest > 0 then - ffi.copy(self.buf, src + first, rest) - end + local rest = n - first + if rest > 0 then + ffi.copy(self.buf, src + first, rest) + end - self.write_idx = self.write_idx + ffi.cast("uint32_t", n) + self.write_idx = self.write_idx + ffi.cast('uint32_t', n) end function ring_mt:put(str) - assert(type(str) == "string", "RingBuf:put expects a string") - local n = #str - if n == 0 then return end - assert(n <= self:write_avail(), "RingBuf: write would exceed capacity") - local tmp = ffi.new("uint8_t[?]", n) - ffi.copy(tmp, str, n) - copy_in(self, tmp, n) + assert(type(str) == 'string', 'RingBuf:put expects a string') + local n = #str + if n == 0 then return end + assert(n <= self:write_avail(), 'RingBuf: write would exceed capacity') + local tmp = ffi.new('uint8_t[?]', n) + ffi.copy(tmp, str, n) + copy_in(self, tmp, n) end function ring_mt:take(n) - assert(type(n) == "number" and n >= 0, "RingBuf:take expects non-negative count") - local avail = self:read_avail() - if avail == 0 or n == 0 then - return "" - end - if n > avail then - n = avail - end - local tmp = copy_out(self, n) - return ffi.string(tmp, n) + assert(type(n) == 'number' and n >= 0, 'RingBuf:take expects non-negative count') + local avail = self:read_avail() + if avail == 0 or n == 0 then + return '' + end + if n > avail then + n = avail + end + local tmp = copy_out(self, n) + return ffi.string(tmp, n) end function ring_mt:tostring() - local n = self:read_avail() - if n == 0 then - return "" - end - local old = self.read_idx - local tmp = copy_out(self, n) - self.read_idx = old - return ffi.string(tmp, n) + local n = self:read_avail() + if n == 0 then + return '' + end + local old = self.read_idx + local tmp = copy_out(self, n) + self.read_idx = old + return ffi.string(tmp, n) end function ring_mt:find(pattern) - assert(type(pattern) == "string" and #pattern > 0, - "RingBuf:find expects non-empty string") - local s = self:tostring() - local i = s:find(pattern, 1, true) - return i and (i - 1) or nil + assert(type(pattern) == 'string' and #pattern > 0, + 'RingBuf:find expects non-empty string') + local s = self:tostring() + local i = s:find(pattern, 1, true) + return i and (i - 1) or nil end local function RingBuf_new(size) - local self = ring_ct(size) - return ring_mt.init(self, size) + local self = ring_ct(size) + return ring_mt.init(self, size) end ---------------------------------------------------------------------- @@ -167,48 +167,48 @@ end ---------------------------------------------------------------------- local function LinearBuf_new(cap) - cap = cap or 4096 - assert(cap > 0, "LinearBuf.new: positive initial capacity required") - local buf = ffi.new("uint8_t[?]", cap) - return setmetatable({ buf = buf, len = 0, cap = cap }, lin_mt) + cap = cap or 4096 + assert(cap > 0, 'LinearBuf.new: positive initial capacity required') + local buf = ffi.new('uint8_t[?]', cap) + return setmetatable({ buf = buf, len = 0, cap = cap }, lin_mt) end function lin_mt:reset() - self.len = 0 + self.len = 0 end function lin_mt:ensure(extra) - local needed = self.len + extra - if needed <= self.cap then - return - end + local needed = self.len + extra + if needed <= self.cap then + return + end - local new_cap = self.cap > 0 and self.cap or 1 - while new_cap < needed do - new_cap = new_cap * 2 - end + local new_cap = self.cap > 0 and self.cap or 1 + while new_cap < needed do + new_cap = new_cap * 2 + end - local new_buf = ffi.new("uint8_t[?]", new_cap) - if self.len > 0 then - ffi.copy(new_buf, self.buf, self.len) - end - self.buf, self.cap = new_buf, new_cap + local new_buf = ffi.new('uint8_t[?]', new_cap) + if self.len > 0 then + ffi.copy(new_buf, self.buf, self.len) + end + self.buf, self.cap = new_buf, new_cap end function lin_mt:append(str) - assert(type(str) == "string", "LinearBuf:append expects a string") - local n = #str - if n == 0 then return end - self:ensure(n) - ffi.copy(self.buf + self.len, str, n) - self.len = self.len + n + assert(type(str) == 'string', 'LinearBuf:append expects a string') + local n = #str + if n == 0 then return end + self:ensure(n) + ffi.copy(self.buf + self.len, str, n) + self.len = self.len + n end function lin_mt:tostring() - if self.len == 0 then - return "" - end - return ffi.string(self.buf, self.len) + if self.len == 0 then + return '' + end + return ffi.string(self.buf, self.len) end ---------------------------------------------------------------------- @@ -216,8 +216,8 @@ end ---------------------------------------------------------------------- return { - RingBuf = { new = RingBuf_new }, - LinearBuf = { new = LinearBuf_new }, - has_ffi = true, - is_supported = function() return true end, + RingBuf = { new = RingBuf_new }, + LinearBuf = { new = LinearBuf_new }, + has_ffi = true, + is_supported = function () return true end, } diff --git a/src/fibers/utils/bytes/lua.lua b/src/fibers/utils/bytes/lua.lua index 368c038..af7b85e 100644 --- a/src/fibers/utils/bytes/lua.lua +++ b/src/fibers/utils/bytes/lua.lua @@ -11,23 +11,23 @@ ---------------------------------------------------------------------- local function rope_tostring(chunks, head_idx, head_off) - local n = #chunks - if head_idx > n then - return "" - end + local n = #chunks + if head_idx > n then + return '' + end - local out = {} - local first = chunks[head_idx] - if head_off > 0 then - first = first:sub(head_off + 1) - end - out[1] = first + local out = {} + local first = chunks[head_idx] + if head_off > 0 then + first = first:sub(head_off + 1) + end + out[1] = first - for i = head_idx + 1, n do - out[#out + 1] = chunks[i] - end + for i = head_idx + 1, n do + out[#out + 1] = chunks[i] + end - return table.concat(out) + return table.concat(out) end ---------------------------------------------------------------------- @@ -39,167 +39,167 @@ local RingBuf_mt = {} RingBuf_mt.__index = RingBuf_mt local function RingBuf_new(size) - assert(type(size) == "number" and size > 0, - "RingBuf.new: positive size required") - return setmetatable({ - chunks = {}, - head_idx = 1, - head_off = 0, - len = 0, - size = size, - }, RingBuf_mt) + assert(type(size) == 'number' and size > 0, + 'RingBuf.new: positive size required') + return setmetatable({ + chunks = {}, + head_idx = 1, + head_off = 0, + len = 0, + size = size, + }, RingBuf_mt) end local function compact(self) - local hi = self.head_idx - if hi <= 8 and hi <= (#self.chunks / 2) then - return - end - for i = 1, hi - 1 do - self.chunks[i] = nil - end - local k = 1 - for j = hi, #self.chunks do - self.chunks[k] = self.chunks[j] - if k ~= j then - self.chunks[j] = nil - end - k = k + 1 - end - self.head_idx = 1 + local hi = self.head_idx + if hi <= 8 and hi <= (#self.chunks / 2) then + return + end + for i = 1, hi - 1 do + self.chunks[i] = nil + end + local k = 1 + for j = hi, #self.chunks do + self.chunks[k] = self.chunks[j] + if k ~= j then + self.chunks[j] = nil + end + k = k + 1 + end + self.head_idx = 1 end function RingBuf_mt:reset() - self.chunks = {} - self.head_idx = 1 - self.head_off = 0 - self.len = 0 + self.chunks = {} + self.head_idx = 1 + self.head_off = 0 + self.len = 0 end function RingBuf_mt:read_avail() - return self.len + return self.len end function RingBuf_mt:write_avail() - return self.size - self.len + return self.size - self.len end function RingBuf_mt:is_empty() - return self.len == 0 + return self.len == 0 end function RingBuf_mt:is_full() - return self.len >= self.size + return self.len >= self.size end function RingBuf_mt:advance_read(n) - assert(n >= 0 and n <= self.len, "RingBuf:advance_read out of range") - if n == 0 then return end + assert(n >= 0 and n <= self.len, 'RingBuf:advance_read out of range') + if n == 0 then return end - self.len = self.len - n + self.len = self.len - n - local i = self.head_idx - local off = self.head_off + local i = self.head_idx + local off = self.head_off - while n > 0 and i <= #self.chunks do - local chunk = self.chunks[i] - local rem = #chunk - off - if n < rem then - off = off + n - n = 0 - else - n = n - rem - i = i + 1 - off = 0 - end - end + while n > 0 and i <= #self.chunks do + local chunk = self.chunks[i] + local rem = #chunk - off + if n < rem then + off = off + n + n = 0 + else + n = n - rem + i = i + 1 + off = 0 + end + end - self.head_idx = i - self.head_off = off - compact(self) + self.head_idx = i + self.head_off = off + compact(self) end function RingBuf_mt:write(src, count) - assert(type(src) == "string", "RingBuf:write expects string") - local n = count or #src - assert(n <= #src, "RingBuf:write count > #src") - assert(n <= self:write_avail(), "RingBuf: write xrun") - if n == 0 then return end + assert(type(src) == 'string', 'RingBuf:write expects string') + local n = count or #src + assert(n <= #src, 'RingBuf:write count > #src') + assert(n <= self:write_avail(), 'RingBuf: write xrun') + if n == 0 then return end - local s = src - if n < #s then - s = s:sub(1, n) - end - self.len = self.len + n - self.chunks[#self.chunks + 1] = s + local s = src + if n < #s then + s = s:sub(1, n) + end + self.len = self.len + n + self.chunks[#self.chunks + 1] = s end function RingBuf_mt:read(_, count) - assert(count >= 0 and count <= self.len, "RingBuf: read xrun") - if count == 0 then - return "" - end - - local out = {} - local need = count - local i = self.head_idx - local off = self.head_off - local n = #self.chunks - - while need > 0 and i <= n do - local chunk = self.chunks[i] - local rem = #chunk - off - local take = math.min(need, rem) - out[#out + 1] = chunk:sub(off + 1, off + take) - need = need - take - if take == rem then - i = i + 1 - off = 0 - else - off = off + take - end - end - - local s = table.concat(out) - self.head_idx = i - self.head_off = off - self.len = self.len - count - compact(self) - return s + assert(count >= 0 and count <= self.len, 'RingBuf: read xrun') + if count == 0 then + return '' + end + + local out = {} + local need = count + local i = self.head_idx + local off = self.head_off + local n = #self.chunks + + while need > 0 and i <= n do + local chunk = self.chunks[i] + local rem = #chunk - off + local take = math.min(need, rem) + out[#out + 1] = chunk:sub(off + 1, off + take) + need = need - take + if take == rem then + i = i + 1 + off = 0 + else + off = off + take + end + end + + local s = table.concat(out) + self.head_idx = i + self.head_off = off + self.len = self.len - count + compact(self) + return s end function RingBuf_mt:put(str) - assert(type(str) == "string", "RingBuf:put expects a string") - local n = #str - if n == 0 then return end - assert(n <= self:write_avail(), "RingBuf: write would exceed capacity") - self:write(str, n) + assert(type(str) == 'string', 'RingBuf:put expects a string') + local n = #str + if n == 0 then return end + assert(n <= self:write_avail(), 'RingBuf: write would exceed capacity') + self:write(str, n) end function RingBuf_mt:take(n) - assert(type(n) == "number" and n >= 0, "RingBuf:take expects non-negative count") - if self.len == 0 or n == 0 then - return "" - end - if n > self.len then - n = self.len - end - return self:read(nil, n) + assert(type(n) == 'number' and n >= 0, 'RingBuf:take expects non-negative count') + if self.len == 0 or n == 0 then + return '' + end + if n > self.len then + n = self.len + end + return self:read(nil, n) end function RingBuf_mt:tostring() - if self.len == 0 then - return "" - end - return rope_tostring(self.chunks, self.head_idx, self.head_off) + if self.len == 0 then + return '' + end + return rope_tostring(self.chunks, self.head_idx, self.head_off) end function RingBuf_mt:find(pattern) - assert(type(pattern) == "string" and #pattern > 0, - "RingBuf:find expects non-empty string") - local s = self:tostring() - local i = s:find(pattern, 1, true) - return i and (i - 1) or nil + assert(type(pattern) == 'string' and #pattern > 0, + 'RingBuf:find expects non-empty string') + local s = self:tostring() + local i = s:find(pattern, 1, true) + return i and (i - 1) or nil end ---------------------------------------------------------------------- @@ -211,32 +211,32 @@ local LinearBuf_mt = {} LinearBuf_mt.__index = LinearBuf_mt local function LinearBuf_new(_) - return setmetatable({ - chunks = {}, - head_idx = 1, - head_off = 0, - len = 0, - }, LinearBuf_mt) + return setmetatable({ + chunks = {}, + head_idx = 1, + head_off = 0, + len = 0, + }, LinearBuf_mt) end function LinearBuf_mt:reset() - self.chunks = {} - self.head_idx = 1 - self.head_off = 0 - self.len = 0 + self.chunks = {} + self.head_idx = 1 + self.head_off = 0 + self.len = 0 end function LinearBuf_mt:append(s) - if not s or #s == 0 then return end - self.len = self.len + #s - self.chunks[#self.chunks + 1] = s + if not s or #s == 0 then return end + self.len = self.len + #s + self.chunks[#self.chunks + 1] = s end function LinearBuf_mt:tostring() - if self.len == 0 then - return "" - end - return rope_tostring(self.chunks, self.head_idx, self.head_off) + if self.len == 0 then + return '' + end + return rope_tostring(self.chunks, self.head_idx, self.head_off) end ---------------------------------------------------------------------- @@ -244,8 +244,8 @@ end ---------------------------------------------------------------------- return { - RingBuf = { new = RingBuf_new }, - LinearBuf = { new = LinearBuf_new }, - has_ffi = false, - is_supported = function() return true end, + RingBuf = { new = RingBuf_new }, + LinearBuf = { new = LinearBuf_new }, + has_ffi = false, + is_supported = function () return true end, } diff --git a/src/fibers/utils/ffi_compat.lua b/src/fibers/utils/ffi_compat.lua index 6c40a7e..2decd15 100644 --- a/src/fibers/utils/ffi_compat.lua +++ b/src/fibers/utils/ffi_compat.lua @@ -14,46 +14,47 @@ local ok, ffi = pcall(require, 'ffi') local provider = 'luajit_ffi' if not ok then - local ok2, cffi = pcall(require, 'cffi') - if not ok2 then - return { - is_supported = function() return false end, - } - end - ffi = cffi - provider = 'cffi' + local ok2, cffi = pcall(require, 'cffi') + if not ok2 then + return { + is_supported = function () return false end, + } + end + ffi = cffi + provider = 'cffi' end -- Normalise helper functions. -local tonumber_fn = ffi.tonumber or tonumber -local type_fn = ffi.type or type +local tonumber_fn = rawget(ffi, 'tonumber') or tonumber +local type_fn = rawget(ffi, 'type') or type local function errno() - -- Both LuaJIT ffi and cffi expose errno() in their API. - return ffi.errno() + -- Both LuaJIT ffi and cffi expose errno() in their API. + return ffi.errno() end local function is_null(ptr) - -- For LuaJIT ffi, NULL pointers compare equal to nil. - -- For cffi, you must compare with cffi.nullptr. - if ptr == nil then - return true - end - if ffi.nullptr and ptr == ffi.nullptr then - return true - end - return false + -- For LuaJIT ffi, NULL pointers compare equal to nil. + -- For cffi, you must compare with cffi.nullptr. + if ptr == nil then + return true + end + local ffi_nullptr = rawget(ffi, 'nullptr') + if ffi_nullptr and ptr == ffi_nullptr then + return true + end + return false end local M = { - ffi = ffi, - C = ffi.C, - provider = provider, - tonumber = tonumber_fn, - type = type_fn, - errno = errno, - is_null = is_null, - is_supported = function() return true end, + ffi = ffi, + C = ffi.C, + provider = provider, + tonumber = tonumber_fn, + type = type_fn, + errno = errno, + is_null = is_null, + is_supported = function () return true end, } return M diff --git a/src/fibers/utils/fifo.lua b/src/fibers/utils/fifo.lua index ee7997f..2632185 100644 --- a/src/fibers/utils/fifo.lua +++ b/src/fibers/utils/fifo.lua @@ -8,49 +8,49 @@ Fifo.__index = Fifo --- Create a new FIFO queue. -- @treturn Fifo The created FIFO queue. local function new() - return setmetatable({ - count = 0, -- total items ever pushed - first = 1, -- index of first item - items = {} -- storage table - }, Fifo) + return setmetatable({ + count = 0, -- total items ever pushed + first = 1, -- index of first item + items = {} -- storage table + }, Fifo) end --- Push a value onto the queue. -- @param x The value to push (can be nil) function Fifo:push(x) - self.count = self.count + 1 - self.items[self.count] = x + self.count = self.count + 1 + self.items[self.count] = x end --- Check if the queue is empty. -- @treturn boolean True if empty function Fifo:empty() - return self.first > self.count + return self.first > self.count end --- Peek at the first item without removing it. -- @return The first item function Fifo:peek() - assert(not self:empty(), "queue is empty") - return self.items[self.first] + assert(not self:empty(), 'queue is empty') + return self.items[self.first] end --- Remove and return the first item. -- @return The first item function Fifo:pop() - assert(not self:empty(), "queue is empty") - local val = self.items[self.first] - self.items[self.first] = nil -- allow GC - self.first = self.first + 1 - return val + assert(not self:empty(), 'queue is empty') + local val = self.items[self.first] + self.items[self.first] = nil -- allow GC + self.first = self.first + 1 + return val end --- Return the length of the queue. -- @return The queue length function Fifo:length() - return self.count - self.first + 1 + return self.count - self.first + 1 end return { - new = new + new = new } diff --git a/src/fibers/utils/time.lua b/src/fibers/utils/time.lua index 3dd0c7e..1621b63 100644 --- a/src/fibers/utils/time.lua +++ b/src/fibers/utils/time.lua @@ -11,24 +11,24 @@ ---@module 'fibers.utils.time' local candidates = { - 'fibers.utils.time.ffi', - 'fibers.utils.time.luaposix', - 'fibers.utils.time.nixio', - 'fibers.utils.time.linux', + 'fibers.utils.time.ffi', + 'fibers.utils.time.luaposix', + 'fibers.utils.time.nixio', + 'fibers.utils.time.linux', } local chosen for _, name in ipairs(candidates) do - local ok, mod = pcall(require, name) - if ok and type(mod) == "table" and mod.is_supported and mod.is_supported() then - chosen = mod - break - end + local ok, mod = pcall(require, name) + if ok and type(mod) == 'table' and mod.is_supported and mod.is_supported() then + chosen = mod + break + end end if not chosen then - error("fibers.utils.time: no suitable time backend available on this platform") + error('fibers.utils.time: no suitable time backend available on this platform') end return chosen diff --git a/src/fibers/utils/time/core.lua b/src/fibers/utils/time/core.lua index dd195da..4b954ee 100644 --- a/src/fibers/utils/time/core.lua +++ b/src/fibers/utils/time/core.lua @@ -23,66 +23,66 @@ ---@field is_supported fun(): boolean|nil -- optional local function build_backend(ops) - assert(type(ops) == "table", "time backend ops must be a table") - assert(type(ops.realtime) == "function", "time ops.realtime must be a function") - assert(type(ops.monotonic) == "function", "time ops.monotonic must be a function") - assert(type(ops._block) == "function", "time ops._block must be a function") - assert(type(ops.realtime_info) == "table", "time ops.realtime_info must be a table") - assert(type(ops.monotonic_info) == "table", "time ops.monotonic_info must be a table") - assert(type(ops.block_info) == "table", "time ops.block_info must be a table") + assert(type(ops) == 'table', 'time backend ops must be a table') + assert(type(ops.realtime) == 'function', 'time ops.realtime must be a function') + assert(type(ops.monotonic) == 'function', 'time ops.monotonic must be a function') + assert(type(ops._block) == 'function', 'time ops._block must be a function') + assert(type(ops.realtime_info) == 'table', 'time ops.realtime_info must be a table') + assert(type(ops.monotonic_info) == 'table', 'time ops.monotonic_info must be a table') + assert(type(ops.block_info) == 'table', 'time ops.block_info must be a table') - local function realtime() - return ops.realtime() - end + local function realtime() + return ops.realtime() + end - local function monotonic() - return ops.monotonic() - end + local function monotonic() + return ops.monotonic() + end - local function block(dt) - assert(type(dt) == "number" and dt >= 0, "block: dt must be a non-negative number") - return ops._block(dt) - end + local function block(dt) + assert(type(dt) == 'number' and dt >= 0, 'block: dt must be a non-negative number') + return ops._block(dt) + end - local function info() - return { - realtime = ops.realtime_info, - monotonic = ops.monotonic_info, - sleep = ops.block_info, - } - end + local function info() + return { + realtime = ops.realtime_info, + monotonic = ops.monotonic_info, + sleep = ops.block_info, + } + end - local function realtime_source() - return ops.realtime_info - end + local function realtime_source() + return ops.realtime_info + end - local function monotonic_source() - return ops.monotonic_info - end + local function monotonic_source() + return ops.monotonic_info + end - local function block_source() - return ops.block_info - end + local function block_source() + return ops.block_info + end - local function is_supported() - if type(ops.is_supported) == "function" then - return not not ops.is_supported() - end - return true - end + local function is_supported() + if type(ops.is_supported) == 'function' then + return not not ops.is_supported() + end + return true + end - return { - realtime = realtime, - monotonic = monotonic, - _block = block, - info = info, - realtime_source = realtime_source, - monotonic_source = monotonic_source, - block_source = block_source, - is_supported = is_supported, - } + return { + realtime = realtime, + monotonic = monotonic, + _block = block, + info = info, + realtime_source = realtime_source, + monotonic_source = monotonic_source, + block_source = block_source, + is_supported = is_supported, + } end return { - build_backend = build_backend, + build_backend = build_backend, } diff --git a/src/fibers/utils/time/ffi.lua b/src/fibers/utils/time/ffi.lua index 6a8583e..26ed3c4 100644 --- a/src/fibers/utils/time/ffi.lua +++ b/src/fibers/utils/time/ffi.lua @@ -9,7 +9,7 @@ local core = require 'fibers.utils.time.core' local ffi_c = require 'fibers.utils.ffi_compat' if not (ffi_c.is_supported and ffi_c.is_supported()) then - return { is_supported = function() return false end } + return { is_supported = function () return false end } end local ffi = ffi_c.ffi @@ -17,7 +17,7 @@ local C = ffi_c.C local toint = ffi_c.tonumber local get_errno = ffi_c.errno -ffi.cdef[[ +ffi.cdef [[ typedef long time_t; typedef long suseconds_t; @@ -43,35 +43,35 @@ local EINTR = 4 ---------------------------------------------------------------------- local function strerror(e) - local s = C.strerror(e) - if s == nil then - return "errno " .. tostring(e) - end - return ffi.string(s) + local s = C.strerror(e) + if s == nil then + return 'errno ' .. tostring(e) + end + return ffi.string(s) end local function ts_to_seconds(ts) - return tonumber(ts.tv_sec) + tonumber(ts.tv_nsec) * 1e-9 + return tonumber(ts.tv_sec) + tonumber(ts.tv_nsec) * 1e-9 end local function read_clock(clk_id) - local ts = ffi.new("struct timespec[1]") - local rc = toint(C.clock_gettime(clk_id, ts)) - if rc ~= 0 then - local e = get_errno() - error("clock_gettime failed: " .. strerror(e)) - end - return ts_to_seconds(ts[0]) + local ts = ffi.new('struct timespec[1]') + local rc = toint(C.clock_gettime(clk_id, ts)) + if rc ~= 0 then + local e = get_errno() + error('clock_gettime failed: ' .. strerror(e)) + end + return ts_to_seconds(ts[0]) end local function clock_resolution(clk_id) - local ts = ffi.new("struct timespec[1]") - local rc = toint(C.clock_getres(clk_id, ts)) - if rc ~= 0 then - -- Fall back to a conservative default (1 ms) if the query fails. - return 1e-3 - end - return ts_to_seconds(ts[0]) + local ts = ffi.new('struct timespec[1]') + local rc = toint(C.clock_getres(clk_id, ts)) + if rc ~= 0 then + -- Fall back to a conservative default (1 ms) if the query fails. + return 1e-3 + end + return ts_to_seconds(ts[0]) end ---------------------------------------------------------------------- @@ -79,38 +79,38 @@ end ---------------------------------------------------------------------- local function _block(dt) - if dt <= 0 then - return true, nil - end - - local req = ffi.new("struct timespec[1]") - local rem = ffi.new("struct timespec[1]") - - local sec = math.floor(dt) - local frac = dt - sec - local nsec = math.floor(frac * 1e9 + 0.5) - if nsec >= 1000000000 then - sec = sec + 1 - nsec = nsec - 1000000000 - end - - req[0].tv_sec = sec - req[0].tv_nsec = nsec - - while true do - local rc = toint(C.nanosleep(req, rem)) - if rc == 0 then - return true, nil - end - local e = get_errno() - if e == EINTR then - -- Interrupted; continue with remaining time. - req[0].tv_sec = rem[0].tv_sec - req[0].tv_nsec = rem[0].tv_nsec - else - return false, "nanosleep failed: " .. strerror(e) - end - end + if dt <= 0 then + return true, nil + end + + local req = ffi.new('struct timespec[1]') + local rem = ffi.new('struct timespec[1]') + + local sec = math.floor(dt) + local frac = dt - sec + local nsec = math.floor(frac * 1e9 + 0.5) + if nsec >= 1000000000 then + sec = sec + 1 + nsec = nsec - 1000000000 + end + + req[0].tv_sec = sec + req[0].tv_nsec = nsec + + while true do + local rc = toint(C.nanosleep(req, rem)) + if rc == 0 then + return true, nil + end + local e = get_errno() + if e == EINTR then + -- Interrupted; continue with remaining time. + req[0].tv_sec = rem[0].tv_sec + req[0].tv_nsec = rem[0].tv_nsec + else + return false, 'nanosleep failed: ' .. strerror(e) + end + end end ---------------------------------------------------------------------- @@ -121,46 +121,46 @@ local realtime_res = clock_resolution(CLOCK_REALTIME) local monotonic_res = clock_resolution(CLOCK_MONOTONIC) local function is_supported() - -- Probe both clocks once; treat errors as lack of support. - local ok = pcall(function() - read_clock(CLOCK_REALTIME) - read_clock(CLOCK_MONOTONIC) - end) - return ok + -- Probe both clocks once; treat errors as lack of support. + local ok = pcall(function () + read_clock(CLOCK_REALTIME) + read_clock(CLOCK_MONOTONIC) + end) + return ok end local ops = { - realtime = function() - return read_clock(CLOCK_REALTIME) - end, - - monotonic = function() - return read_clock(CLOCK_MONOTONIC) - end, - - realtime_info = { - name = "clock_gettime(CLOCK_REALTIME)", - resolution = realtime_res, - monotonic = false, - epoch = "unix", - }, - - monotonic_info = { - name = "clock_gettime(CLOCK_MONOTONIC)", - resolution = monotonic_res, - monotonic = true, - epoch = "unspecified", - }, - - _block = _block, - - block_info = { - name = "nanosleep", - resolution = monotonic_res, - clock = "monotonic", - }, - - is_supported = is_supported, + realtime = function () + return read_clock(CLOCK_REALTIME) + end, + + monotonic = function () + return read_clock(CLOCK_MONOTONIC) + end, + + realtime_info = { + name = 'clock_gettime(CLOCK_REALTIME)', + resolution = realtime_res, + monotonic = false, + epoch = 'unix', + }, + + monotonic_info = { + name = 'clock_gettime(CLOCK_MONOTONIC)', + resolution = monotonic_res, + monotonic = true, + epoch = 'unspecified', + }, + + _block = _block, + + block_info = { + name = 'nanosleep', + resolution = monotonic_res, + clock = 'monotonic', + }, + + is_supported = is_supported, } diff --git a/src/fibers/utils/time/linux.lua b/src/fibers/utils/time/linux.lua index 2a3fcf7..e24abbb 100644 --- a/src/fibers/utils/time/linux.lua +++ b/src/fibers/utils/time/linux.lua @@ -12,46 +12,46 @@ local core = require 'fibers.utils.time.core' ---------------------------------------------------------------------- local function read_uptime_raw() - local f = io.open("/proc/uptime", "r") - if not f then - return nil - end - local line = f:read("*l") - f:close() - if not line then - return nil - end - local first = line:match("^(%S+)") - return first + local f = io.open('/proc/uptime', 'r') + if not f then + return nil + end + local line = f:read('*l') + f:close() + if not line then + return nil + end + local first = line:match('^(%S+)') + return first end local function read_uptime() - local token = read_uptime_raw() - if not token then - return nil - end - local v = tonumber(token) - return v + local token = read_uptime_raw() + if not token then + return nil + end + local v = tonumber(token) + return v end -- Estimate resolution from the number of fractional digits in /proc/uptime. local monotonic_resolution = 1.0 do - local token = read_uptime_raw() - if token then - local frac = token:match("%.([0-9]+)") - if frac and #frac > 0 then - monotonic_resolution = 10 ^ (-#frac) - end - end + local token = read_uptime_raw() + if token then + local frac = token:match('%.([0-9]+)') + if frac and #frac > 0 then + monotonic_resolution = 10 ^ (- #frac) + end + end end local function monotonic() - local v = read_uptime() - if not v then - error("fallback monotonic: /proc/uptime not available") - end - return v + local v = read_uptime() + if not v then + error('fallback monotonic: /proc/uptime not available') + end + return v end ---------------------------------------------------------------------- @@ -63,20 +63,20 @@ local realtime_name local realtime_resolution do - local ok, socket = pcall(require, 'socket') - if ok and type(socket) == "table" and type(socket.gettime) == "function" then - realtime_fn = socket.gettime - realtime_name = "socket.gettime" - realtime_resolution = 1e-6 -- approximate; depends on platform - else - realtime_fn = function() return os.time() end - realtime_name = "os.time" - realtime_resolution = 1.0 - end + local ok, socket = pcall(require, 'socket') + if ok and type(socket) == 'table' and type(socket.gettime) == 'function' then + realtime_fn = socket.gettime + realtime_name = 'socket.gettime' + realtime_resolution = 1e-6 -- approximate; depends on platform + else + realtime_fn = function () return os.time() end + realtime_name = 'os.time' + realtime_resolution = 1.0 + end end local function realtime() - return realtime_fn() + return realtime_fn() end ---------------------------------------------------------------------- @@ -84,60 +84,61 @@ end ---------------------------------------------------------------------- local function command_succeeded(a, b, c) - -- Lua 5.1: boolean, "exit"/"signal", code - if a == true then - return true - end - -- Lua 5.2+: numeric exit code - if type(a) == "number" then - return a == 0 - end - -- Some implementations: nil, "exit", code - if a == nil and b == "exit" then - return c == 0 - end - return false + -- Lua 5.1: boolean, "exit"/"signal", code + if a == true then + return true + end + -- Lua 5.2+: numeric exit code + if type(a) == 'number' then + return a == 0 + end + -- Some implementations: nil, "exit", code + if a == nil and b == 'exit' then + return c == 0 + end + return false end local function run_sleep(arg) - local cmd = "sleep " .. arg - local a, b, c = os.execute(cmd) - return command_succeeded(a, b, c) + local cmd = 'sleep ' .. arg + local a, b, c = os.execute(cmd) + return command_succeeded(a, b, c) end -local has_fractional_sleep do - local ok = run_sleep("0.01") - has_fractional_sleep = ok +local has_fractional_sleep +do + local ok = run_sleep('0.01') + has_fractional_sleep = ok end local function _block(dt) - if dt <= 0 then - return true, nil - end - - if has_fractional_sleep then - -- Round to centiseconds. - local centis = math.max(1, math.floor(dt * 100 + 0.5)) - local arg = string.format("%.2f", centis / 100.0) - local ok = run_sleep(arg) - if not ok then - -- As a last resort, busy-wait using monotonic time. - local start = monotonic() - while monotonic() - start < dt do end - return true, "sleep command failed; used busy-wait" - end - return true, nil - else - -- Integral seconds only; round up so we do not undersleep. - local secs = math.ceil(dt) - local ok = run_sleep(tostring(secs)) - if not ok then - local start = monotonic() - while monotonic() - start < dt do end - return true, "sleep command failed; used busy-wait" - end - return true, nil - end + if dt <= 0 then + return true, nil + end + + if has_fractional_sleep then + -- Round to centiseconds. + local centis = math.max(1, math.floor(dt * 100 + 0.5)) + local arg = string.format('%.2f', centis / 100.0) + local ok = run_sleep(arg) + if not ok then + -- As a last resort, busy-wait using monotonic time. + local start = monotonic() + while monotonic() - start < dt do end + return true, 'sleep command failed; used busy-wait' + end + return true, nil + else + -- Integral seconds only; round up so we do not undersleep. + local secs = math.ceil(dt) + local ok = run_sleep(tostring(secs)) + if not ok then + local start = monotonic() + while monotonic() - start < dt do end + return true, 'sleep command failed; used busy-wait' + end + return true, nil + end end ---------------------------------------------------------------------- @@ -145,44 +146,44 @@ end ---------------------------------------------------------------------- local function is_supported() - -- This backend is only considered usable if /proc/uptime exists. - local f = io.open("/proc/uptime", "r") - if f then - f:close() - return true - end - return false + -- This backend is only considered usable if /proc/uptime exists. + local f = io.open('/proc/uptime', 'r') + if f then + f:close() + return true + end + return false end local ops = { - realtime = realtime, - monotonic = monotonic, - - realtime_info = { - name = realtime_name, - resolution = realtime_resolution, - monotonic = false, - epoch = "unix", - }, - - monotonic_info = { - name = "/proc/uptime", - resolution = monotonic_resolution, - monotonic = true, - epoch = "unspecified", - }, - - _block = _block, - - block_info = { - name = has_fractional_sleep - and "sleep (shell, fractional)" - or "sleep (shell, integral)", - resolution = has_fractional_sleep and 0.01 or 1.0, - clock = "realtime", - }, - - is_supported = is_supported, + realtime = realtime, + monotonic = monotonic, + + realtime_info = { + name = realtime_name, + resolution = realtime_resolution, + monotonic = false, + epoch = 'unix', + }, + + monotonic_info = { + name = '/proc/uptime', + resolution = monotonic_resolution, + monotonic = true, + epoch = 'unspecified', + }, + + _block = _block, + + block_info = { + name = has_fractional_sleep + and 'sleep (shell, fractional)' + or 'sleep (shell, integral)', + resolution = has_fractional_sleep and 0.01 or 1.0, + clock = 'realtime', + }, + + is_supported = is_supported, } return core.build_backend(ops) diff --git a/src/fibers/utils/time/luaposix.lua b/src/fibers/utils/time/luaposix.lua index d405121..8eb51a4 100644 --- a/src/fibers/utils/time/luaposix.lua +++ b/src/fibers/utils/time/luaposix.lua @@ -7,13 +7,13 @@ local core = require 'fibers.utils.time.core' local ok_time, ptime = pcall(require, 'posix.time') -if not ok_time or type(ptime) ~= "table" then - return { is_supported = function() return false end } +if not ok_time or type(ptime) ~= 'table' then + return { is_supported = function () return false end } end local ok_unistd, unistd = pcall(require, 'posix.unistd') -if not ok_unistd or type(unistd) ~= "table" then - return { is_supported = function() return false end } +if not ok_unistd or type(unistd) ~= 'table' then + return { is_supported = function () return false end } end local errno = require 'posix.errno' @@ -22,7 +22,7 @@ local CLOCK_REALTIME = ptime.CLOCK_REALTIME local CLOCK_MONOTONIC = ptime.CLOCK_MONOTONIC if not CLOCK_REALTIME or not CLOCK_MONOTONIC then - return { is_supported = function() return false end } + return { is_supported = function () return false end } end ---------------------------------------------------------------------- @@ -30,26 +30,26 @@ end ---------------------------------------------------------------------- local function ts_to_seconds(ts) - return ts.tv_sec + ts.tv_nsec * 1e-9 + return ts.tv_sec + ts.tv_nsec * 1e-9 end local function read_clock(clk_id) - local ts, err = ptime.clock_gettime(clk_id) - if not ts then - error("clock_gettime failed: " .. tostring(err)) - end - return ts_to_seconds(ts) + local ts, err = ptime.clock_gettime(clk_id) + if not ts then + error('clock_gettime failed: ' .. tostring(err)) + end + return ts_to_seconds(ts) end local function clock_resolution(clk_id) - if type(ptime.clock_getres) == "function" then - local ts = select(1, ptime.clock_getres(clk_id)) - if ts then - return ts_to_seconds(ts) - end - end - -- Fallback if clock_getres is missing or fails. - return 1e-3 + if type(ptime.clock_getres) == 'function' then + local ts = select(1, ptime.clock_getres(clk_id)) + if ts then + return ts_to_seconds(ts) + end + end + -- Fallback if clock_getres is missing or fails. + return 1e-3 end ---------------------------------------------------------------------- @@ -57,32 +57,32 @@ end ---------------------------------------------------------------------- local function _block(dt) - if dt <= 0 then - return true, nil - end - - local sec = math.floor(dt) - local frac = dt - sec - local nsec = math.floor(frac * 1e9 + 0.5) - if nsec >= 1000000000 then - sec = sec + 1 - nsec = nsec - 1000000000 - end - - local req = { tv_sec = sec, tv_nsec = nsec } - - while true do - local ok, err, eno, rem = ptime.nanosleep(req) - if ok then - return true, nil - end - if eno == errno.EINTR and rem then - -- Interrupted; continue with remaining time. - req = rem - else - return false, err or ("nanosleep failed (errno " .. tostring(eno) .. ")") - end - end + if dt <= 0 then + return true, nil + end + + local sec = math.floor(dt) + local frac = dt - sec + local nsec = math.floor(frac * 1e9 + 0.5) + if nsec >= 1000000000 then + sec = sec + 1 + nsec = nsec - 1000000000 + end + + local req = { tv_sec = sec, tv_nsec = nsec } + + while true do + local ok, err, eno, rem = ptime.nanosleep(req) + if ok then + return true, nil + end + if eno == errno.EINTR and rem then + -- Interrupted; continue with remaining time. + req = rem + else + return false, err or ('nanosleep failed (errno ' .. tostring(eno) .. ')') + end + end end ---------------------------------------------------------------------- @@ -93,44 +93,44 @@ local realtime_res = clock_resolution(CLOCK_REALTIME) local monotonic_res = clock_resolution(CLOCK_MONOTONIC) local function is_supported() - local ok = pcall(function() - read_clock(CLOCK_MONOTONIC) - end) - return ok + local ok = pcall(function () + read_clock(CLOCK_MONOTONIC) + end) + return ok end local ops = { - realtime = function() - return read_clock(CLOCK_REALTIME) - end, - - monotonic = function() - return read_clock(CLOCK_MONOTONIC) - end, - - realtime_info = { - name = "posix.time.clock_gettime(CLOCK_REALTIME)", - resolution = realtime_res, - monotonic = false, - epoch = "unix", - }, - - monotonic_info = { - name = "posix.time.clock_gettime(CLOCK_MONOTONIC)", - resolution = monotonic_res, - monotonic = true, - epoch = "unspecified", - }, - - _block = _block, - - block_info = { - name = "posix.time.nanosleep", - resolution = monotonic_res, - clock = "monotonic", - }, - - is_supported = is_supported, + realtime = function () + return read_clock(CLOCK_REALTIME) + end, + + monotonic = function () + return read_clock(CLOCK_MONOTONIC) + end, + + realtime_info = { + name = 'posix.time.clock_gettime(CLOCK_REALTIME)', + resolution = realtime_res, + monotonic = false, + epoch = 'unix', + }, + + monotonic_info = { + name = 'posix.time.clock_gettime(CLOCK_MONOTONIC)', + resolution = monotonic_res, + monotonic = true, + epoch = 'unspecified', + }, + + _block = _block, + + block_info = { + name = 'posix.time.nanosleep', + resolution = monotonic_res, + clock = 'monotonic', + }, + + is_supported = is_supported, } diff --git a/src/fibers/utils/time/nixio.lua b/src/fibers/utils/time/nixio.lua index cefa7c1..7154a0a 100644 --- a/src/fibers/utils/time/nixio.lua +++ b/src/fibers/utils/time/nixio.lua @@ -7,51 +7,52 @@ local core = require 'fibers.utils.time.core' local ok, nixio = pcall(require, 'nixio') -if not ok or type(nixio) ~= "table" then - return { is_supported = function() return false end } +if not ok or type(nixio) ~= 'table' then + return { is_supported = function () return false end } end ---------------------------------------------------------------------- -- Monotonic time via /proc/uptime ---------------------------------------------------------------------- -local UPTIME_PATH = "/proc/uptime" +local UPTIME_PATH = '/proc/uptime' --- Read the first field from /proc/uptime as a number. ---@return number|nil value, string|nil err local function read_uptime() - local f, err = io.open(UPTIME_PATH, "r") - if not f then - return nil, ("failed to open %s: %s"):format(UPTIME_PATH, tostring(err)) - end - - local line = f:read("*l") - f:close() - - if not line then - return nil, ("failed to read %s: empty file"):format(UPTIME_PATH) - end - - local first = line:match("^%s*(%S+)") - if not first then - return nil, ("failed to parse %s: no fields"):format(UPTIME_PATH) - end - - local val = tonumber(first) - if not val then - return nil, ("failed to parse %s: non-numeric uptime '%s'"):format( - UPTIME_PATH, - tostring(first) - ) - end - - return val, nil + local f, err = io.open(UPTIME_PATH, 'r') + if not f then + return nil, ('failed to open %s: %s'):format(UPTIME_PATH, tostring(err)) + end + + local line = f:read('*l') + f:close() + + if not line then + return nil, ('failed to read %s: empty file'):format(UPTIME_PATH) + end + + local first = line:match('^%s*(%S+)') + if not first then + return nil, ('failed to parse %s: no fields'):format(UPTIME_PATH) + end + + local val = tonumber(first) + if not val then + return nil, ("failed to parse %s: non-numeric uptime '%s'"):format( + UPTIME_PATH, + tostring(first) + ) + end + + return val, nil end -- Probe once at load time so metadata and is_supported() can report accurately. -local monotonic_ok do - local v = read_uptime() - monotonic_ok = (v ~= nil) +local monotonic_ok +do + local v = read_uptime() + monotonic_ok = (v ~= nil) end ---------------------------------------------------------------------- @@ -60,8 +61,8 @@ end --- Wall-clock time: seconds since Unix epoch as a Lua number. local function realtime() - -- nixio.gettime() already returns epoch seconds with fractional part. - return nixio.gettime() + -- nixio.gettime() already returns epoch seconds with fractional part. + return nixio.gettime() end --- Monotonic time in seconds. @@ -71,12 +72,12 @@ end --- nixio.gettime() rather than raising; monotonic_info.name still --- reflects /proc/uptime as the intended primary source. local function monotonic() - local v = read_uptime() - if v ~= nil then - return v - end - -- Degraded path: not truly monotonic, but avoids hard failure. - return nixio.gettime() + local v = read_uptime() + if v ~= nil then + return v + end + -- Degraded path: not truly monotonic, but avoids hard failure. + return nixio.gettime() end ---------------------------------------------------------------------- @@ -86,39 +87,39 @@ end ---@param dt number ---@return boolean ok, string|nil err local function _block(dt) - if type(dt) ~= "number" then - return false, "sleep: dt must be a number" - end - if dt <= 0 then - return true, nil - end - - local deadline = monotonic() + dt - - while true do - local now = monotonic() - local remaining = deadline - now - if remaining <= 0 then - return true, nil - end - - local secs = math.floor(remaining) - if secs < 0 then secs = 0 end - - local frac = remaining - secs - if frac < 0 then frac = 0 end - local nsec = math.floor(frac * 1e9 + 0.5) - - -- nixio.nanosleep(seconds, nanoseconds) - local ok_ns, err, eno = nixio.nanosleep(secs, nsec) - if not ok_ns then - local msg = tostring(err or eno or "") - if msg ~= "" and msg ~= "EINTR" then - return false, ("nixio.nanosleep failed: %s"):format(msg) - end - -- EINTR or unknown soft error: loop again and recompute remaining. - end - end + if type(dt) ~= 'number' then + return false, 'sleep: dt must be a number' + end + if dt <= 0 then + return true, nil + end + + local deadline = monotonic() + dt + + while true do + local now = monotonic() + local remaining = deadline - now + if remaining <= 0 then + return true, nil + end + + local secs = math.floor(remaining) + if secs < 0 then secs = 0 end + + local frac = remaining - secs + if frac < 0 then frac = 0 end + local nsec = math.floor(frac * 1e9 + 0.5) + + -- nixio.nanosleep(seconds, nanoseconds) + local ok_ns, err, eno = nixio.nanosleep(secs, nsec) + if not ok_ns then + local msg = tostring(err or eno or '') + if msg ~= '' and msg ~= 'EINTR' then + return false, ('nixio.nanosleep failed: %s'):format(msg) + end + -- EINTR or unknown soft error: loop again and recompute remaining. + end + end end ---------------------------------------------------------------------- @@ -126,49 +127,49 @@ end ---------------------------------------------------------------------- local function is_supported() - -- Require: nixio present, gettime/nanosleep available, and /proc/uptime - -- readable at initialisation. - if type(nixio.gettime) ~= "function" then - return false - end - if type(nixio.nanosleep) ~= "function" then - return false - end - if not monotonic_ok then - return false - end - return true + -- Require: nixio present, gettime/nanosleep available, and /proc/uptime + -- readable at initialisation. + if type(nixio.gettime) ~= 'function' then + return false + end + if type(nixio.nanosleep) ~= 'function' then + return false + end + if not monotonic_ok then + return false + end + return true end local ops = { - realtime = realtime, - - monotonic = monotonic, - - realtime_info = { - name = "nixio.gettime", - resolution = 0.001, -- approximate; depends on platform - monotonic = false, - epoch = "unix", - }, - - monotonic_info = { - name = "/proc/uptime", - -- /proc/uptime is typically centisecond resolution. - resolution = 0.01, - monotonic = true, - epoch = "unspecified", - }, - - _block = _block, - - block_info = { - name = "nixio.nanosleep", - resolution = 0.001, - clock = "monotonic", - }, - - is_supported = is_supported, + realtime = realtime, + + monotonic = monotonic, + + realtime_info = { + name = 'nixio.gettime', + resolution = 0.001, -- approximate; depends on platform + monotonic = false, + epoch = 'unix', + }, + + monotonic_info = { + name = '/proc/uptime', + -- /proc/uptime is typically centisecond resolution. + resolution = 0.01, + monotonic = true, + epoch = 'unspecified', + }, + + _block = _block, + + block_info = { + name = 'nixio.nanosleep', + resolution = 0.001, + clock = 'monotonic', + }, + + is_supported = is_supported, } diff --git a/src/fibers/wait.lua b/src/fibers/wait.lua index 7c5358f..13d3e2d 100644 --- a/src/fibers/wait.lua +++ b/src/fibers/wait.lua @@ -21,13 +21,13 @@ local op = require 'fibers.op' -local unpack = rawget(table, "unpack") or _G.unpack -local pack = rawget(table, "pack") or function(...) - return { n = select("#", ...), ... } +local unpack = rawget(table, 'unpack') or _G.unpack +local pack = rawget(table, 'pack') or function (...) + return { n = select('#', ...), ... } end local function id_wrap(...) - return ... + return ... end ---------------------------------------------------------------------- @@ -51,16 +51,16 @@ Waitset.__index = Waitset --- Create a new Waitset instance. ---@return Waitset local function new_waitset() - return setmetatable({ buckets = {} }, Waitset) + return setmetatable({ buckets = {} }, Waitset) end --- Remove element at index i by swapping with the tail. ---@param t Task[] ---@param i integer local function remove_at(t, i) - local n = #t - t[i] = t[n] - t[n] = nil + local n = #t + t[i] = t[n] + t[n] = nil end --- Add a task under a given key. @@ -73,61 +73,61 @@ end ---@param task Task ---@return WaitToken function Waitset:add(key, task) - local buckets = self.buckets - local list = buckets[key] - if not list then - list = {} - buckets[key] = list - end - - list[#list + 1] = task - local idx = #list - local unlinked = false - - ---@class WaitToken - local token = { - _waitset = self, - key = key, - task = task, - } - - --- Unlink this task from the waitset. - --- Best-effort: falls back to a reverse scan if the stored index - --- has been invalidated by earlier removals. - ---@param tok WaitToken - ---@return boolean bucket_empty - function token.unlink(tok) - if unlinked then - return false - end - unlinked = true - - local bs = tok._waitset.buckets - local l = bs[tok.key] - if not l or #l == 0 then - return false - end - - -- Best-effort removal; index may be stale. - if idx <= #l and l[idx] == tok.task then - remove_at(l, idx) - else - for i = #l, 1, -1 do - if l[i] == tok.task then - remove_at(l, i) - break - end - end - end - - if #l == 0 then - bs[tok.key] = nil - return true - end - return false - end - - return token + local buckets = self.buckets + local list = buckets[key] + if not list then + list = {} + buckets[key] = list + end + + list[#list + 1] = task + local idx = #list + local unlinked = false + + ---@class WaitToken + local token = { + _waitset = self, + key = key, + task = task, + } + + --- Unlink this task from the waitset. + --- Best-effort: falls back to a reverse scan if the stored index + --- has been invalidated by earlier removals. + ---@param tok WaitToken + ---@return boolean bucket_empty + function token.unlink(tok) + if unlinked then + return false + end + unlinked = true + + local bs = tok._waitset.buckets + local l = bs[tok.key] + if not l or #l == 0 then + return false + end + + -- Best-effort removal; index may be stale. + if idx <= #l and l[idx] == tok.task then + remove_at(l, idx) + else + for i = #l, 1, -1 do + if l[i] == tok.task then + remove_at(l, i) + break + end + end + end + + if #l == 0 then + bs[tok.key] = nil + return true + end + return false + end + + return token end --- Take and remove all waiters for a key. @@ -136,12 +136,12 @@ end ---@param key any ---@return Task[]|nil function Waitset:take_all(key) - local list = self.buckets[key] - if not list then - return nil - end - self.buckets[key] = nil - return list + local list = self.buckets[key] + if not list then + return nil + end + self.buckets[key] = nil + return list end --- Take and remove a single waiter (LIFO) for a key. @@ -150,65 +150,65 @@ end ---@param key any ---@return Task|nil function Waitset:take_one(key) - local list = self.buckets[key] - if not list or #list == 0 then - return nil - end - local idx = #list - local task = list[idx] - list[idx] = nil - if #list == 0 then - self.buckets[key] = nil - end - return task + local list = self.buckets[key] + if not list or #list == 0 then + return nil + end + local idx = #list + local task = list[idx] + list[idx] = nil + if #list == 0 then + self.buckets[key] = nil + end + return task end --- Return whether there are no waiters for this key. ---@param key any ---@return boolean function Waitset:is_empty(key) - local list = self.buckets[key] - return not list or #list == 0 + local list = self.buckets[key] + return not list or #list == 0 end --- Return the number of waiters for this key. ---@param key any ---@return integer function Waitset:size(key) - local list = self.buckets[key] - return list and #list or 0 + local list = self.buckets[key] + return list and #list or 0 end --- Remove all waiters for a single key without notifying them. ---@param key any function Waitset:clear_key(key) - self.buckets[key] = nil + self.buckets[key] = nil end --- Remove all waiters for all keys without notifying them. function Waitset:clear_all() - self.buckets = {} + self.buckets = {} end --- Notify and schedule all waiters for a key. ---@param key any ---@param scheduler Scheduler function Waitset:notify_all(key, scheduler) - local list = self:take_all(key) - if not list then return end - for i = 1, #list do - scheduler:schedule(list[i]) - list[i] = nil - end + local list = self:take_all(key) + if not list then return end + for i = 1, #list do + scheduler:schedule(list[i]) + list[i] = nil + end end --- Notify and schedule a single waiter (LIFO) for a key. ---@param key any ---@param scheduler Scheduler function Waitset:notify_one(key, scheduler) - local task = self:take_one(key) - if not task then return end - scheduler:schedule(task) + local task = self:take_one(key) + if not task then return end + scheduler:schedule(task) end ---------------------------------------------------------------------- @@ -245,75 +245,75 @@ end ---@param wrap_fn? WrapFn ---@return Op local function waitable(register, step, wrap_fn) - assert(type(register) == "function", "waitable: register must be a function") - assert(type(step) == "function", "waitable: step must be a function") - - wrap_fn = wrap_fn or id_wrap - - return op.guard(function() - -- Token for this synchronisation (one per compiled leaf). - local token - - -- Fast path: single non-blocking attempt. - -- step() must not yield; if it raises, this fiber fails. - local function try() - return step() - end - - --- Blocking path: register a task that will re-run step - --- after the external condition changes. - --- - --- The same task is re-used across wake-ups; token is updated - --- to track the latest registration. - ---@param suspension Suspension - ---@param leaf_wrap WrapFn - local function block(suspension, leaf_wrap) - ---@class WaitTask : Task - local task - - task = { - run = function() - if not suspension:waiting() then - return - end - - -- Re-check readiness. - local res = pack(step()) - local done = res[1] - if done then - -- Complete with the leaf's final wrap. - return suspension:complete( - leaf_wrap, - unpack(res, 2, res.n) - ) - end - - -- Not done yet; re-register for another wake-up. - if token and token.unlink then - token:unlink() - end - - token = register(task, suspension, leaf_wrap) - end, - } - - -- Initial registration for this synchronisation. - token = register(task, suspension, leaf_wrap) - end - - local prim = op.new_primitive(wrap_fn, try, block) - - -- If this op participates in a choice and loses, ensure any - -- extant registration is cancelled once for this synchronisation. - return prim:on_abort(function() - if token and token.unlink then - token:unlink() - end - end) - end) + assert(type(register) == 'function', 'waitable: register must be a function') + assert(type(step) == 'function', 'waitable: step must be a function') + + wrap_fn = wrap_fn or id_wrap + + return op.guard(function () + -- Token for this synchronisation (one per compiled leaf). + local token + + -- Fast path: single non-blocking attempt. + -- step() must not yield; if it raises, this fiber fails. + local function try() + return step() + end + + --- Blocking path: register a task that will re-run step + --- after the external condition changes. + --- + --- The same task is re-used across wake-ups; token is updated + --- to track the latest registration. + ---@param suspension Suspension + ---@param leaf_wrap WrapFn + local function block(suspension, leaf_wrap) + ---@class WaitTask : Task + local task + + task = { + run = function () + if not suspension:waiting() then + return + end + + -- Re-check readiness. + local res = pack(step()) + local done = res[1] + if done then + -- Complete with the leaf's final wrap. + return suspension:complete( + leaf_wrap, + unpack(res, 2, res.n) + ) + end + + -- Not done yet; re-register for another wake-up. + if token and token.unlink then + token:unlink() + end + + token = register(task, suspension, leaf_wrap) + end, + } + + -- Initial registration for this synchronisation. + token = register(task, suspension, leaf_wrap) + end + + local prim = op.new_primitive(wrap_fn, try, block) + + -- If this op participates in a choice and loses, ensure any + -- extant registration is cancelled once for this synchronisation. + return prim:on_abort(function () + if token and token.unlink then + token:unlink() + end + end) + end) end return { - new_waitset = new_waitset, - waitable = waitable, + new_waitset = new_waitset, + waitable = waitable, } diff --git a/src/fibers/waitgroup.lua b/src/fibers/waitgroup.lua index 43b64b5..fb5ebbf 100644 --- a/src/fibers/waitgroup.lua +++ b/src/fibers/waitgroup.lua @@ -19,70 +19,70 @@ Waitgroup.__index = Waitgroup --- Create a new waitgroup. ---@return Waitgroup local function new() - return setmetatable({ - _counter = 0, - _cond = nil, -- per-generation condition; nil when idle - }, Waitgroup) + return setmetatable({ + _counter = 0, + _cond = nil, -- per-generation condition; nil when idle + }, Waitgroup) end --- Adjust the waitgroup counter by delta. --- When the counter returns to zero, the current generation completes. ---@param delta integer function Waitgroup:add(delta) - if delta == 0 then - return - end + if delta == 0 then + return + end - local old_count = self._counter - local new_count = old_count + delta + local old_count = self._counter + local new_count = old_count + delta - if new_count < 0 then - error("waitgroup counter goes negative") - end + if new_count < 0 then + error('waitgroup counter goes negative') + end - self._counter = new_count + self._counter = new_count - if new_count == 0 then - -- This generation completes: wake any waiters and drop the condition. - if self._cond then - self._cond:signal() - self._cond = nil - end - elseif old_count == 0 and new_count > 0 then - -- Starting a new generation: create a condition for new work. - self._cond = cond_mod.new() - end + if new_count == 0 then + -- This generation completes: wake any waiters and drop the condition. + if self._cond then + self._cond:signal() + self._cond = nil + end + elseif old_count == 0 and new_count > 0 then + -- Starting a new generation: create a condition for new work. + self._cond = cond_mod.new() + end end --- Decrement the waitgroup counter by one. function Waitgroup:done() - self:add(-1) + self:add(-1) end --- Build an Op that completes when the current generation drains. ---@return Op function Waitgroup:wait_op() - -- Build the op lazily at perform time. - return op.guard(function() - -- If there is nothing outstanding, fire immediately. - if self._counter == 0 then - return op.always() - end + -- Build the op lazily at perform time. + return op.guard(function () + -- If there is nothing outstanding, fire immediately. + if self._counter == 0 then + return op.always() + end - -- Active generation: delegate to the generation's condition. - local cond = assert(self._cond, "waitgroup internal error: missing condition for active generation") - return cond:wait_op() - end) + -- Active generation: delegate to the generation's condition. + local cond = assert(self._cond, 'waitgroup internal error: missing condition for active generation') + return cond:wait_op() + end) end --- Block until the current generation completes. ---@return any ... function Waitgroup:wait() - return perform(self:wait_op()) + return perform(self:wait_op()) end return { - --- Construct a new waitgroup. - ---@return Waitgroup - new = new, + --- Construct a new waitgroup. + ---@return Waitgroup + new = new, } diff --git a/tests/test.lua b/tests/test.lua index faf145e..0e4dcf8 100644 --- a/tests/test.lua +++ b/tests/test.lua @@ -1,31 +1,31 @@ -package.path = "../src/?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path package.path = package.path .. ';/usr/lib/lua/?.lua;/usr/lib/lua/?/init.lua' local sep = '-' local modules = { - { 'utils', 'bytes' }, - { 'utils', 'bytes_stress' }, - { 'io', 'file' }, - { 'io', 'mem' }, - { 'io', 'stream' }, - { 'io', 'socket' }, - { 'io', 'exec_backend' }, - { 'io', 'exec' }, - { 'timer' }, - { 'alarm' }, - { 'sched' }, - { 'runtime' }, - { 'channel' }, - { 'cond' }, - { 'sleep' }, - { 'waitgroup' }, - { 'scope' }, + { 'utils', 'bytes' }, + { 'utils', 'bytes_stress' }, + { 'io', 'file' }, + { 'io', 'mem' }, + { 'io', 'stream' }, + { 'io', 'socket' }, + { 'io', 'exec_backend' }, + { 'io', 'exec' }, + { 'timer' }, + { 'alarm' }, + { 'sched' }, + { 'runtime' }, + { 'channel' }, + { 'cond' }, + { 'sleep' }, + { 'waitgroup' }, + { 'scope' }, } for _, j in ipairs(modules) do - local test_file_name = "test".."_"..table.concat(j, sep)..".lua" - dofile(test_file_name) + local test_file_name = 'test' .. '_' .. table.concat(j, sep) .. '.lua' + dofile(test_file_name) end print('all tests passed!') diff --git a/tests/test_alarm.lua b/tests/test_alarm.lua index c80635b..86fc7a5 100644 --- a/tests/test_alarm.lua +++ b/tests/test_alarm.lua @@ -1,18 +1,18 @@ -package.path = "../src/?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path -- test_alarm.lua -print("testing: fibers.alarm") +print('testing: fibers.alarm') -local fibers = require "fibers" -local sleep_mod = require "fibers.sleep" -local alarm_mod = require "fibers.alarm" +local fibers = require 'fibers' +local sleep_mod = require 'fibers.sleep' +local alarm_mod = require 'fibers.alarm' -local sleep = sleep_mod.sleep +local sleep = sleep_mod.sleep local function approx_equal(a, b, eps) - eps = eps or 1e-3 - return math.abs(a - b) <= eps + eps = eps or 1e-3 + return math.abs(a - b) <= eps end ------------------------------------------------------------------------ @@ -23,7 +23,7 @@ end local wall_time = 0 local function now_fn() - return wall_time + return wall_time end -- Install our test time source once. Alarm code only allows this once. @@ -34,49 +34,49 @@ alarm_mod.set_time_source(now_fn) ------------------------------------------------------------------------ local function test_basic_alarm() - print("[test_basic_alarm] start") + print('[test_basic_alarm] start') - wall_time = 0 + wall_time = 0 - local calls = {} + local calls = {} - local function next_time(last, now) - calls[#calls + 1] = { last = last, now = now } - if last ~= nil then - -- one-shot: only fire once - return nil - end - -- Fire 0.1 seconds after "now" - return now + 0.1 - end + local function next_time(last, now) + calls[#calls + 1] = { last = last, now = now } + if last ~= nil then + -- one-shot: only fire once + return nil + end + -- Fire 0.1 seconds after "now" + return now + 0.1 + end - local al = alarm_mod.new{ next_time = next_time } + local al = alarm_mod.new { next_time = next_time } - local result = {} + local result = {} - fibers.spawn(function() - local ok, alarm, fired_epoch = fibers.perform(al:wait_op()) - result.ok = ok - result.alarm = alarm - result.time = fired_epoch - end) + fibers.spawn(function () + local ok, alarm, fired_epoch = fibers.perform(al:wait_op()) + result.ok = ok + result.alarm = alarm + result.time = fired_epoch + end) - -- Give the spawned fiber a chance to set up its wait - sleep(0.01) + -- Give the spawned fiber a chance to set up its wait + sleep(0.01) - -- Let 0.5s of monotonic time pass, which is comfortably > 0.1 - sleep(0.5) + -- Let 0.5s of monotonic time pass, which is comfortably > 0.1 + sleep(0.5) - assert(result.ok == true, "basic alarm did not fire") - assert(result.alarm == al, "basic alarm: unexpected alarm instance") - assert(approx_equal(result.time, 0.1), - ("basic alarm: expected fired_epoch ~= 0.1 (got %f)"):format(result.time)) + assert(result.ok == true, 'basic alarm did not fire') + assert(result.alarm == al, 'basic alarm: unexpected alarm instance') + assert(approx_equal(result.time, 0.1), + ('basic alarm: expected fired_epoch ~= 0.1 (got %f)'):format(result.time)) - assert(#calls >= 1, "basic alarm: next_time was never called") - assert(approx_equal(calls[1].now, 0), - ("basic alarm: expected first now ~= 0 (got %f)"):format(calls[1].now)) + assert(#calls >= 1, 'basic alarm: next_time was never called') + assert(approx_equal(calls[1].now, 0), + ('basic alarm: expected first now ~= 0 (got %f)'):format(calls[1].now)) - print("[test_basic_alarm] ok") + print('[test_basic_alarm] ok') end ------------------------------------------------------------------------ @@ -84,76 +84,76 @@ end ------------------------------------------------------------------------ local function test_preemptive_reschedule() - print("[test_preemptive_reschedule] start") - - wall_time = 0 - - -- Offset controls how far in the future the alarm fires. - local offset = 10 - - local calls = {} - - local function next_time(last, now) - calls[#calls + 1] = { last = last, now = now } - if last ~= nil then - -- one-shot for this test: only fire once - return nil - end - return now + offset - end - - local al = alarm_mod.new{ next_time = next_time } - - local result = {} - - fibers.spawn(function() - local ok, alarm, fired_epoch = fibers.perform(al:wait_op()) - result.ok = ok - result.alarm = alarm - result.time = fired_epoch - end) - - -- Allow the spawned fiber to start its wait and schedule the initial sleep - sleep(0.01) - - -- At this point: - -- wall_time == 0 - -- next_time(nil, 0) -> 0 + offset (=10) - -- so the alarm is sleeping for dt = 10 seconds in monotonic time. - - -- Now simulate a civil-time change: - -- * new notion of "wall now", - -- * new offset (much shorter wait), - -- * and notify alarms via time_changed(). - wall_time = 100 -- new wall "now" - offset = 1 -- next firing should now be at 101 - - alarm_mod.time_changed() - - -- After time_changed(), the alarm's wait_op should: - -- * wake from the clock-change branch, - -- * clear _next_wall, - -- * recompute next_wall from next_time(nil, 100) -> 101, - -- * sleep for dt = 1, - -- * then fire at last_fired_epoch == 101. - - -- Let enough monotonic time pass for the shorter dt=1 sleep to complete. - sleep(2.0) - - assert(result.ok == true, "preemptive alarm did not fire") - assert(result.alarm == al, "preemptive alarm: unexpected alarm instance") - assert(approx_equal(result.time, 101), - ("preemptive alarm: expected fired_epoch ~= 101 (got %f)"):format(result.time)) - - -- Check that next_time was called at least twice: - assert(#calls >= 2, - ("preemptive alarm: expected at least 2 calls to next_time, got %d"):format(#calls)) - assert(approx_equal(calls[1].now, 0), - ("preemptive alarm: first now ~= 0 (got %f)"):format(calls[1].now)) - assert(approx_equal(calls[2].now, 100), - ("preemptive alarm: second now ~= 100 (got %f)"):format(calls[2].now)) - - print("[test_preemptive_reschedule] ok") + print('[test_preemptive_reschedule] start') + + wall_time = 0 + + -- Offset controls how far in the future the alarm fires. + local offset = 10 + + local calls = {} + + local function next_time(last, now) + calls[#calls + 1] = { last = last, now = now } + if last ~= nil then + -- one-shot for this test: only fire once + return nil + end + return now + offset + end + + local al = alarm_mod.new { next_time = next_time } + + local result = {} + + fibers.spawn(function () + local ok, alarm, fired_epoch = fibers.perform(al:wait_op()) + result.ok = ok + result.alarm = alarm + result.time = fired_epoch + end) + + -- Allow the spawned fiber to start its wait and schedule the initial sleep + sleep(0.01) + + -- At this point: + -- wall_time == 0 + -- next_time(nil, 0) -> 0 + offset (=10) + -- so the alarm is sleeping for dt = 10 seconds in monotonic time. + + -- Now simulate a civil-time change: + -- * new notion of "wall now", + -- * new offset (much shorter wait), + -- * and notify alarms via time_changed(). + wall_time = 100 -- new wall "now" + offset = 1 -- next firing should now be at 101 + + alarm_mod.time_changed() + + -- After time_changed(), the alarm's wait_op should: + -- * wake from the clock-change branch, + -- * clear _next_wall, + -- * recompute next_wall from next_time(nil, 100) -> 101, + -- * sleep for dt = 1, + -- * then fire at last_fired_epoch == 101. + + -- Let enough monotonic time pass for the shorter dt=1 sleep to complete. + sleep(2.0) + + assert(result.ok == true, 'preemptive alarm did not fire') + assert(result.alarm == al, 'preemptive alarm: unexpected alarm instance') + assert(approx_equal(result.time, 101), + ('preemptive alarm: expected fired_epoch ~= 101 (got %f)'):format(result.time)) + + -- Check that next_time was called at least twice: + assert(#calls >= 2, + ('preemptive alarm: expected at least 2 calls to next_time, got %d'):format(#calls)) + assert(approx_equal(calls[1].now, 0), + ('preemptive alarm: first now ~= 0 (got %f)'):format(calls[1].now)) + assert(approx_equal(calls[2].now, 100), + ('preemptive alarm: second now ~= 100 (got %f)'):format(calls[2].now)) + + print('[test_preemptive_reschedule] ok') end ------------------------------------------------------------------------ @@ -161,78 +161,78 @@ end ------------------------------------------------------------------------ local function test_multiple_alarms_reschedule() - print("[test_multiple_alarms_reschedule] start") + print('[test_multiple_alarms_reschedule] start') - wall_time = 0 + wall_time = 0 - local offset1 = 10 - local offset2 = 20 + local offset1 = 10 + local offset2 = 20 - local calls1, calls2 = {}, {} + local calls1, calls2 = {}, {} - local function next_time1(last, now) - calls1[#calls1 + 1] = { last = last, now = now } - if last ~= nil then - return nil - end - return now + offset1 - end + local function next_time1(last, now) + calls1[#calls1 + 1] = { last = last, now = now } + if last ~= nil then + return nil + end + return now + offset1 + end - local function next_time2(last, now) - calls2[#calls2 + 1] = { last = last, now = now } - if last ~= nil then - return nil - end - return now + offset2 - end + local function next_time2(last, now) + calls2[#calls2 + 1] = { last = last, now = now } + if last ~= nil then + return nil + end + return now + offset2 + end - local al1 = alarm_mod.new{ next_time = next_time1 } - local al2 = alarm_mod.new{ next_time = next_time2 } + local al1 = alarm_mod.new { next_time = next_time1 } + local al2 = alarm_mod.new { next_time = next_time2 } - local r1, r2 = {}, {} + local r1, r2 = {}, {} - fibers.spawn(function() - local ok, alarm, t = fibers.perform(al1:wait_op()) - r1.ok, r1.alarm, r1.time = ok, alarm, t - end) + fibers.spawn(function () + local ok, alarm, t = fibers.perform(al1:wait_op()) + r1.ok, r1.alarm, r1.time = ok, alarm, t + end) - fibers.spawn(function() - local ok, alarm, t = fibers.perform(al2:wait_op()) - r2.ok, r2.alarm, r2.time = ok, alarm, t - end) + fibers.spawn(function () + local ok, alarm, t = fibers.perform(al2:wait_op()) + r2.ok, r2.alarm, r2.time = ok, alarm, t + end) - -- Let both alarms set up their initial waits. - sleep(0.01) + -- Let both alarms set up their initial waits. + sleep(0.01) - -- Change civil time and offsets and notify once. - wall_time = 100 - offset1 = 1 - offset2 = 2 + -- Change civil time and offsets and notify once. + wall_time = 100 + offset1 = 1 + offset2 = 2 - alarm_mod.time_changed() + alarm_mod.time_changed() - -- Enough monotonic time for both new sleeps (1 and 2 seconds). - sleep(3.0) + -- Enough monotonic time for both new sleeps (1 and 2 seconds). + sleep(3.0) - assert(r1.ok == true, "alarm1 did not fire") - assert(r2.ok == true, "alarm2 did not fire") + assert(r1.ok == true, 'alarm1 did not fire') + assert(r2.ok == true, 'alarm2 did not fire') - assert(r1.alarm == al1, "alarm1: unexpected alarm instance") - assert(r2.alarm == al2, "alarm2: unexpected alarm instance") + assert(r1.alarm == al1, 'alarm1: unexpected alarm instance') + assert(r2.alarm == al2, 'alarm2: unexpected alarm instance') - assert(approx_equal(r1.time, 101), - ("alarm1: expected fired_epoch ~= 101 (got %f)"):format(r1.time)) - assert(approx_equal(r2.time, 102), - ("alarm2: expected fired_epoch ~= 102 (got %f)"):format(r2.time)) + assert(approx_equal(r1.time, 101), + ('alarm1: expected fired_epoch ~= 101 (got %f)'):format(r1.time)) + assert(approx_equal(r2.time, 102), + ('alarm2: expected fired_epoch ~= 102 (got %f)'):format(r2.time)) - assert(#calls1 >= 2 and #calls2 >= 2, "multiple alarms: next_time not called enough times") + assert(#calls1 >= 2 and #calls2 >= 2, 'multiple alarms: next_time not called enough times') - assert(approx_equal(calls1[1].now, 0), ("alarm1: first now ~= 0 (got %f)"):format(calls1[1].now)) - assert(approx_equal(calls1[2].now, 100),("alarm1: second now ~= 100 (got %f)"):format(calls1[2].now)) - assert(approx_equal(calls2[1].now, 0), ("alarm2: first now ~= 0 (got %f)"):format(calls2[1].now)) - assert(approx_equal(calls2[2].now, 100),("alarm2: second now ~= 100 (got %f)"):format(calls2[2].now)) + assert(approx_equal(calls1[1].now, 0), ('alarm1: first now ~= 0 (got %f)'):format(calls1[1].now)) + assert(approx_equal(calls1[2].now, 100), ('alarm1: second now ~= 100 (got %f)'):format(calls1[2].now)) + assert(approx_equal(calls2[1].now, 0), ('alarm2: first now ~= 0 (got %f)'):format(calls2[1].now)) + assert(approx_equal(calls2[2].now, 100), ('alarm2: second now ~= 100 (got %f)'):format(calls2[2].now)) - print("[test_multiple_alarms_reschedule] ok") + print('[test_multiple_alarms_reschedule] ok') end ------------------------------------------------------------------------ @@ -240,76 +240,76 @@ end ------------------------------------------------------------------------ local function test_repeated_time_changes() - print("[test_repeated_time_changes] start") - - wall_time = 0 - - local offset = 10 - local calls = {} - - local function next_time(last, now) - calls[#calls + 1] = { last = last, now = now } - if last ~= nil then - -- one-shot - return nil - end - return now + offset - end - - local al = alarm_mod.new{ next_time = next_time } - - local result = {} - - fibers.spawn(function() - local ok, alarm, fired_epoch = fibers.perform(al:wait_op()) - result.ok = ok - result.alarm = alarm - result.time = fired_epoch - end) - - -- Allow initial wait setup (now = 0, next_wall = 10). - sleep(0.01) - - -- First change: bump wall_time to 100, keep offset = 10. - wall_time = 100 - offset = 10 - alarm_mod.time_changed() - sleep(0.01) - - -- Second change: wall_time to 200, offset = 5. - wall_time = 200 - offset = 5 - alarm_mod.time_changed() - sleep(0.01) - - -- Third change: wall_time to 300, offset = 1. - wall_time = 300 - offset = 1 - alarm_mod.time_changed() - - -- Final sleep long enough for the last dt = 1. - sleep(2.0) - - assert(result.ok == true, "repeated-change alarm did not fire") - assert(result.alarm == al, "repeated-change alarm: unexpected alarm instance") - assert(approx_equal(result.time, 301), - ("repeated-change alarm: expected fired_epoch ~= 301 (got %f)"):format(result.time)) - - assert(#calls >= 3, ("repeated-change alarm: expected multiple next_time calls, got %d"):format(#calls)) - - -- Check that we saw non-decreasing now values and that the last is ~300. - local last_now = calls[1].now - for i = 2, #calls do - assert(calls[i].now >= last_now, - ("repeated-change alarm: now not non-decreasing at call %d (prev=%f, now=%f)") - :format(i, last_now, calls[i].now)) - last_now = calls[i].now - end - - assert(approx_equal(last_now, 300), - ("repeated-change alarm: last now ~= 300 (got %f)"):format(last_now)) - - print("[test_repeated_time_changes] ok") + print('[test_repeated_time_changes] start') + + wall_time = 0 + + local offset = 10 + local calls = {} + + local function next_time(last, now) + calls[#calls + 1] = { last = last, now = now } + if last ~= nil then + -- one-shot + return nil + end + return now + offset + end + + local al = alarm_mod.new { next_time = next_time } + + local result = {} + + fibers.spawn(function () + local ok, alarm, fired_epoch = fibers.perform(al:wait_op()) + result.ok = ok + result.alarm = alarm + result.time = fired_epoch + end) + + -- Allow initial wait setup (now = 0, next_wall = 10). + sleep(0.01) + + -- First change: bump wall_time to 100, keep offset = 10. + wall_time = 100 + offset = 10 + alarm_mod.time_changed() + sleep(0.01) + + -- Second change: wall_time to 200, offset = 5. + wall_time = 200 + offset = 5 + alarm_mod.time_changed() + sleep(0.01) + + -- Third change: wall_time to 300, offset = 1. + wall_time = 300 + offset = 1 + alarm_mod.time_changed() + + -- Final sleep long enough for the last dt = 1. + sleep(2.0) + + assert(result.ok == true, 'repeated-change alarm did not fire') + assert(result.alarm == al, 'repeated-change alarm: unexpected alarm instance') + assert(approx_equal(result.time, 301), + ('repeated-change alarm: expected fired_epoch ~= 301 (got %f)'):format(result.time)) + + assert(#calls >= 3, ('repeated-change alarm: expected multiple next_time calls, got %d'):format(#calls)) + + -- Check that we saw non-decreasing now values and that the last is ~300. + local last_now = calls[1].now + for i = 2, #calls do + assert(calls[i].now >= last_now, + ('repeated-change alarm: now not non-decreasing at call %d (prev=%f, now=%f)') + :format(i, last_now, calls[i].now)) + last_now = calls[i].now + end + + assert(approx_equal(last_now, 300), + ('repeated-change alarm: last now ~= 300 (got %f)'):format(last_now)) + + print('[test_repeated_time_changes] ok') end ------------------------------------------------------------------------ @@ -317,76 +317,76 @@ end ------------------------------------------------------------------------ local function test_exhaustion_after_reschedule() - print("[test_exhaustion_after_reschedule] start") - - wall_time = 0 - - local offset = 10 - local calls = {} - - local function next_time(last, now) - calls[#calls + 1] = { last = last, now = now } - if last ~= nil then - -- No further recurrences: one-shot alarm - return nil - end - return now + offset - end - - local al = alarm_mod.new{ next_time = next_time } - - local r1, r2 = {}, {} - - fibers.spawn(function() - -- First wait: should fire once. - local ok1, alarm1, t1 = fibers.perform(al:wait_op()) - r1 = { ok = ok1, alarm = alarm1, time = t1 } - - -- Second wait: should give exhaustion notification. - local ok2, why2, alarm2, last2 = fibers.perform(al:wait_op()) - r2 = { ok = ok2, why = why2, alarm = alarm2, last = last2 } - end) - - -- Allow initial scheduling at now = 0, next_wall = 10. - sleep(0.01) - - -- Change civil time and offset before the first firing. - wall_time = 100 - offset = 1 - alarm_mod.time_changed() - - -- Enough time for the recomputed dt = 1 sleep. - sleep(2.0) - - -- Check first firing. - assert(r1.ok == true, "exhaustion test: first firing did not occur") - assert(r1.alarm == al, "exhaustion test: first firing alarm mismatch") - assert(approx_equal(r1.time, 101), - ("exhaustion test: expected first fired_epoch ~= 101 (got %f)"):format(r1.time)) - - -- Check exhaustion notification. - assert(r2.ok == false, "exhaustion test: second wait should report exhaustion") - assert(r2.why == "no_more_recurrences", - ("exhaustion test: expected reason 'no_more_recurrences', got %s"):format(tostring(r2.why))) - assert(r2.alarm == al, "exhaustion test: second wait alarm mismatch") - assert(approx_equal(r2.last, r1.time), - ("exhaustion test: expected last == first fired time (got %f vs %f)") - :format(r2.last or -1, r1.time or -1)) - - -- Ensure next_time saw at least two calls (initial schedule and exhaustion computation). - assert(#calls >= 2, ("exhaustion test: expected at least 2 next_time calls, got %d"):format(#calls)) - - print("[test_exhaustion_after_reschedule] ok") + print('[test_exhaustion_after_reschedule] start') + + wall_time = 0 + + local offset = 10 + local calls = {} + + local function next_time(last, now) + calls[#calls + 1] = { last = last, now = now } + if last ~= nil then + -- No further recurrences: one-shot alarm + return nil + end + return now + offset + end + + local al = alarm_mod.new { next_time = next_time } + + local r1, r2 = {}, {} + + fibers.spawn(function () + -- First wait: should fire once. + local ok1, alarm1, t1 = fibers.perform(al:wait_op()) + r1 = { ok = ok1, alarm = alarm1, time = t1 } + + -- Second wait: should give exhaustion notification. + local ok2, why2, alarm2, last2 = fibers.perform(al:wait_op()) + r2 = { ok = ok2, why = why2, alarm = alarm2, last = last2 } + end) + + -- Allow initial scheduling at now = 0, next_wall = 10. + sleep(0.01) + + -- Change civil time and offset before the first firing. + wall_time = 100 + offset = 1 + alarm_mod.time_changed() + + -- Enough time for the recomputed dt = 1 sleep. + sleep(2.0) + + -- Check first firing. + assert(r1.ok == true, 'exhaustion test: first firing did not occur') + assert(r1.alarm == al, 'exhaustion test: first firing alarm mismatch') + assert(approx_equal(r1.time, 101), + ('exhaustion test: expected first fired_epoch ~= 101 (got %f)'):format(r1.time)) + + -- Check exhaustion notification. + assert(r2.ok == false, 'exhaustion test: second wait should report exhaustion') + assert(r2.why == 'no_more_recurrences', + ("exhaustion test: expected reason 'no_more_recurrences', got %s"):format(tostring(r2.why))) + assert(r2.alarm == al, 'exhaustion test: second wait alarm mismatch') + assert(approx_equal(r2.last, r1.time), + ('exhaustion test: expected last == first fired time (got %f vs %f)') + :format(r2.last or -1, r1.time or -1)) + + -- Ensure next_time saw at least two calls (initial schedule and exhaustion computation). + assert(#calls >= 2, ('exhaustion test: expected at least 2 next_time calls, got %d'):format(#calls)) + + print('[test_exhaustion_after_reschedule] ok') end ------------------------------------------------------------------------ -- Run tests under the fibers scheduler ------------------------------------------------------------------------ -fibers.run(function() - test_basic_alarm() - test_preemptive_reschedule() - test_multiple_alarms_reschedule() - test_repeated_time_changes() - test_exhaustion_after_reschedule() +fibers.run(function () + test_basic_alarm() + test_preemptive_reschedule() + test_multiple_alarms_reschedule() + test_repeated_time_changes() + test_exhaustion_after_reschedule() end) diff --git a/tests/test_channel.lua b/tests/test_channel.lua index 6afdf92..6040ffa 100644 --- a/tests/test_channel.lua +++ b/tests/test_channel.lua @@ -1,99 +1,99 @@ print('testing: fibers.channel') -package.path = "../src/?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path local fibers = require 'fibers' local channel = require 'fibers.channel' local function test_unbuffered() - local chan = channel.new() - fibers.spawn(function() chan:put(42) end) - assert(chan:get() == 42, "basic transfer") + local chan = channel.new() + fibers.spawn(function () chan:put(42) end) + assert(chan:get() == 42, 'basic transfer') - local received, signal = true, channel.new() - fibers.spawn(function() - received = chan:get() - signal:put(true) - end) - fibers.spawn(function() - chan:put(nil) - signal:put(true) - end) - signal:get() - signal:get() - assert(received == nil, "blocking transfer") - print("Unbuffered passed") + local received, signal = true, channel.new() + fibers.spawn(function () + received = chan:get() + signal:put(true) + end) + fibers.spawn(function () + chan:put(nil) + signal:put(true) + end) + signal:get() + signal:get() + assert(received == nil, 'blocking transfer') + print('Unbuffered passed') end local function test_buffered() - local chan, signal = channel.new(2), channel.new() + local chan, signal = channel.new(2), channel.new() - fibers.spawn(function() - for i = 1, 4 do - chan:put(i) - end - signal:put(true) - end) - fibers.spawn(function() - for i = 1, 2 do - assert(chan:get() == i) - end - end) + fibers.spawn(function () + for i = 1, 4 do + chan:put(i) + end + signal:put(true) + end) + fibers.spawn(function () + for i = 1, 2 do + assert(chan:get() == i) + end + end) - signal:get() - fibers.spawn(function() - chan:put(5) - signal:put(true) - end) - assert(chan:get() == 3) - signal:get() - assert(chan:get() == 4) - assert(chan:get() == 5) - print("Bounded buffered passed") + signal:get() + fibers.spawn(function () + chan:put(5) + signal:put(true) + end) + assert(chan:get() == 3) + signal:get() + assert(chan:get() == 4) + assert(chan:get() == 5) + print('Bounded buffered passed') end local function test_unbounded() - local chan = channel.new(math.huge) - for i = 1, 1000 do chan:put(i) end - for i = 1, 1000 do assert(chan:get() == i) end + local chan = channel.new(math.huge) + for i = 1, 1000 do chan:put(i) end + for i = 1, 1000 do assert(chan:get() == i) end - local blocked = true - fibers.spawn(function() - chan:get() - blocked = false - end) + local blocked = true + fibers.spawn(function () + chan:get() + blocked = false + end) - -- At this point, there is no value available, so get() must have blocked. - assert(blocked, "get should block") + -- At this point, there is no value available, so get() must have blocked. + assert(blocked, 'get should block') - -- Now provide a value so the spawned fiber can complete. - chan:put(123) + -- Now provide a value so the spawned fiber can complete. + chan:put(123) - print("Unbounded passed") + print('Unbounded passed') end local function test_concurrent() - local chan, signal, results = channel.new(1), channel.new(), {} - fibers.spawn(function() - for i = 1, 11 do chan:put(i) end - signal:put() - end) - fibers.spawn(function() - for _ = 1, 10 do table.insert(results, chan:get()) end - signal:put() - end) - signal:get() - signal:get() - for i = 1, 10 do assert(results[i] == i) end - print("Concurrent passed") + local chan, signal, results = channel.new(1), channel.new(), {} + fibers.spawn(function () + for i = 1, 11 do chan:put(i) end + signal:put() + end) + fibers.spawn(function () + for _ = 1, 10 do table.insert(results, chan:get()) end + signal:put() + end) + signal:get() + signal:get() + for i = 1, 10 do assert(results[i] == i) end + print('Concurrent passed') end local function main() - test_unbuffered() - test_buffered() - test_unbounded() - test_concurrent() - print("All channel tests passed!") + test_unbuffered() + test_buffered() + test_unbounded() + test_concurrent() + print('All channel tests passed!') end fibers.run(main) diff --git a/tests/test_cond.lua b/tests/test_cond.lua index 80f0584..99305e2 100644 --- a/tests/test_cond.lua +++ b/tests/test_cond.lua @@ -2,7 +2,7 @@ print('testing: fibers.cond') -- look one level up -package.path = "../src/?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path local cond = require 'fibers.cond' local runtime = require 'fibers.runtime' @@ -10,28 +10,28 @@ local sleep = require 'fibers.sleep' local time = require 'fibers.utils.time' local function equal(x, y) - if type(x) ~= type(y) then return false end - if type(x) == 'table' then - for k, v in pairs(x) do - if not equal(v, y[k]) then return false end - end - for k, _ in pairs(y) do - if x[k] == nil then return false end - end - return true - else - return x == y - end + if type(x) ~= type(y) then return false end + if type(x) == 'table' then + for k, v in pairs(x) do + if not equal(v, y[k]) then return false end + end + for k, _ in pairs(y) do + if x[k] == nil then return false end + end + return true + else + return x == y + end end local c, log = cond.new(), {} local function record(x) table.insert(log, x) end -runtime.spawn_raw(function() - record('a'); c:wait(); record('b') +runtime.spawn_raw(function () + record('a'); c:wait(); record('b') end) -runtime.spawn_raw(function() - record('c'); c:signal(); record('d') +runtime.spawn_raw(function () + record('c'); c:signal(); record('d') end) assert(equal(log, {})) runtime.current_scheduler:run() @@ -39,20 +39,20 @@ assert(equal(log, { 'a', 'c', 'd' })) runtime.current_scheduler:run() assert(equal(log, { 'a', 'c', 'd', 'b' })) -runtime.spawn_raw(function() - local fiber_count = 1e3 - for _ = 1, fiber_count do - runtime.spawn_raw(function() c:wait(); end) - end +runtime.spawn_raw(function () + local fiber_count = 1e3 + for _ = 1, fiber_count do + runtime.spawn_raw(function () c:wait(); end) + end - sleep.sleep(1) + sleep.sleep(1) - local start_time = time.monotonic() - c:signal() - local end_time = time.monotonic() + local start_time = time.monotonic() + c:signal() + local end_time = time.monotonic() - print("Time taken to signal fiber: ", (end_time - start_time) / fiber_count) - runtime.stop() + print('Time taken to signal fiber: ', (end_time - start_time) / fiber_count) + runtime.stop() end) runtime.main() diff --git a/tests/test_io-exec.lua b/tests/test_io-exec.lua index 3e91af4..294c5ae 100644 --- a/tests/test_io-exec.lua +++ b/tests/test_io-exec.lua @@ -4,10 +4,10 @@ -- -- Run as: luajit test_io-exec.lua -print("testing: fibers.io.exec") +print('testing: fibers.io.exec') -- Look one level up for src modules. -package.path = "../src/?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path local fibers = require 'fibers' local exec = require 'fibers.io.exec' @@ -20,27 +20,27 @@ local poller = require 'fibers.io.poller' ---------------------------------------------------------------------- local function warm_up_exec_backend() - -- Run a trivial command once to force backend initialisation - fibers.run(function() - local proc = exec.command{ - "sh", "-c", "true", - stdin = "null", - stdout = "null", - stderr = "null", - } - local status, code, _, err = fibers.perform(proc:run_op()) - assert(err == nil, "warm-up wait error: " .. tostring(err)) - assert(status == "exited", "warm-up status: " .. tostring(status)) - assert(code == 0, "warm-up exit code: " .. tostring(code)) - end) + -- Run a trivial command once to force backend initialisation + fibers.run(function () + local proc = exec.command { + 'sh', '-c', 'true', + stdin = 'null', + stdout = 'null', + stderr = 'null', + } + local status, code, _, err = fibers.perform(proc:run_op()) + assert(err == nil, 'warm-up wait error: ' .. tostring(err)) + assert(status == 'exited', 'warm-up status: ' .. tostring(status)) + assert(code == 0, 'warm-up exit code: ' .. tostring(code)) + end) end local function shell_capture(cmd) - local p, perr = io.popen(cmd, "r") - assert(p, "io.popen failed: " .. tostring(perr)) - local out = p:read("*a") or "" - p:close() - return out + local p, perr = io.popen(cmd, 'r') + assert(p, 'io.popen failed: ' .. tostring(perr)) + local out = p:read('*a') or '' + p:close() + return out end -- Force poller initialisation so its FDs are part of the baseline. @@ -51,21 +51,21 @@ warm_up_exec_backend() -- Count open FDs of the parent (luajit) process using /proc and $PPID. local function get_fd_count_for_parent() - local script = [=[ + local script = [=[ ls "/proc/$PPID/fd" 2>/dev/null | wc -l ]=] - local ok, out = pcall(shell_capture, script) - if not ok then - return nil - end - local n = out:match("(%d+)") - return n and tonumber(n) or nil + local ok, out = pcall(shell_capture, script) + if not ok then + return nil + end + local n = out:match('(%d+)') + return n and tonumber(n) or nil end -- Count zombie children (state 'Z') of the parent (luajit) process. -- Uses /proc/*/stat and awk; best-effort only. local function get_zombie_count_for_parent() - local script = [=[ + local script = [=[ parent="$PPID" count=0 for stat in /proc/[0-9]*/stat; do @@ -79,19 +79,19 @@ for stat in /proc/[0-9]*/stat; do done printf '%s\n' "$count" ]=] - local ok, out = pcall(shell_capture, script) - if not ok then - return nil - end - local n = out:match("(%d+)") - return n and tonumber(n) or nil + local ok, out = pcall(shell_capture, script) + if not ok then + return nil + end + local n = out:match('(%d+)') + return n and tonumber(n) or nil end local baseline_fd_count = get_fd_count_for_parent() local baseline_zombie_count = get_zombie_count_for_parent() -print(("baseline: fds=%s zombies=%s") - :format(tostring(baseline_fd_count), tostring(baseline_zombie_count))) +print(('baseline: fds=%s zombies=%s') + :format(tostring(baseline_fd_count), tostring(baseline_zombie_count))) ---------------------------------------------------------------------- -- Tests @@ -99,224 +99,224 @@ print(("baseline: fds=%s zombies=%s") -- 1. Simple spawn: check we can see a non-zero exit code and no signal. local function simple_exit_code() - print("running: simple_exit_code") - - local proc = exec.command{ - "sh", "-c", "exit 7", - stdin = "null", - stdout = "null", - stderr = "null", - } - assert(proc, "command creation failed") - - local status, code, sig, werr = fibers.perform(proc:run_op()) - assert(werr == nil, "wait error: " .. tostring(werr)) - assert(status == "exited", "expected status 'exited', got " .. tostring(status)) - assert(sig == nil, "expected no signal, got " .. tostring(sig)) - assert(code == 7, "expected exit code 7, got " .. tostring(code)) + print('running: simple_exit_code') + + local proc = exec.command { + 'sh', '-c', 'exit 7', + stdin = 'null', + stdout = 'null', + stderr = 'null', + } + assert(proc, 'command creation failed') + + local status, code, sig, werr = fibers.perform(proc:run_op()) + assert(werr == nil, 'wait error: ' .. tostring(werr)) + assert(status == 'exited', "expected status 'exited', got " .. tostring(status)) + assert(sig == nil, 'expected no signal, got ' .. tostring(sig)) + assert(code == 7, 'expected exit code 7, got ' .. tostring(code)) end -- 2. stdin/stdout pipes: cat echoes what we send. local function stdin_stdout_pipe_round_trip() - print("running: stdin_stdout_pipe_round_trip") - - local proc = exec.command{ - "sh", "-c", "cat", - stdin = "pipe", - stdout = "pipe", - stderr = "pipe", - } - assert(proc, "command creation failed") - - local stdin_stream, sin_err = proc:stdin_stream() - local stdout_stream, sout_err = proc:stdout_stream() - assert(stdin_stream and not sin_err, "expected stdin stream, got error: " .. tostring(sin_err)) - assert(stdout_stream and not sout_err, "expected stdout stream, got error: " .. tostring(sout_err)) - - local msg = "line1\nline2\n" - local n, werr = stdin_stream:write(msg) - assert(werr == nil, "write error: " .. tostring(werr)) - assert(n == #msg, "short write: " .. tostring(n)) - stdin_stream:close() -- send EOF - - local out, rerr = stdout_stream:read_all() - assert(rerr == nil, "read_all stdout error: " .. tostring(rerr)) - assert(out == msg, ("unexpected echo: %q"):format(out)) - - local status, code, sig, werr2 = fibers.perform(proc:run_op()) - assert(werr2 == nil, "wait error: " .. tostring(werr2)) - assert(status == "exited", "expected exited status") - assert(sig == nil, "expected no signal") - assert(code == 0, "expected exit 0") + print('running: stdin_stdout_pipe_round_trip') + + local proc = exec.command { + 'sh', '-c', 'cat', + stdin = 'pipe', + stdout = 'pipe', + stderr = 'pipe', + } + assert(proc, 'command creation failed') + + local stdin_stream, sin_err = proc:stdin_stream() + local stdout_stream, sout_err = proc:stdout_stream() + assert(stdin_stream and not sin_err, 'expected stdin stream, got error: ' .. tostring(sin_err)) + assert(stdout_stream and not sout_err, 'expected stdout stream, got error: ' .. tostring(sout_err)) + + local msg = 'line1\nline2\n' + local n, werr = stdin_stream:write(msg) + assert(werr == nil, 'write error: ' .. tostring(werr)) + assert(n == #msg, 'short write: ' .. tostring(n)) + stdin_stream:close() -- send EOF + + local out, rerr = stdout_stream:read_all() + assert(rerr == nil, 'read_all stdout error: ' .. tostring(rerr)) + assert(out == msg, ('unexpected echo: %q'):format(out)) + + local status, code, sig, werr2 = fibers.perform(proc:run_op()) + assert(werr2 == nil, 'wait error: ' .. tostring(werr2)) + assert(status == 'exited', 'expected exited status') + assert(sig == nil, 'expected no signal') + assert(code == 0, 'expected exit 0') end -- 3. stderr as separate pipe vs stderr redirected to stdout. local function stderr_pipe_vs_stderr_is_stdout() - print("running: stderr_pipe_vs_stderr_is_stdout") - - -- Separate stderr. - local proc1 = exec.command{ - "sh", "-c", "echo out; echo err 1>&2", - stdin = "null", - stdout = "pipe", - stderr = "pipe", - } - assert(proc1, "spawn failed (separate stderr)") - - local out_stream1, oserr1 = proc1:stdout_stream() - local err_stream1, eserr1 = proc1:stderr_stream() - assert(out_stream1 and not oserr1, "stdout stream error: " .. tostring(oserr1)) - assert(err_stream1 and not eserr1, "stderr stream error: " .. tostring(eserr1)) - - local out1, oerr1 = out_stream1:read_all() - local errout1, eerr1 = err_stream1:read_all() - assert(oerr1 == nil, "stdout read error: " .. tostring(oerr1)) - assert(eerr1 == nil, "stderr read error: " .. tostring(eerr1)) - assert(out1 == "out\n", ("unexpected stdout: %q"):format(out1)) - assert(errout1 == "err\n", ("unexpected stderr: %q"):format(errout1)) - - fibers.perform(proc1:run_op()) - - -- stderr merged into stdout. - local proc2 = exec.command{ - "sh", "-c", "echo out; echo err 1>&2", - stdin = "null", - stdout = "pipe", - stderr = "stdout", - } - assert(proc2, "spawn failed (stderr=stdout)") - - local out_stream2, oserr2 = proc2:stdout_stream() - assert(out_stream2 and not oserr2, "stdout stream error: " .. tostring(oserr2)) - - local err_stream2, _ = proc2:stderr_stream() - -- When stderr is redirected to stdout, stderr_stream should return the same stream. - assert(err_stream2 == out_stream2, - "expected stderr_stream to return stdout stream when redirected") - - local merged, merr = out_stream2:read_all() - assert(merr == nil, "merged stdout read error: " .. tostring(merr)) - assert(merged:match("out"), ("merged output missing 'out': %q"):format(merged)) - assert(merged:match("err"), ("merged output missing 'err': %q"):format(merged)) - - fibers.perform(proc2:run_op()) + print('running: stderr_pipe_vs_stderr_is_stdout') + + -- Separate stderr. + local proc1 = exec.command { + 'sh', '-c', 'echo out; echo err 1>&2', + stdin = 'null', + stdout = 'pipe', + stderr = 'pipe', + } + assert(proc1, 'spawn failed (separate stderr)') + + local out_stream1, oserr1 = proc1:stdout_stream() + local err_stream1, eserr1 = proc1:stderr_stream() + assert(out_stream1 and not oserr1, 'stdout stream error: ' .. tostring(oserr1)) + assert(err_stream1 and not eserr1, 'stderr stream error: ' .. tostring(eserr1)) + + local out1, oerr1 = out_stream1:read_all() + local errout1, eerr1 = err_stream1:read_all() + assert(oerr1 == nil, 'stdout read error: ' .. tostring(oerr1)) + assert(eerr1 == nil, 'stderr read error: ' .. tostring(eerr1)) + assert(out1 == 'out\n', ('unexpected stdout: %q'):format(out1)) + assert(errout1 == 'err\n', ('unexpected stderr: %q'):format(errout1)) + + fibers.perform(proc1:run_op()) + + -- stderr merged into stdout. + local proc2 = exec.command { + 'sh', '-c', 'echo out; echo err 1>&2', + stdin = 'null', + stdout = 'pipe', + stderr = 'stdout', + } + assert(proc2, 'spawn failed (stderr=stdout)') + + local out_stream2, oserr2 = proc2:stdout_stream() + assert(out_stream2 and not oserr2, 'stdout stream error: ' .. tostring(oserr2)) + + local err_stream2, _ = proc2:stderr_stream() + -- When stderr is redirected to stdout, stderr_stream should return the same stream. + assert(err_stream2 == out_stream2, + 'expected stderr_stream to return stdout stream when redirected') + + local merged, merr = out_stream2:read_all() + assert(merr == nil, 'merged stdout read error: ' .. tostring(merr)) + assert(merged:match('out'), ("merged output missing 'out': %q"):format(merged)) + assert(merged:match('err'), ("merged output missing 'err': %q"):format(merged)) + + fibers.perform(proc2:run_op()) end -- 4. output_op: convenient capture of stdout plus status. local function output_op_normal_completion() - print("running: output_op_normal_completion") - - local proc = exec.command{ - "sh", "-c", "echo bracket", - stdin = "null", - stdout = "pipe", - stderr = "pipe", - } - assert(proc, "command creation failed") - - local out, status, code, sig, err = fibers.perform(proc:output_op()) - assert(err == nil, "output_op error: " .. tostring(err)) - assert(status == "exited", "expected status 'exited'") - assert(code == 0, "expected exit code 0") - assert(sig == nil, "expected no signal") - - -- /bin/sh echo will append a newline. - assert(out == "bracket\n", ("unexpected output from output_op: %q"):format(out)) + print('running: output_op_normal_completion') + + local proc = exec.command { + 'sh', '-c', 'echo bracket', + stdin = 'null', + stdout = 'pipe', + stderr = 'pipe', + } + assert(proc, 'command creation failed') + + local out, status, code, sig, err = fibers.perform(proc:output_op()) + assert(err == nil, 'output_op error: ' .. tostring(err)) + assert(status == 'exited', "expected status 'exited'") + assert(code == 0, 'expected exit code 0') + assert(sig == nil, 'expected no signal') + + -- /bin/sh echo will append a newline. + assert(out == 'bracket\n', ('unexpected output from output_op: %q'):format(out)) end -- 5. wait_op via run_op and a simple timeout pattern using boolean_choice. local function wait_op_with_timeout_pattern() - print("running: wait_op_with_timeout_pattern") - - local proc = exec.command{ - "/bin/sh", "-c", "sleep 1", - stdin = "null", - stdout = "null", - stderr = "null", - } - assert(proc, "command creation failed") - - -- Race process completion vs timeout. - local ev = op.boolean_choice( - proc:run_op(), - sleep.sleep_op(2.0) - ) - - local is_exit, status, code, sig, werr = fibers.perform(ev) - assert(is_exit == true, "process did not finish before timeout") - assert(werr == nil, "wait_op error: " .. tostring(werr)) - assert(status == "exited", "expected status 'exited'") - assert(code == 0, "expected exit code 0, got " .. tostring(code)) - -- sig may be nil; we do not insist on value. - - -- Second wait should be immediate and return the same result. - local status2, code2, sig2, werr2 = fibers.perform(proc:run_op()) - assert(status2 == status, "status changed between waits") - assert(code2 == code, "code changed between waits") - assert(sig2 == sig, "signal changed between waits") - assert(werr2 == nil, "unexpected error on second wait: " .. tostring(werr2)) + print('running: wait_op_with_timeout_pattern') + + local proc = exec.command { + '/bin/sh', '-c', 'sleep 1', + stdin = 'null', + stdout = 'null', + stderr = 'null', + } + assert(proc, 'command creation failed') + + -- Race process completion vs timeout. + local ev = op.boolean_choice( + proc:run_op(), + sleep.sleep_op(2.0) + ) + + local is_exit, status, code, sig, werr = fibers.perform(ev) + assert(is_exit == true, 'process did not finish before timeout') + assert(werr == nil, 'wait_op error: ' .. tostring(werr)) + assert(status == 'exited', "expected status 'exited'") + assert(code == 0, 'expected exit code 0, got ' .. tostring(code)) + -- sig may be nil; we do not insist on value. + + -- Second wait should be immediate and return the same result. + local status2, code2, sig2, werr2 = fibers.perform(proc:run_op()) + assert(status2 == status, 'status changed between waits') + assert(code2 == code, 'code changed between waits') + assert(sig2 == sig, 'signal changed between waits') + assert(werr2 == nil, 'unexpected error on second wait: ' .. tostring(werr2)) end -- 6. shutdown: terminate a long-running process (TERM then KILL if needed). local function shutdown_long_running_process() - print("running: shutdown_long_running_process") - - local proc = exec.command{ - "sh", "-c", "while true; do sleep 1; done", - stdin = "null", - stdout = "null", - stderr = "null", - } - assert(proc, "command creation failed") - - local t0 = fibers.now() - local _, _, _, err = fibers.perform(proc:shutdown_op(0.2)) - local t1 = fibers.now() - - -- Ensure we did not stall. - assert((t1 - t0) < 5.0, ("shutdown took too long: %.3fs"):format(t1 - t0)) - - -- For this ad hoc test we only insist that the process reached - -- a terminal state. shutdown_op may surface backend details in `err`, - -- which we treat as diagnostic rather than fatal here. - local state, code_or_sig = proc:status() - assert( - state == "exited" or state == "signalled", - ("unexpected final state: %s (detail=%s, err=%s)") - :format(tostring(state), tostring(code_or_sig), tostring(err)) - ) + print('running: shutdown_long_running_process') + + local proc = exec.command { + 'sh', '-c', 'while true; do sleep 1; done', + stdin = 'null', + stdout = 'null', + stderr = 'null', + } + assert(proc, 'command creation failed') + + local t0 = fibers.now() + local _, _, _, err = fibers.perform(proc:shutdown_op(0.2)) + local t1 = fibers.now() + + -- Ensure we did not stall. + assert((t1 - t0) < 5.0, ('shutdown took too long: %.3fs'):format(t1 - t0)) + + -- For this ad hoc test we only insist that the process reached + -- a terminal state. shutdown_op may surface backend details in `err`, + -- which we treat as diagnostic rather than fatal here. + local state, code_or_sig = proc:status() + assert( + state == 'exited' or state == 'signalled', + ('unexpected final state: %s (detail=%s, err=%s)') + :format(tostring(state), tostring(code_or_sig), tostring(err)) + ) end -- 7. Spawning as an Op for CML-shaped code (basic usage). local function spawn_op_basic_usage() - print("running: spawn_op_basic_usage") - - -- Build an Op that, when performed, creates a Command and returns it. - local spawn_ev = op.guard(function() - local proc = exec.command{ - "sh", "-c", "printf 'via_op'", - stdin = "null", - stdout = "pipe", - stderr = "pipe", - } - return op.always(proc) - end) - - local proc = fibers.perform(spawn_ev) - assert(proc, "spawn_ev did not return a process") - - local stdout_stream, serr = proc:stdout_stream() - assert(stdout_stream and not serr, "stdout stream error: " .. tostring(serr)) - - local out, rerr = stdout_stream:read_all() - assert(rerr == nil, "read_all error: " .. tostring(rerr)) - assert(out == "via_op", ("unexpected stdout from spawn_ev: %q"):format(out)) - - local status, code, sig, werr = fibers.perform(proc:run_op()) - assert(werr == nil, "wait error: " .. tostring(werr)) - assert(status == "exited", "expected status 'exited'") - assert(sig == nil, "expected no signal") - assert(code == 0, "expected exit 0 from spawned process") + print('running: spawn_op_basic_usage') + + -- Build an Op that, when performed, creates a Command and returns it. + local spawn_ev = op.guard(function () + local proc = exec.command { + 'sh', '-c', "printf 'via_op'", + stdin = 'null', + stdout = 'pipe', + stderr = 'pipe', + } + return op.always(proc) + end) + + local proc = fibers.perform(spawn_ev) + assert(proc, 'spawn_ev did not return a process') + + local stdout_stream, serr = proc:stdout_stream() + assert(stdout_stream and not serr, 'stdout stream error: ' .. tostring(serr)) + + local out, rerr = stdout_stream:read_all() + assert(rerr == nil, 'read_all error: ' .. tostring(rerr)) + assert(out == 'via_op', ('unexpected stdout from spawn_ev: %q'):format(out)) + + local status, code, sig, werr = fibers.perform(proc:run_op()) + assert(werr == nil, 'wait error: ' .. tostring(werr)) + assert(status == 'exited', "expected status 'exited'") + assert(sig == nil, 'expected no signal') + assert(code == 0, 'expected exit 0 from spawned process') end ---------------------------------------------------------------------- @@ -324,40 +324,40 @@ end ---------------------------------------------------------------------- local function many_short_lived_processes_stress() - print("running: many_short_lived_processes_stress") - - local N = 50 - - for i = 1, N do - local proc = exec.command{ - "sh", "-c", ("printf 'run-%d'; exit %d"):format(i, i % 256), - stdin = "null", - stdout = "pipe", - stderr = (i % 2 == 0) and "null" or "pipe", - } - assert(proc, ("command creation failed at iteration %d"):format(i)) - - local stdout_stream, serr = proc:stdout_stream() - assert(stdout_stream and not serr, - ("stdout stream error at iteration %d: %s"):format(i, tostring(serr))) - - local out, rerr = stdout_stream:read_all() - assert(rerr == nil, - ("read_all error at iteration %d: %s"):format(i, tostring(rerr))) - assert(out == ("run-%d"):format(i), - ("unexpected stdout at iteration %d: %q"):format(i, out)) - - local status, code, sig, werr = fibers.perform(proc:run_op()) - assert(werr == nil, - ("wait error at iteration %d: %s"):format(i, tostring(werr))) - assert(status == "exited", - ("status not 'exited' at iteration %d: %s"):format(i, tostring(status))) - assert(sig == nil, - ("signal not nil at iteration %d: %s"):format(i, tostring(sig))) - assert(code == i % 256, - ("exit code mismatch at iteration %d: got %d, expected %d") - :format(i, code, i % 256)) - end + print('running: many_short_lived_processes_stress') + + local N = 50 + + for i = 1, N do + local proc = exec.command { + 'sh', '-c', ("printf 'run-%d'; exit %d"):format(i, i % 256), + stdin = 'null', + stdout = 'pipe', + stderr = (i % 2 == 0) and 'null' or 'pipe', + } + assert(proc, ('command creation failed at iteration %d'):format(i)) + + local stdout_stream, serr = proc:stdout_stream() + assert(stdout_stream and not serr, + ('stdout stream error at iteration %d: %s'):format(i, tostring(serr))) + + local out, rerr = stdout_stream:read_all() + assert(rerr == nil, + ('read_all error at iteration %d: %s'):format(i, tostring(rerr))) + assert(out == ('run-%d'):format(i), + ('unexpected stdout at iteration %d: %q'):format(i, out)) + + local status, code, sig, werr = fibers.perform(proc:run_op()) + assert(werr == nil, + ('wait error at iteration %d: %s'):format(i, tostring(werr))) + assert(status == 'exited', + ("status not 'exited' at iteration %d: %s"):format(i, tostring(status))) + assert(sig == nil, + ('signal not nil at iteration %d: %s'):format(i, tostring(sig))) + assert(code == i % 256, + ('exit code mismatch at iteration %d: got %d, expected %d') + :format(i, code, i % 256)) + end end ---------------------------------------------------------------------- @@ -365,10 +365,10 @@ end ---------------------------------------------------------------------- local function large_output_output_op_stress() - print("running: large_output_output_op_stress") + print('running: large_output_output_op_stress') - local lines = 5000 - local script = ([[ + local lines = 5000 + local script = ([[ i=1 while [ $i -le %d ]; do echo "line-$i" @@ -376,34 +376,34 @@ while [ $i -le %d ]; do done ]]):format(lines) - local proc = exec.command{ - "sh", "-c", script, - stdin = "null", - stdout = "pipe", - stderr = "pipe", - } - assert(proc, "command creation failed") - - local out, status, code, sig, err = fibers.perform(proc:output_op()) - assert(err == nil, "output_op error: " .. tostring(err)) - assert(status == "exited", "expected status 'exited'") - assert(code == 0, "expected exit code 0") - assert(sig == nil, "expected no signal") - - local count = 0 - for line in out:gmatch("([^\n]*)\n") do - if line ~= "" then - count = count + 1 - end - end - assert(count == lines, - ("unexpected number of lines from large_output_output_op_stress: got %d, expected %d") - :format(count, lines)) - - assert(out:find("line-1", 1, true), - "large output missing 'line-1'") - assert(out:find("line-" .. tostring(lines), 1, true), - "large output missing last line marker") + local proc = exec.command { + 'sh', '-c', script, + stdin = 'null', + stdout = 'pipe', + stderr = 'pipe', + } + assert(proc, 'command creation failed') + + local out, status, code, sig, err = fibers.perform(proc:output_op()) + assert(err == nil, 'output_op error: ' .. tostring(err)) + assert(status == 'exited', "expected status 'exited'") + assert(code == 0, 'expected exit code 0') + assert(sig == nil, 'expected no signal') + + local count = 0 + for line in out:gmatch('([^\n]*)\n') do + if line ~= '' then + count = count + 1 + end + end + assert(count == lines, + ('unexpected number of lines from large_output_output_op_stress: got %d, expected %d') + :format(count, lines)) + + assert(out:find('line-1', 1, true), + "large output missing 'line-1'") + assert(out:find('line-' .. tostring(lines), 1, true), + 'large output missing last line marker') end ---------------------------------------------------------------------- @@ -411,15 +411,15 @@ end ---------------------------------------------------------------------- local function main() - simple_exit_code() - stdin_stdout_pipe_round_trip() - stderr_pipe_vs_stderr_is_stdout() - output_op_normal_completion() - wait_op_with_timeout_pattern() - shutdown_long_running_process() - spawn_op_basic_usage() - many_short_lived_processes_stress() - large_output_output_op_stress() + simple_exit_code() + stdin_stdout_pipe_round_trip() + stderr_pipe_vs_stderr_is_stdout() + output_op_normal_completion() + wait_op_with_timeout_pattern() + shutdown_long_running_process() + spawn_op_basic_usage() + many_short_lived_processes_stress() + large_output_output_op_stress() end fibers.run(main) @@ -427,23 +427,23 @@ fibers.run(main) local final_fd_count = get_fd_count_for_parent() local final_zombie_count = get_zombie_count_for_parent() -print(("final: fds=%s zombies=%s") - :format(tostring(final_fd_count), tostring(final_zombie_count))) +print(('final: fds=%s zombies=%s') + :format(tostring(final_fd_count), tostring(final_zombie_count))) if baseline_fd_count and final_fd_count then - assert(final_fd_count == baseline_fd_count, - ("FD leak detected: baseline=%d final=%d") - :format(baseline_fd_count, final_fd_count)) + assert(final_fd_count == baseline_fd_count, + ('FD leak detected: baseline=%d final=%d') + :format(baseline_fd_count, final_fd_count)) else - print("FD leak check skipped (could not read /proc or count FDs)") + print('FD leak check skipped (could not read /proc or count FDs)') end if baseline_zombie_count and final_zombie_count then - assert(final_zombie_count <= baseline_zombie_count, - ("zombie leak detected: baseline=%d final=%d") - :format(baseline_zombie_count, final_zombie_count)) + assert(final_zombie_count <= baseline_zombie_count, + ('zombie leak detected: baseline=%d final=%d') + :format(baseline_zombie_count, final_zombie_count)) else - print("Zombie leak check skipped (could not read /proc or count zombies)") + print('Zombie leak check skipped (could not read /proc or count zombies)') end -print("test_io-exec.lua: all assertions passed") +print('test_io-exec.lua: all assertions passed') diff --git a/tests/test_io-exec_backend.lua b/tests/test_io-exec_backend.lua index aef2db8..e50e067 100644 --- a/tests/test_io-exec_backend.lua +++ b/tests/test_io-exec_backend.lua @@ -6,7 +6,7 @@ print('testing: fibers.io.exec_backend') -- look one level up -package.path = "../src/?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path local proc_backend = require 'fibers.io.exec_backend' local stdlib = require 'posix.stdlib' @@ -16,7 +16,7 @@ local stdlib = require 'posix.stdlib' ---------------------------------------------------------------------- local function inherit_stream() - return { mode = "inherit" } + return { mode = 'inherit' } end ---------------------------------------------------------------------- @@ -27,14 +27,14 @@ end -- child is finished. -- exec_backend.core wires ops.poll(state) -> done:boolean, code|nil, signal|nil, err|nil local function wait_blocking(backend) - while true do - local done, code, sig, err = backend._ops.poll(backend._state) - assert(err == nil, "poll error: " .. tostring(err)) - if done then - return code, sig - end - -- Busy wait is acceptable here: tests are short-lived and single-process. - end + while true do + local done, code, sig, err = backend._ops.poll(backend._state) + assert(err == nil, 'poll error: ' .. tostring(err)) + if done then + return code, sig + end + -- Busy wait is acceptable here: tests are short-lived and single-process. + end end ---------------------------------------------------------------------- @@ -42,26 +42,26 @@ end ---------------------------------------------------------------------- local function test_simple_exit() - local spec = { - argv = { "sh", "-c", "exit 7" }, - cwd = nil, - env = nil, - flags = nil, - stdin = inherit_stream(), - stdout = inherit_stream(), - stderr = inherit_stream(), - } - - -- start() now returns a ProcHandle: { backend = ExecBackend, stdin, stdout, stderr } - local handle, err = proc_backend.start(spec) - assert(handle, "start failed: " .. tostring(err)) - - local backend = assert(handle.backend, "no backend in handle") - - local code, sig = wait_blocking(backend) - assert(code == 7, - ("expected exit code 7, got %s"):format(tostring(code))) - assert(sig == nil, "expected no terminating signal") + local spec = { + argv = { 'sh', '-c', 'exit 7' }, + cwd = nil, + env = nil, + flags = nil, + stdin = inherit_stream(), + stdout = inherit_stream(), + stderr = inherit_stream(), + } + + -- start() now returns a ProcHandle: { backend = ExecBackend, stdin, stdout, stderr } + local handle, err = proc_backend.start(spec) + assert(handle, 'start failed: ' .. tostring(err)) + + local backend = assert(handle.backend, 'no backend in handle') + + local code, sig = wait_blocking(backend) + assert(code == 7, + ('expected exit code 7, got %s'):format(tostring(code))) + assert(sig == nil, 'expected no terminating signal') end ---------------------------------------------------------------------- @@ -69,11 +69,11 @@ end ---------------------------------------------------------------------- local function test_env_inherit() - -- Ensure a parent variable is set. - assert(stdlib.setenv("PROC_BACKEND_TEST", "parent_inherit")) + -- Ensure a parent variable is set. + assert(stdlib.setenv('PROC_BACKEND_TEST', 'parent_inherit')) - -- Shell script checks inherited value and exits 0 only on match. - local script = [[ + -- Shell script checks inherited value and exits 0 only on match. + local script = [[ if [ "$PROC_BACKEND_TEST" = "parent_inherit" ]; then exit 0 else @@ -81,24 +81,24 @@ local function test_env_inherit() fi ]] - local spec = { - argv = { "sh", "-c", script }, - cwd = nil, - env = nil, -- inherit environment - flags = nil, - stdin = inherit_stream(), - stdout = inherit_stream(), - stderr = inherit_stream(), - } - - local handle, err = proc_backend.start(spec) - assert(handle, "start failed: " .. tostring(err)) - local backend = assert(handle.backend, "no backend in handle") - - local code, sig = wait_blocking(backend) - assert(sig == nil, "expected no terminating signal") - assert(code == 0, - ("expected exit code 0 from env inherit test, got %s"):format(tostring(code))) + local spec = { + argv = { 'sh', '-c', script }, + cwd = nil, + env = nil, -- inherit environment + flags = nil, + stdin = inherit_stream(), + stdout = inherit_stream(), + stderr = inherit_stream(), + } + + local handle, err = proc_backend.start(spec) + assert(handle, 'start failed: ' .. tostring(err)) + local backend = assert(handle.backend, 'no backend in handle') + + local code, sig = wait_blocking(backend) + assert(sig == nil, 'expected no terminating signal') + assert(code == 0, + ('expected exit code 0 from env inherit test, got %s'):format(tostring(code))) end ---------------------------------------------------------------------- @@ -106,10 +106,10 @@ end ---------------------------------------------------------------------- local function test_env_override() - -- Parent value that should be overridden in the child. - assert(stdlib.setenv("PROC_BACKEND_TEST", "parent_value")) + -- Parent value that should be overridden in the child. + assert(stdlib.setenv('PROC_BACKEND_TEST', 'parent_value')) - local script = [[ + local script = [[ if [ "$PROC_BACKEND_TEST" = "child_value" ]; then exit 0 else @@ -117,24 +117,24 @@ local function test_env_override() fi ]] - local spec = { - argv = { "sh", "-c", script }, - cwd = nil, - env = { PROC_BACKEND_TEST = "child_value" }, -- override - flags = nil, - stdin = inherit_stream(), - stdout = inherit_stream(), - stderr = inherit_stream(), - } - - local handle, err = proc_backend.start(spec) - assert(handle, "start failed: " .. tostring(err)) - local backend = assert(handle.backend, "no backend in handle") - - local code, sig = wait_blocking(backend) - assert(sig == nil, "expected no terminating signal") - assert(code == 0, - ("expected exit code 0 from env override test, got %s"):format(tostring(code))) + local spec = { + argv = { 'sh', '-c', script }, + cwd = nil, + env = { PROC_BACKEND_TEST = 'child_value' }, -- override + flags = nil, + stdin = inherit_stream(), + stdout = inherit_stream(), + stderr = inherit_stream(), + } + + local handle, err = proc_backend.start(spec) + assert(handle, 'start failed: ' .. tostring(err)) + local backend = assert(handle.backend, 'no backend in handle') + + local code, sig = wait_blocking(backend) + assert(sig == nil, 'expected no terminating signal') + assert(code == 0, + ('expected exit code 0 from env override test, got %s'):format(tostring(code))) end ---------------------------------------------------------------------- @@ -142,10 +142,10 @@ end ---------------------------------------------------------------------- local function main() - test_simple_exit() - test_env_inherit() - test_env_override() - io.stdout:write("proc_backend tests passed\n") + test_simple_exit() + test_env_inherit() + test_env_override() + io.stdout:write('proc_backend tests passed\n') end main() diff --git a/tests/test_io-file.lua b/tests/test_io-file.lua index 93c8f5a..eb80908 100644 --- a/tests/test_io-file.lua +++ b/tests/test_io-file.lua @@ -9,7 +9,7 @@ print('testing: fibers.io.file') -- look one level up -package.path = "../src/?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path -- Assertion-based checks for fibers.io.file and stream I/O. -- Exercises: @@ -24,10 +24,10 @@ package.path = "../src/?.lua;" .. package.path -- - merge_lines_op across multiple streams -- - setvbuf, filename, rename, nonblock/block, is_stream -local fibers = require 'fibers' -local sleep = require 'fibers.sleep' -local file_mod = require 'fibers.io.file' -local scope_mod = require 'fibers.scope' +local fibers = require 'fibers' +local sleep = require 'fibers.sleep' +local file_mod = require 'fibers.io.file' +local scope_mod = require 'fibers.scope' local stream_mod = require 'fibers.io.stream' local perform = fibers.perform @@ -39,30 +39,30 @@ math.randomseed(os.time()) ---------------------------------------------------------------------- local function test_tmpfile_roundtrip() - local f, err = file_mod.tmpfile() - assert(f, "tmpfile() failed: " .. tostring(err)) - - local msg = "hello, tmpfile" - local n, werr = perform(f:write_string_op(msg)) - assert(n == #msg, "write_string_op wrote " .. tostring(n) .. " bytes, expected " .. #msg) - assert(werr == nil, "write_string_op returned error: " .. tostring(werr)) - - -- Rewind to the start. - local pos, serr = f:seek("set", 0) - assert(pos ~= nil, "seek failed: " .. tostring(serr)) - - local s, cnt, rerr = perform(f:read_string_op{ - min = #msg, - max = #msg, - eof_ok = true, - }) - - assert(rerr == nil, "read_string_op returned error: " .. tostring(rerr)) - assert(cnt == #msg, "read_string_op read " .. tostring(cnt) .. " bytes, expected " .. #msg) - assert(s == msg, ("read_string_op returned %q, expected %q"):format(tostring(s), tostring(msg))) - - local ok, cerr = f:close() - assert(ok, "tmpfile:close() failed: " .. tostring(cerr)) + local f, err = file_mod.tmpfile() + assert(f, 'tmpfile() failed: ' .. tostring(err)) + + local msg = 'hello, tmpfile' + local n, werr = perform(f:write_string_op(msg)) + assert(n == #msg, 'write_string_op wrote ' .. tostring(n) .. ' bytes, expected ' .. #msg) + assert(werr == nil, 'write_string_op returned error: ' .. tostring(werr)) + + -- Rewind to the start. + local pos, serr = f:seek('set', 0) + assert(pos ~= nil, 'seek failed: ' .. tostring(serr)) + + local s, cnt, rerr = perform(f:read_string_op { + min = #msg, + max = #msg, + eof_ok = true, + }) + + assert(rerr == nil, 'read_string_op returned error: ' .. tostring(rerr)) + assert(cnt == #msg, 'read_string_op read ' .. tostring(cnt) .. ' bytes, expected ' .. #msg) + assert(s == msg, ('read_string_op returned %q, expected %q'):format(tostring(s), tostring(msg))) + + local ok, cerr = f:close() + assert(ok, 'tmpfile:close() failed: ' .. tostring(cerr)) end ---------------------------------------------------------------------- @@ -70,40 +70,40 @@ end ---------------------------------------------------------------------- local function test_pipe_roundtrip_and_eof() - local r, w = file_mod.pipe() - assert(r and w, "pipe() did not return read and write streams") - - local msg = "pipe-test" - local n, werr = perform(w:write_string_op(msg)) - assert(n == #msg, "pipe write_string_op wrote " .. tostring(n) .. " bytes, expected " .. #msg) - assert(werr == nil, "pipe write_string_op returned error: " .. tostring(werr)) - - local s, cnt, rerr = perform(r:read_string_op{ - min = #msg, - max = #msg, - eof_ok = true, - }) - - assert(rerr == nil, "pipe read_string_op returned error: " .. tostring(rerr)) - assert(cnt == #msg, "pipe read_string_op read " .. tostring(cnt) .. " bytes, expected " .. #msg) - assert(s == msg, ("pipe read_string_op returned %q, expected %q"):format(tostring(s), tostring(msg))) - - -- Close the write end and ensure the reader sees EOF. - local okw, errw = w:close() - assert(okw, "pipe write stream close failed: " .. tostring(errw)) - - local s2, cnt2, rerr2 = perform(r:read_string_op{ - min = 1, - eof_ok = true, - }) - - -- At EOF, read_string_op should return (nil, 0, nil). - assert(s2 == nil, "expected nil at EOF, got " .. tostring(s2)) - assert(cnt2 == 0, "expected byte count 0 at EOF, got " .. tostring(cnt2)) - assert(rerr2 == nil, "expected no error at EOF, got " .. tostring(rerr2)) - - local okr, errr = r:close() - assert(okr, "pipe read stream close failed: " .. tostring(errr)) + local r, w = file_mod.pipe() + assert(r and w, 'pipe() did not return read and write streams') + + local msg = 'pipe-test' + local n, werr = perform(w:write_string_op(msg)) + assert(n == #msg, 'pipe write_string_op wrote ' .. tostring(n) .. ' bytes, expected ' .. #msg) + assert(werr == nil, 'pipe write_string_op returned error: ' .. tostring(werr)) + + local s, cnt, rerr = perform(r:read_string_op { + min = #msg, + max = #msg, + eof_ok = true, + }) + + assert(rerr == nil, 'pipe read_string_op returned error: ' .. tostring(rerr)) + assert(cnt == #msg, 'pipe read_string_op read ' .. tostring(cnt) .. ' bytes, expected ' .. #msg) + assert(s == msg, ('pipe read_string_op returned %q, expected %q'):format(tostring(s), tostring(msg))) + + -- Close the write end and ensure the reader sees EOF. + local okw, errw = w:close() + assert(okw, 'pipe write stream close failed: ' .. tostring(errw)) + + local s2, cnt2, rerr2 = perform(r:read_string_op { + min = 1, + eof_ok = true, + }) + + -- At EOF, read_string_op should return (nil, 0, nil). + assert(s2 == nil, 'expected nil at EOF, got ' .. tostring(s2)) + assert(cnt2 == 0, 'expected byte count 0 at EOF, got ' .. tostring(cnt2)) + assert(rerr2 == nil, 'expected no error at EOF, got ' .. tostring(rerr2)) + + local okr, errr = r:close() + assert(okr, 'pipe read stream close failed: ' .. tostring(errr)) end ---------------------------------------------------------------------- @@ -111,42 +111,42 @@ end ---------------------------------------------------------------------- local function test_closed_stream_errors() - -- Use a fresh pipe for write-after-close. - local r1, w1 = file_mod.pipe() - assert(r1 and w1, "pipe() did not return read and write streams") - - local okw1, errw1 = w1:close() - assert(okw1, "write stream close failed: " .. tostring(errw1)) - - -- Writing via an already-closed Stream should raise "stream is not writable". - local ok, err = pcall(function() - return perform(w1:write_string_op("abc")) - end) - assert(not ok, "expected write after close to fail with an assertion") - assert(tostring(err):match("stream is not writable"), - "unexpected write-after-close error: " .. tostring(err)) - - -- Use a fresh pipe for read-after-close. - local r2, w2 = file_mod.pipe() - assert(r2 and w2, "pipe() did not return read and write streams") - - local okr2, errr2 = r2:close() - assert(okr2, "read stream close failed: " .. tostring(errr2)) - - -- Reading via an already-closed Stream should raise "stream is not readable". - ok, err = pcall(function() - return perform(r2:read_string_op{ - min = 1, - eof_ok = false, - }) - end) - assert(not ok, "expected read after close to fail with an assertion") - assert(tostring(err):match("stream is not readable"), - "unexpected read-after-close error: " .. tostring(err)) - - -- Clean up the remaining write end. - local okw2, errw2 = w2:close() - assert(okw2, "second write stream close failed: " .. tostring(errw2)) + -- Use a fresh pipe for write-after-close. + local r1, w1 = file_mod.pipe() + assert(r1 and w1, 'pipe() did not return read and write streams') + + local okw1, errw1 = w1:close() + assert(okw1, 'write stream close failed: ' .. tostring(errw1)) + + -- Writing via an already-closed Stream should raise "stream is not writable". + local ok, err = pcall(function () + return perform(w1:write_string_op('abc')) + end) + assert(not ok, 'expected write after close to fail with an assertion') + assert(tostring(err):match('stream is not writable'), + 'unexpected write-after-close error: ' .. tostring(err)) + + -- Use a fresh pipe for read-after-close. + local r2, w2 = file_mod.pipe() + assert(r2 and w2, 'pipe() did not return read and write streams') + + local okr2, errr2 = r2:close() + assert(okr2, 'read stream close failed: ' .. tostring(errr2)) + + -- Reading via an already-closed Stream should raise "stream is not readable". + ok, err = pcall(function () + return perform(r2:read_string_op { + min = 1, + eof_ok = false, + }) + end) + assert(not ok, 'expected read after close to fail with an assertion') + assert(tostring(err):match('stream is not readable'), + 'unexpected read-after-close error: ' .. tostring(err)) + + -- Clean up the remaining write end. + local okw2, errw2 = w2:close() + assert(okw2, 'second write stream close failed: ' .. tostring(errw2)) end ---------------------------------------------------------------------- @@ -154,52 +154,52 @@ end ---------------------------------------------------------------------- local function test_cancellation_cancels_blocked_read() - -- Use scope.run to create a nested child scope whose cancellation - -- does not affect the top-level scope used by fibers.run. - local status, err = scope_mod.run(function(child) - local r, w = file_mod.pipe() - assert(r and w, "pipe() did not return read and write streams") - - -- Spawn a canceller fiber under the child scope. - child:spawn(function(s) - -- Give the reader time to block. - perform(sleep.sleep_op(0.01)) - s:cancel("test cancellation") - end) - - -- Perform a read that will block (nothing is written). - local v1, v2, v3 = perform(r:read_string_op{ - min = 1, - eof_ok = true, - }) - - -- Under Scope:run_ev semantics, cancellation races the IO op. - -- When cancellation wins, the cancel_op returns: - -- false, reason, nil - assert(v1 == false, - "expected first result false from cancelled op, got " .. tostring(v1)) - assert(v2 == "test cancellation", - "expected cancellation reason 'test cancellation', got " .. tostring(v2)) - assert(v3 == nil, - "expected third result nil from cancel_op, got " .. tostring(v3)) - - -- Close streams to avoid leaks; at this point cancellation has - -- already been signalled. - local okr, errr = r:close() - local okw, errw = w:close() - assert(okr, "reader close failed after cancellation: " .. tostring(errr)) - assert(okw, "writer close failed after cancellation: " .. tostring(errw)) - end) - - if status == "failed" then - error(err) - end - - -- From the outer point of view, the nested scope should report as cancelled. - assert(status == "cancelled", - "expected child scope status 'cancelled', got " .. tostring(status)) - assert(err == "test cancellation", - "unexpected child scope cancellation error: " .. tostring(err)) + -- Use scope.run to create a nested child scope whose cancellation + -- does not affect the top-level scope used by fibers.run. + local status, err = scope_mod.run(function (child) + local r, w = file_mod.pipe() + assert(r and w, 'pipe() did not return read and write streams') + + -- Spawn a canceller fiber under the child scope. + child:spawn(function (s) + -- Give the reader time to block. + perform(sleep.sleep_op(0.01)) + s:cancel('test cancellation') + end) + + -- Perform a read that will block (nothing is written). + local v1, v2, v3 = perform(r:read_string_op { + min = 1, + eof_ok = true, + }) + + -- Under Scope:run_ev semantics, cancellation races the IO op. + -- When cancellation wins, the cancel_op returns: + -- false, reason, nil + assert(v1 == false, + 'expected first result false from cancelled op, got ' .. tostring(v1)) + assert(v2 == 'test cancellation', + "expected cancellation reason 'test cancellation', got " .. tostring(v2)) + assert(v3 == nil, + 'expected third result nil from cancel_op, got ' .. tostring(v3)) + + -- Close streams to avoid leaks; at this point cancellation has + -- already been signalled. + local okr, errr = r:close() + local okw, errw = w:close() + assert(okr, 'reader close failed after cancellation: ' .. tostring(errr)) + assert(okw, 'writer close failed after cancellation: ' .. tostring(errw)) + end) + + if status == 'failed' then + error(err) + end + + -- From the outer point of view, the nested scope should report as cancelled. + assert(status == 'cancelled', + "expected child scope status 'cancelled', got " .. tostring(status)) + assert(err == 'test cancellation', + 'unexpected child scope cancellation error: ' .. tostring(err)) end ---------------------------------------------------------------------- @@ -207,34 +207,34 @@ end ---------------------------------------------------------------------- local function test_read_line_variants() - local f, err = file_mod.tmpfile() - assert(f, "tmpfile() failed: " .. tostring(err)) - - local content = "line1\nline2\n" - local n, werr = f:write(content) - assert(werr == nil, "write failed in test_read_line_variants: " .. tostring(werr)) - assert(n == #content, "write wrote " .. tostring(n) .. " bytes, expected " .. #content) - - local pos, serr = f:seek("set", 0) - assert(pos == 0, "seek to start failed: " .. tostring(serr)) - - -- Default / "*l": line without terminator. - local l1, e1 = f:read() -- equivalent to "*l" - assert(e1 == nil, "read('*l') returned error: " .. tostring(e1)) - assert(l1 == "line1", "read('*l') returned " .. tostring(l1) .. ", expected 'line1'") - - -- "*L": line with terminator. - local l2, e2 = f:read("*L") - assert(e2 == nil, "read('*L') returned error: " .. tostring(e2)) - assert(l2 == "line2\n", "read('*L') returned " .. tostring(l2) .. ", expected 'line2\\n'") - - -- EOF: another line should give nil, nil. - local l3, e3 = f:read("*l") - assert(l3 == nil, "expected nil at EOF from read('*l'), got " .. tostring(l3)) - assert(e3 == nil, "expected nil error at EOF from read('*l'), got " .. tostring(e3)) - - local ok, cerr = f:close() - assert(ok, "tmpfile close failed in test_read_line_variants: " .. tostring(cerr)) + local f, err = file_mod.tmpfile() + assert(f, 'tmpfile() failed: ' .. tostring(err)) + + local content = 'line1\nline2\n' + local n, werr = f:write(content) + assert(werr == nil, 'write failed in test_read_line_variants: ' .. tostring(werr)) + assert(n == #content, 'write wrote ' .. tostring(n) .. ' bytes, expected ' .. #content) + + local pos, serr = f:seek('set', 0) + assert(pos == 0, 'seek to start failed: ' .. tostring(serr)) + + -- Default / "*l": line without terminator. + local l1, e1 = f:read() -- equivalent to "*l" + assert(e1 == nil, "read('*l') returned error: " .. tostring(e1)) + assert(l1 == 'line1', "read('*l') returned " .. tostring(l1) .. ", expected 'line1'") + + -- "*L": line with terminator. + local l2, e2 = f:read('*L') + assert(e2 == nil, "read('*L') returned error: " .. tostring(e2)) + assert(l2 == 'line2\n', "read('*L') returned " .. tostring(l2) .. ", expected 'line2\\n'") + + -- EOF: another line should give nil, nil. + local l3, e3 = f:read('*l') + assert(l3 == nil, "expected nil at EOF from read('*l'), got " .. tostring(l3)) + assert(e3 == nil, "expected nil error at EOF from read('*l'), got " .. tostring(e3)) + + local ok, cerr = f:close() + assert(ok, 'tmpfile close failed in test_read_line_variants: ' .. tostring(cerr)) end ---------------------------------------------------------------------- @@ -242,75 +242,75 @@ end ---------------------------------------------------------------------- local function test_read_all_and_exactly() - local f, err = file_mod.tmpfile() - assert(f, "tmpfile() failed: " .. tostring(err)) + local f, err = file_mod.tmpfile() + assert(f, 'tmpfile() failed: ' .. tostring(err)) - local content = "abc123" - local n, werr = f:write(content) - assert(werr == nil, "write failed in test_read_all_and_exactly: " .. tostring(werr)) - assert(n == #content, "write wrote " .. tostring(n) .. " bytes, expected " .. #content) + local content = 'abc123' + local n, werr = f:write(content) + assert(werr == nil, 'write failed in test_read_all_and_exactly: ' .. tostring(werr)) + assert(n == #content, 'write wrote ' .. tostring(n) .. ' bytes, expected ' .. #content) - local pos, serr = f:seek("set", 0) - assert(pos == 0, "seek to start failed: " .. tostring(serr)) + local pos, serr = f:seek('set', 0) + assert(pos == 0, 'seek to start failed: ' .. tostring(serr)) - -- read_all should return the whole string. - local all, eall = f:read_all() - assert(eall == nil, "read_all returned error: " .. tostring(eall)) - assert(all == content, "read_all returned " .. tostring(all) .. ", expected " .. tostring(content)) + -- read_all should return the whole string. + local all, eall = f:read_all() + assert(eall == nil, 'read_all returned error: ' .. tostring(eall)) + assert(all == content, 'read_all returned ' .. tostring(all) .. ', expected ' .. tostring(content)) - -- Rewind and exercise read_exactly. - pos, serr = f:seek("set", 0) - assert(pos == 0, "seek to start failed (2): " .. tostring(serr)) + -- Rewind and exercise read_exactly. + pos, serr = f:seek('set', 0) + assert(pos == 0, 'seek to start failed (2): ' .. tostring(serr)) - local s1, e1 = f:read_exactly(3) - assert(e1 == nil, "read_exactly(3) returned error: " .. tostring(e1)) - assert(s1 == "abc", "read_exactly(3) returned " .. tostring(s1) .. ", expected 'abc'") + local s1, e1 = f:read_exactly(3) + assert(e1 == nil, 'read_exactly(3) returned error: ' .. tostring(e1)) + assert(s1 == 'abc', 'read_exactly(3) returned ' .. tostring(s1) .. ", expected 'abc'") - local s2, e2 = f:read_exactly(3) - assert(e2 == nil, "read_exactly(3) second returned error: " .. tostring(e2)) - assert(s2 == "123", "read_exactly(3) second returned " .. tostring(s2) .. ", expected '123'") + local s2, e2 = f:read_exactly(3) + assert(e2 == nil, 'read_exactly(3) second returned error: ' .. tostring(e2)) + assert(s2 == '123', 'read_exactly(3) second returned ' .. tostring(s2) .. ", expected '123'") - -- EOF: attempting to read beyond end should give "short read". - local s3, e3 = f:read_exactly(1) - assert(s3 == nil, "expected nil from read_exactly beyond EOF, got " .. tostring(s3)) - assert(e3 == "short read", "expected 'short read' error, got " .. tostring(e3)) + -- EOF: attempting to read beyond end should give "short read". + local s3, e3 = f:read_exactly(1) + assert(s3 == nil, 'expected nil from read_exactly beyond EOF, got ' .. tostring(s3)) + assert(e3 == 'short read', "expected 'short read' error, got " .. tostring(e3)) - local ok, cerr = f:close() - assert(ok, "tmpfile close failed in test_read_all_and_exactly: " .. tostring(cerr)) + local ok, cerr = f:close() + assert(ok, 'tmpfile close failed in test_read_all_and_exactly: ' .. tostring(cerr)) end local function test_read_numeric_formats() - local f, err = file_mod.tmpfile() - assert(f, "tmpfile() failed: " .. tostring(err)) + local f, err = file_mod.tmpfile() + assert(f, 'tmpfile() failed: ' .. tostring(err)) - local content = "xyz" - local n, werr = f:write(content) - assert(werr == nil, "write failed in test_read_numeric_formats: " .. tostring(werr)) - assert(n == #content, "write wrote " .. tostring(n) .. " bytes, expected " .. #content) + local content = 'xyz' + local n, werr = f:write(content) + assert(werr == nil, 'write failed in test_read_numeric_formats: ' .. tostring(werr)) + assert(n == #content, 'write wrote ' .. tostring(n) .. ' bytes, expected ' .. #content) - local pos, serr = f:seek("set", 0) - assert(pos == 0, "seek to start failed: " .. tostring(serr)) + local pos, serr = f:seek('set', 0) + assert(pos == 0, 'seek to start failed: ' .. tostring(serr)) - -- n == 0: should return "" immediately. - local s0, e0 = f:read(0) - assert(e0 == nil, "read(0) returned error: " .. tostring(e0)) - assert(s0 == "", "read(0) returned " .. tostring(s0) .. ", expected empty string") + -- n == 0: should return "" immediately. + local s0, e0 = f:read(0) + assert(e0 == nil, 'read(0) returned error: ' .. tostring(e0)) + assert(s0 == '', 'read(0) returned ' .. tostring(s0) .. ', expected empty string') - -- n > 0: read up to n bytes; EOF semantics. - local s1, e1 = f:read(1) - assert(e1 == nil, "read(1) returned error: " .. tostring(e1)) - assert(s1 == "x", "read(1) returned " .. tostring(s1) .. ", expected 'x'") + -- n > 0: read up to n bytes; EOF semantics. + local s1, e1 = f:read(1) + assert(e1 == nil, 'read(1) returned error: ' .. tostring(e1)) + assert(s1 == 'x', 'read(1) returned ' .. tostring(s1) .. ", expected 'x'") - local s2, e2 = f:read(10) -- remaining two bytes - assert(e2 == nil, "read(10) returned error: " .. tostring(e2)) - assert(s2 == "yz", "read(10) returned " .. tostring(s2) .. ", expected 'yz'") + local s2, e2 = f:read(10) -- remaining two bytes + assert(e2 == nil, 'read(10) returned error: ' .. tostring(e2)) + assert(s2 == 'yz', 'read(10) returned ' .. tostring(s2) .. ", expected 'yz'") - local s3, e3 = f:read(1) -- EOF now - assert(s3 == nil, "expected nil at EOF from read(1), got " .. tostring(s3)) - assert(e3 == nil, "expected nil error at EOF from read(1), got " .. tostring(e3)) + local s3, e3 = f:read(1) -- EOF now + assert(s3 == nil, 'expected nil at EOF from read(1), got ' .. tostring(s3)) + assert(e3 == nil, 'expected nil error at EOF from read(1), got ' .. tostring(e3)) - local ok, cerr = f:close() - assert(ok, "tmpfile close failed in test_read_numeric_formats: " .. tostring(cerr)) + local ok, cerr = f:close() + assert(ok, 'tmpfile close failed in test_read_numeric_formats: ' .. tostring(cerr)) end ---------------------------------------------------------------------- @@ -318,28 +318,28 @@ end ---------------------------------------------------------------------- local function test_write_variants() - local f, err = file_mod.tmpfile() - assert(f, "tmpfile() failed: " .. tostring(err)) + local f, err = file_mod.tmpfile() + assert(f, 'tmpfile() failed: ' .. tostring(err)) - -- Zero-argument write should succeed and write nothing. - local n0, e0 = f:write() - assert(e0 == nil, "zero-arg write returned error: " .. tostring(e0)) - assert(n0 == 0, "zero-arg write returned " .. tostring(n0) .. ", expected 0") + -- Zero-argument write should succeed and write nothing. + local n0, e0 = f:write() + assert(e0 == nil, 'zero-arg write returned error: ' .. tostring(e0)) + assert(n0 == 0, 'zero-arg write returned ' .. tostring(n0) .. ', expected 0') - -- Multi-argument write should tostring each argument. - local _, werr = f:write("foo", 123, true) - assert(werr == nil, "write returned error: " .. tostring(werr)) + -- Multi-argument write should tostring each argument. + local _, werr = f:write('foo', 123, true) + assert(werr == nil, 'write returned error: ' .. tostring(werr)) - local pos, serr = f:seek("set", 0) - assert(pos == 0, "seek to start failed: " .. tostring(serr)) + local pos, serr = f:seek('set', 0) + assert(pos == 0, 'seek to start failed: ' .. tostring(serr)) - local s, e = f:read_all() - assert(e == nil, "read_all returned error after write: " .. tostring(e)) - assert(s == "foo123true", - "read_all returned " .. tostring(s) .. ", expected 'foo123true'") + local s, e = f:read_all() + assert(e == nil, 'read_all returned error after write: ' .. tostring(e)) + assert(s == 'foo123true', + 'read_all returned ' .. tostring(s) .. ", expected 'foo123true'") - local ok, cerr = f:close() - assert(ok, "tmpfile close failed in test_write_variants: " .. tostring(cerr)) + local ok, cerr = f:close() + assert(ok, 'tmpfile close failed in test_write_variants: ' .. tostring(cerr)) end ---------------------------------------------------------------------- @@ -347,48 +347,48 @@ end ---------------------------------------------------------------------- local function test_merge_lines_op() - local r1, w1 = file_mod.pipe() - local r2, w2 = file_mod.pipe() - assert(r1 and w1 and r2 and w2, "pipe() did not return streams") - - local named = { - a = r1, - b = r2, - } - - -- Spawn writers under the top-level scope; ignore the scope argument. - fibers.spawn(function(w) - local _, err = w:write("line-a\n") - assert(err == nil, "writer a write error: " .. tostring(err)) - local ok, cerr = w:close() - assert(ok, "writer a close error: " .. tostring(cerr)) - end, w1) - - fibers.spawn(function(w) - local _, err = w:write("line-b\n") - assert(err == nil, "writer b write error: " .. tostring(err)) - local ok, cerr = w:close() - assert(ok, "writer b close error: " .. tostring(cerr)) - end, w2) - - local op = stream_mod.merge_lines_op(named, { terminator = "\n" }) - local name, line, err = perform(op) - - assert(err == nil, "merge_lines_op returned error: " .. tostring(err)) - assert(name == "a" or name == "b", - "merge_lines_op returned unexpected name: " .. tostring(name)) - - if name == "a" then - assert(line == "line-a", "expected 'line-a' from arm 'a', got " .. tostring(line)) - else - assert(line == "line-b", "expected 'line-b' from arm 'b', got " .. tostring(line)) - end - - -- Close the remaining reader. - local ok1, cerr1 = r1:close() - local ok2, cerr2 = r2:close() - assert(ok1, "reader r1 close failed: " .. tostring(cerr1)) - assert(ok2, "reader r2 close failed: " .. tostring(cerr2)) + local r1, w1 = file_mod.pipe() + local r2, w2 = file_mod.pipe() + assert(r1 and w1 and r2 and w2, 'pipe() did not return streams') + + local named = { + a = r1, + b = r2, + } + + -- Spawn writers under the top-level scope; ignore the scope argument. + fibers.spawn(function (w) + local _, err = w:write('line-a\n') + assert(err == nil, 'writer a write error: ' .. tostring(err)) + local ok, cerr = w:close() + assert(ok, 'writer a close error: ' .. tostring(cerr)) + end, w1) + + fibers.spawn(function (w) + local _, err = w:write('line-b\n') + assert(err == nil, 'writer b write error: ' .. tostring(err)) + local ok, cerr = w:close() + assert(ok, 'writer b close error: ' .. tostring(cerr)) + end, w2) + + local op = stream_mod.merge_lines_op(named, { terminator = '\n' }) + local name, line, err = perform(op) + + assert(err == nil, 'merge_lines_op returned error: ' .. tostring(err)) + assert(name == 'a' or name == 'b', + 'merge_lines_op returned unexpected name: ' .. tostring(name)) + + if name == 'a' then + assert(line == 'line-a', "expected 'line-a' from arm 'a', got " .. tostring(line)) + else + assert(line == 'line-b', "expected 'line-b' from arm 'b', got " .. tostring(line)) + end + + -- Close the remaining reader. + local ok1, cerr1 = r1:close() + local ok2, cerr2 = r2:close() + assert(ok1, 'reader r1 close failed: ' .. tostring(cerr1)) + assert(ok2, 'reader r2 close failed: ' .. tostring(cerr2)) end ---------------------------------------------------------------------- @@ -396,53 +396,53 @@ end ---------------------------------------------------------------------- local function test_stream_properties_and_rename() - local f, err = file_mod.tmpfile() - assert(f, "tmpfile() failed: " .. tostring(err)) - - -- is_stream - assert(stream_mod.is_stream(f), "is_stream did not recognise a Stream") - assert(not stream_mod.is_stream(123), "is_stream misclassified a number") - - -- filename should be non-nil for tmpfile - local fname = f:filename() - assert(fname ~= nil, "tmpfile:filename() returned nil") - - -- setvbuf toggles line_buffering flag - assert(f.line_buffering == false, "expected line_buffering default false") - f:setvbuf("line") - assert(f.line_buffering == true, "setvbuf('line') did not set line_buffering") - f:setvbuf("no") - assert(f.line_buffering == false, "setvbuf('no') did not clear line_buffering") - f:setvbuf("full") - assert(f.line_buffering == false, "setvbuf('full') did not clear line_buffering") - - -- nonblock/block should be safe no-ops for fd-backed streams. - f:nonblock() - f:block() - - -- Rename the tmpfile to a stable name and ensure it persists after close. - local newname = fname .. ".renamed" - local rok, rerr = f:rename(newname) - assert(rok, "rename failed: " .. tostring(rerr)) - assert(f:filename() == newname, - "filename after rename was " .. tostring(f:filename()) .. ", expected " .. tostring(newname)) - - local ok, cerr = f:close() - assert(ok, "tmpfile close after rename failed: " .. tostring(cerr)) - - -- The renamed file should now exist and be openable via file_mod.open. - local f2, oerr = file_mod.open(newname, "r") - assert(f2, "expected to reopen renamed file, got error: " .. tostring(oerr)) - - local _, e = f2:read_all() - assert(e == nil, "read_all from reopened file returned error: " .. tostring(e)) - -- Content is not prescribed here; just ensure read_all works and the stream is valid. - - local ok2, cerr2 = f2:close() - assert(ok2, "reopened stream close failed: " .. tostring(cerr2)) - - -- Clean up the renamed file to avoid littering. - os.remove(newname) + local f, err = file_mod.tmpfile() + assert(f, 'tmpfile() failed: ' .. tostring(err)) + + -- is_stream + assert(stream_mod.is_stream(f), 'is_stream did not recognise a Stream') + assert(not stream_mod.is_stream(123), 'is_stream misclassified a number') + + -- filename should be non-nil for tmpfile + local fname = f:filename() + assert(fname ~= nil, 'tmpfile:filename() returned nil') + + -- setvbuf toggles line_buffering flag + assert(f.line_buffering == false, 'expected line_buffering default false') + f:setvbuf('line') + assert(f.line_buffering == true, "setvbuf('line') did not set line_buffering") + f:setvbuf('no') + assert(f.line_buffering == false, "setvbuf('no') did not clear line_buffering") + f:setvbuf('full') + assert(f.line_buffering == false, "setvbuf('full') did not clear line_buffering") + + -- nonblock/block should be safe no-ops for fd-backed streams. + f:nonblock() + f:block() + + -- Rename the tmpfile to a stable name and ensure it persists after close. + local newname = fname .. '.renamed' + local rok, rerr = f:rename(newname) + assert(rok, 'rename failed: ' .. tostring(rerr)) + assert(f:filename() == newname, + 'filename after rename was ' .. tostring(f:filename()) .. ', expected ' .. tostring(newname)) + + local ok, cerr = f:close() + assert(ok, 'tmpfile close after rename failed: ' .. tostring(cerr)) + + -- The renamed file should now exist and be openable via file_mod.open. + local f2, oerr = file_mod.open(newname, 'r') + assert(f2, 'expected to reopen renamed file, got error: ' .. tostring(oerr)) + + local _, e = f2:read_all() + assert(e == nil, 'read_all from reopened file returned error: ' .. tostring(e)) + -- Content is not prescribed here; just ensure read_all works and the stream is valid. + + local ok2, cerr2 = f2:close() + assert(ok2, 'reopened stream close failed: ' .. tostring(cerr2)) + + -- Clean up the renamed file to avoid littering. + os.remove(newname) end ---------------------------------------------------------------------- @@ -450,19 +450,19 @@ end ---------------------------------------------------------------------- local function main() - test_tmpfile_roundtrip() - test_pipe_roundtrip_and_eof() - test_closed_stream_errors() - test_cancellation_cancels_blocked_read() - - test_read_line_variants() - test_read_all_and_exactly() - test_read_numeric_formats() - test_write_variants() - test_merge_lines_op() - test_stream_properties_and_rename() + test_tmpfile_roundtrip() + test_pipe_roundtrip_and_eof() + test_closed_stream_errors() + test_cancellation_cancels_blocked_read() + + test_read_line_variants() + test_read_all_and_exactly() + test_read_numeric_formats() + test_write_variants() + test_merge_lines_op() + test_stream_properties_and_rename() end fibers.run(main) -print("test_file.lua: all assertions passed") +print('test_file.lua: all assertions passed') diff --git a/tests/test_io-mem.lua b/tests/test_io-mem.lua index 1481b93..c9f5abc 100644 --- a/tests/test_io-mem.lua +++ b/tests/test_io-mem.lua @@ -8,7 +8,7 @@ print('testing: fibers.io.mem_stream') -- look one level up -package.path = "../src/?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path local fibers = require 'fibers' @@ -17,22 +17,22 @@ local mem = require 'fibers.io.mem_backend' local function assert_eq(actual, expected, msg) - if actual ~= expected then - error(string.format("%s (expected: %s, got: %s)", msg or "assert_eq failed", - tostring(expected), tostring(actual))) - end + if actual ~= expected then + error(string.format('%s (expected: %s, got: %s)', msg or 'assert_eq failed', + tostring(expected), tostring(actual))) + end end local function assert_nil(v, msg) - if v ~= nil then - error(string.format("%s (expected nil, got: %s)", msg or "assert_nil failed", tostring(v))) - end + if v ~= nil then + error(string.format('%s (expected nil, got: %s)', msg or 'assert_nil failed', tostring(v))) + end end local function assert_true(v, msg) - if not v then - error(msg or "assert_true failed") - end + if not v then + error(msg or 'assert_true failed') + end end ---------------------------------------------------------------------- @@ -40,36 +40,36 @@ end ---------------------------------------------------------------------- local function test_simple_read_write() - local a_io, b_io = mem.pipe(1024) + local a_io, b_io = mem.pipe(1024) - local a = stream.open(a_io, true, true) - local b = stream.open(b_io, true, true) + local a = stream.open(a_io, true, true) + local b = stream.open(b_io, true, true) - local payload = "hello world" + local payload = 'hello world' - -- Writer: write once from A to B - fibers.spawn(function() - local ev = a:write_string_op(payload) - local n, err = fibers.perform(ev) - assert_nil(err, "simple write: unexpected error") - assert_eq(n, #payload, "simple write: wrong byte count") - end) + -- Writer: write once from A to B + fibers.spawn(function () + local ev = a:write_string_op(payload) + local n, err = fibers.perform(ev) + assert_nil(err, 'simple write: unexpected error') + assert_eq(n, #payload, 'simple write: wrong byte count') + end) - -- Reader: read exactly len(payload) bytes - local ev = b:read_string_op{ - min = #payload, - max = #payload, - eof_ok = true, - } + -- Reader: read exactly len(payload) bytes + local ev = b:read_string_op { + min = #payload, + max = #payload, + eof_ok = true, + } - local s, cnt, err = fibers.perform(ev) + local s, cnt, err = fibers.perform(ev) - assert_nil(err, "simple read: unexpected error") - assert_eq(cnt, #payload, "simple read: wrong count") - assert_eq(s, payload, "simple read: wrong data") + assert_nil(err, 'simple read: unexpected error') + assert_eq(cnt, #payload, 'simple read: wrong count') + assert_eq(s, payload, 'simple read: wrong data') - a:close() - b:close() + a:close() + b:close() end ---------------------------------------------------------------------- @@ -78,53 +78,53 @@ end ---------------------------------------------------------------------- local function test_backpressure_and_partial() - -- Small buffer forces backpressure. - local a_io, b_io = mem.pipe(4) + -- Small buffer forces backpressure. + local a_io, b_io = mem.pipe(4) - local a = stream.open(a_io, true, true) - local b = stream.open(b_io, true, true) + local a = stream.open(a_io, true, true) + local b = stream.open(b_io, true, true) - local payload = "abcdef" -- length 6, buffer capacity 4 + local payload = 'abcdef' -- length 6, buffer capacity 4 - -- Reader: accumulate until we have the whole payload - fibers.spawn(function() - local collected = {} - local total = 0 + -- Reader: accumulate until we have the whole payload + fibers.spawn(function () + local collected = {} + local total = 0 - while total < #payload do - -- Read at least 1 byte, at most 3 each time. - local ev = b:read_string_op{ - min = 1, - max = 3, - eof_ok = true, - } + while total < #payload do + -- Read at least 1 byte, at most 3 each time. + local ev = b:read_string_op { + min = 1, + max = 3, + eof_ok = true, + } - local s, cnt, err = fibers.perform(ev) - assert_nil(err, "backpressure read: unexpected error") + local s, cnt, err = fibers.perform(ev) + assert_nil(err, 'backpressure read: unexpected error') - if s == nil then - -- EOF; should not happen before we see all bytes. - break - end + if s == nil then + -- EOF; should not happen before we see all bytes. + break + end - assert_true(cnt > 0, "backpressure read: zero-length read with data?") - table.insert(collected, s) - total = total + cnt - end + assert_true(cnt > 0, 'backpressure read: zero-length read with data?') + table.insert(collected, s) + total = total + cnt + end - local joined = table.concat(collected) - assert_eq(joined, payload, "backpressure read: wrong data") - end) + local joined = table.concat(collected) + assert_eq(joined, payload, 'backpressure read: wrong data') + end) - -- Writer: write the full payload as one op - local ev = a:write_string_op(payload) - local n, err = fibers.perform(ev) + -- Writer: write the full payload as one op + local ev = a:write_string_op(payload) + local n, err = fibers.perform(ev) - assert_nil(err, "backpressure write: unexpected error") - assert_eq(n, #payload, "backpressure write: wrong byte count") + assert_nil(err, 'backpressure write: unexpected error') + assert_eq(n, #payload, 'backpressure write: wrong byte count') - a:close() - b:close() + a:close() + b:close() end ---------------------------------------------------------------------- @@ -132,49 +132,49 @@ end ---------------------------------------------------------------------- local function test_eof_behaviour() - local a_io, b_io = mem.pipe(1024) - - local a = stream.open(a_io, true, true) - local b = stream.open(b_io, true, true) - - local payload = "end" - - -- Write then close A's half. - fibers.spawn(function() - local ev = a:write_string_op(payload) - local n, err = fibers.perform(ev) - assert_nil(err, "EOF write: unexpected error") - assert_eq(n, #payload, "EOF write: wrong byte count") - a:close() - end) - - -- First read should get the payload. - local ev1 = b:read_string_op{ - min = #payload, - max = #payload, - eof_ok = true, - } - - local s1, cnt1, err1 = fibers.perform(ev1) - assert_nil(err1, "EOF read(1): unexpected error") - assert_eq(cnt1, #payload, "EOF read(1): wrong count") - assert_eq(s1, payload, "EOF read(1): wrong data") - - -- Second read should see EOF. For read_string_op: - -- EOF with no data → (nil, 0, err|nil) - local ev2 = b:read_string_op{ - min = 1, - max = 16, - eof_ok = true, - } - - local s2, cnt2, err2 = fibers.perform(ev2) - -- EOF is not treated as an error at this layer. - assert_nil(err2, "EOF read(2): unexpected error") - assert_nil(s2, "EOF read(2): expected nil string at EOF") - assert_eq(cnt2, 0, "EOF read(2): expected count == 0 at EOF") - - b:close() + local a_io, b_io = mem.pipe(1024) + + local a = stream.open(a_io, true, true) + local b = stream.open(b_io, true, true) + + local payload = 'end' + + -- Write then close A's half. + fibers.spawn(function () + local ev = a:write_string_op(payload) + local n, err = fibers.perform(ev) + assert_nil(err, 'EOF write: unexpected error') + assert_eq(n, #payload, 'EOF write: wrong byte count') + a:close() + end) + + -- First read should get the payload. + local ev1 = b:read_string_op { + min = #payload, + max = #payload, + eof_ok = true, + } + + local s1, cnt1, err1 = fibers.perform(ev1) + assert_nil(err1, 'EOF read(1): unexpected error') + assert_eq(cnt1, #payload, 'EOF read(1): wrong count') + assert_eq(s1, payload, 'EOF read(1): wrong data') + + -- Second read should see EOF. For read_string_op: + -- EOF with no data → (nil, 0, err|nil) + local ev2 = b:read_string_op { + min = 1, + max = 16, + eof_ok = true, + } + + local s2, cnt2, err2 = fibers.perform(ev2) + -- EOF is not treated as an error at this layer. + assert_nil(err2, 'EOF read(2): unexpected error') + assert_nil(s2, 'EOF read(2): expected nil string at EOF') + assert_eq(cnt2, 0, 'EOF read(2): expected count == 0 at EOF') + + b:close() end ---------------------------------------------------------------------- @@ -182,61 +182,61 @@ end ---------------------------------------------------------------------- local function test_line_terminator() - local a_io, b_io = mem.pipe(1024) - - local a = stream.open(a_io, true, true) - local b = stream.open(b_io, true, true) - - local data = "line1\nline2\n" - - fibers.spawn(function() - local ev = a:write_string_op(data) - local n, err = fibers.perform(ev) - assert_nil(err, "line write: unexpected error") - assert_eq(n, #data, "line write: wrong byte count") - a:close() - end) - - -- Read up to and including first "\n" - local ev1 = b:read_string_op{ - min = 1, - max = #data, - terminator = "\n", - eof_ok = true, - } - - local s1, cnt1, err1 = fibers.perform(ev1) - assert_nil(err1, "line read(1): unexpected error") - assert_eq(s1, "line1\n", "line read(1): wrong data") - assert_eq(cnt1, #s1, "line read(1): wrong count") - - -- Read up to and including second "\n" - local ev2 = b:read_string_op{ - min = 1, - max = #data, - terminator = "\n", - eof_ok = true, - } - - local s2, cnt2, err2 = fibers.perform(ev2) - assert_nil(err2, "line read(2): unexpected error") - assert_eq(s2, "line2\n", "line read(2): wrong data") - assert_eq(cnt2, #s2, "line read(2): wrong count") - - -- Third read should see EOF - local ev3 = b:read_string_op{ - min = 1, - max = 16, - terminator = "\n", - eof_ok = true, - } - - local s3, cnt3, err3 = fibers.perform(ev3) - assert_nil(err3, "line read(3): unexpected error") - assert_nil(s3, "line read(3): expected nil at EOF") - assert_eq(cnt3, 0, "line read(3): expected count == 0 at EOF") - - b:close() + local a_io, b_io = mem.pipe(1024) + + local a = stream.open(a_io, true, true) + local b = stream.open(b_io, true, true) + + local data = 'line1\nline2\n' + + fibers.spawn(function () + local ev = a:write_string_op(data) + local n, err = fibers.perform(ev) + assert_nil(err, 'line write: unexpected error') + assert_eq(n, #data, 'line write: wrong byte count') + a:close() + end) + + -- Read up to and including first "\n" + local ev1 = b:read_string_op { + min = 1, + max = #data, + terminator = '\n', + eof_ok = true, + } + + local s1, cnt1, err1 = fibers.perform(ev1) + assert_nil(err1, 'line read(1): unexpected error') + assert_eq(s1, 'line1\n', 'line read(1): wrong data') + assert_eq(cnt1, #s1, 'line read(1): wrong count') + + -- Read up to and including second "\n" + local ev2 = b:read_string_op { + min = 1, + max = #data, + terminator = '\n', + eof_ok = true, + } + + local s2, cnt2, err2 = fibers.perform(ev2) + assert_nil(err2, 'line read(2): unexpected error') + assert_eq(s2, 'line2\n', 'line read(2): wrong data') + assert_eq(cnt2, #s2, 'line read(2): wrong count') + + -- Third read should see EOF + local ev3 = b:read_string_op { + min = 1, + max = 16, + terminator = '\n', + eof_ok = true, + } + + local s3, cnt3, err3 = fibers.perform(ev3) + assert_nil(err3, 'line read(3): unexpected error') + assert_nil(s3, 'line read(3): expected nil at EOF') + assert_eq(cnt3, 0, 'line read(3): expected count == 0 at EOF') + + b:close() end ---------------------------------------------------------------------- @@ -244,22 +244,22 @@ end ---------------------------------------------------------------------- local function test_write_after_peer_close() - local a_io, b_io = mem.pipe(1024) + local a_io, b_io = mem.pipe(1024) - local a = stream.open(a_io, true, true) - local b = stream.open(b_io, true, true) + local a = stream.open(a_io, true, true) + local b = stream.open(b_io, true, true) - -- Close B immediately. - b:close() + -- Close B immediately. + b:close() - -- Writing from A should report "closed" from the backend. - local ev = a:write_string_op("x") - local _, err = fibers.perform(ev) + -- Writing from A should report "closed" from the backend. + local ev = a:write_string_op('x') + local _, err = fibers.perform(ev) - -- Depending on exact semantics, n may be 0 or nil; err should be "closed". - assert_eq(err, "closed", "write after close: expected 'closed' error") + -- Depending on exact semantics, n may be 0 or nil; err should be "closed". + assert_eq(err, 'closed', "write after close: expected 'closed' error") - a:close() + a:close() end ---------------------------------------------------------------------- @@ -267,13 +267,13 @@ end ---------------------------------------------------------------------- local function main() - test_simple_read_write() - test_backpressure_and_partial() - test_eof_behaviour() - test_line_terminator() - test_write_after_peer_close() + test_simple_read_write() + test_backpressure_and_partial() + test_eof_behaviour() + test_line_terminator() + test_write_after_peer_close() end fibers.run(main) -print("OK\tstream + mem_backend tests passed") +print('OK\tstream + mem_backend tests passed') diff --git a/tests/test_io-poller.lua b/tests/test_io-poller.lua index 12fc316..212c195 100644 --- a/tests/test_io-poller.lua +++ b/tests/test_io-poller.lua @@ -2,24 +2,24 @@ print('testing: fibers.fd') -- look one level up -package.path = "../src/?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path local file_stream = require 'fibers.io.file' local runtime = require 'fibers.runtime' local function equal(x, y) - if type(x) ~= type(y) then return false end - if type(x) == 'table' then - for k, v in pairs(x) do - if not equal(v, y[k]) then return false end - end - for k, _ in pairs(y) do - if x[k] == nil then return false end - end - return true - else - return x == y - end + if type(x) ~= type(y) then return false end + if type(x) == 'table' then + for k, v in pairs(x) do + if not equal(v, y[k]) then return false end + end + for k, _ in pairs(y) do + if x[k] == nil then return false end + end + return true + else + return x == y + end end local log = {} @@ -29,19 +29,19 @@ runtime.current_scheduler:run() assert(equal(log, {})) local rd, wr = file_stream.pipe() -local message = "hello, world\n" -runtime.spawn_raw(function() - record('rd-a') - local str = rd:read_string{min=1,max=math.huge} - record('rd-b') - record(str) +local message = 'hello, world\n' +runtime.spawn_raw(function () + record('rd-a') + local str = rd:read_string { min = 1, max = math.huge } + record('rd-b') + record(str) end) -runtime.spawn_raw(function() - record('wr-a') - wr:write(message) - record('wr-b') - wr:flush() - record('wr-c') +runtime.spawn_raw(function () + record('wr-a') + wr:write(message) + record('wr-b') + wr:flush() + record('wr-c') end) runtime.current_scheduler:run() diff --git a/tests/test_io-socket.lua b/tests/test_io-socket.lua index 31d1c45..c8d2b52 100644 --- a/tests/test_io-socket.lua +++ b/tests/test_io-socket.lua @@ -9,79 +9,79 @@ print('testing: fibers.io.socket') -- look one level up -package.path = "../src/?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path -- test_socket.lua -- -- Simple assertion-based checks for fibers.io.socket over AF_UNIX. -local fibers = require 'fibers' +local fibers = require 'fibers' local socket_mod = require 'fibers.io.socket' local perform = fibers.perform local function test_unix_socket_roundtrip(scope) - -- Construct a unique path under /tmp for this test run. - local base = os.getenv("TMPDIR") or "/tmp" - local path = string.format("%s/fibers_socket_test.%d.%d", - base, os.time(), math.random(1e6)) - - -- Start listening server. - local server, err = socket_mod.listen_unix(path, { ephemeral = true }) - assert(server, "listen_unix failed: " .. tostring(err)) - - -- Server fiber: accept one connection, echo a response, then close. - scope:spawn(function(_) - local s, aerr = server:accept() - assert(s, "server accept failed: " .. tostring(aerr)) - - local msg, cnt, rerr = perform(s:read_string_op{ - min = 5, - max = 5, - eof_ok = true, - }) - - assert(rerr == nil, "server read_string_op error: " .. tostring(rerr)) - assert(cnt == 5, "server read_string_op read " .. tostring(cnt) .. " bytes, expected 5") - assert(msg == "hello", ("server received %q, expected %q"):format(tostring(msg), "hello")) - - local n, werr = perform(s:write_string_op("world")) - assert(werr == nil, "server write_string_op error: " .. tostring(werr)) - assert(n == 5, "server write_string_op wrote " .. tostring(n) .. " bytes, expected 5") - - local okc, cerr = s:close() - assert(okc, "server stream close failed: " .. tostring(cerr)) - - local oks, serr = server:close() - assert(oks, "server socket close failed: " .. tostring(serr)) - end) - - -- Client side: connect, send "hello", read "world". - local client, cerr = socket_mod.connect_unix(path) - assert(client, "connect_unix failed: " .. tostring(cerr)) - - local n, werr = perform(client:write_string_op("hello")) - assert(werr == nil, "client write_string_op error: " .. tostring(werr)) - assert(n == 5, "client write_string_op wrote " .. tostring(n) .. " bytes, expected 5") - - local resp, cnt, rerr = perform(client:read_string_op{ - min = 5, - max = 5, - eof_ok = true, - }) - - assert(rerr == nil, "client read_string_op error: " .. tostring(rerr)) - assert(cnt == 5, "client read_string_op read " .. tostring(cnt) .. " bytes, expected 5") - assert(resp == "world", ("client received %q, expected %q"):format(tostring(resp), "world")) - - local okc, cclose_err = client:close() - assert(okc, "client stream close failed: " .. tostring(cclose_err)) + -- Construct a unique path under /tmp for this test run. + local base = os.getenv('TMPDIR') or '/tmp' + local path = string.format('%s/fibers_socket_test.%d.%d', + base, os.time(), math.random(1e6)) + + -- Start listening server. + local server, err = socket_mod.listen_unix(path, { ephemeral = true }) + assert(server, 'listen_unix failed: ' .. tostring(err)) + + -- Server fiber: accept one connection, echo a response, then close. + scope:spawn(function (_) + local s, aerr = server:accept() + assert(s, 'server accept failed: ' .. tostring(aerr)) + + local msg, cnt, rerr = perform(s:read_string_op { + min = 5, + max = 5, + eof_ok = true, + }) + + assert(rerr == nil, 'server read_string_op error: ' .. tostring(rerr)) + assert(cnt == 5, 'server read_string_op read ' .. tostring(cnt) .. ' bytes, expected 5') + assert(msg == 'hello', ('server received %q, expected %q'):format(tostring(msg), 'hello')) + + local n, werr = perform(s:write_string_op('world')) + assert(werr == nil, 'server write_string_op error: ' .. tostring(werr)) + assert(n == 5, 'server write_string_op wrote ' .. tostring(n) .. ' bytes, expected 5') + + local okc, cerr = s:close() + assert(okc, 'server stream close failed: ' .. tostring(cerr)) + + local oks, serr = server:close() + assert(oks, 'server socket close failed: ' .. tostring(serr)) + end) + + -- Client side: connect, send "hello", read "world". + local client, cerr = socket_mod.connect_unix(path) + assert(client, 'connect_unix failed: ' .. tostring(cerr)) + + local n, werr = perform(client:write_string_op('hello')) + assert(werr == nil, 'client write_string_op error: ' .. tostring(werr)) + assert(n == 5, 'client write_string_op wrote ' .. tostring(n) .. ' bytes, expected 5') + + local resp, cnt, rerr = perform(client:read_string_op { + min = 5, + max = 5, + eof_ok = true, + }) + + assert(rerr == nil, 'client read_string_op error: ' .. tostring(rerr)) + assert(cnt == 5, 'client read_string_op read ' .. tostring(cnt) .. ' bytes, expected 5') + assert(resp == 'world', ('client received %q, expected %q'):format(tostring(resp), 'world')) + + local okc, cclose_err = client:close() + assert(okc, 'client stream close failed: ' .. tostring(cclose_err)) end local function main(scope) - test_unix_socket_roundtrip(scope) + test_unix_socket_roundtrip(scope) end fibers.run(main) -print("test_socket.lua: all assertions passed") +print('test_socket.lua: all assertions passed') diff --git a/tests/test_io-stream.lua b/tests/test_io-stream.lua index a75fb75..6944163 100644 --- a/tests/test_io-stream.lua +++ b/tests/test_io-stream.lua @@ -4,7 +4,7 @@ print('testing: fibers.io.stream') -- look one level up -package.path = "../src/?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path local fibers = require 'fibers' local stream = require 'fibers.io.stream' @@ -14,109 +14,114 @@ local sleep = require 'fibers.sleep' -- In-memory duplex backend with partial I/O and readiness notifications. local function make_stream_pair() - local shared = { - buf = "", - closed = false, - waitset = wait.new_waitset(), -- key "rd" for readability - } - - local rd_io = { shared = shared } - local wr_io = { shared = shared } - - -- Backend read: string-or-nil as per StreamBackend contract. - function rd_io:read_string(max) - if #self.shared.buf == 0 then - if self.shared.closed then - return "", nil -- EOF - end - return nil, nil -- would block - end - max = max or 1 - -- Deliberately read at most 1 byte to exercise partial reads. - local n = math.min(1, max, #self.shared.buf) - local s = self.shared.buf:sub(1, n) - self.shared.buf = self.shared.buf:sub(n + 1) - return s, nil - end - - -- Backend write: always writes 1 byte to exercise partial writes. - function wr_io:write_string(str) - if self.shared.closed then - return nil, "closed" - end - if #str == 0 then - return 0, nil - end - local n = 1 - local ch = str:sub(1, n) - shared.buf = shared.buf .. ch - -- Notify any waiting readers. - shared.waitset:notify_all("rd", runtime.current_scheduler) - return n, nil - end - - function rd_io:on_readable(task) - return shared.waitset:add("rd", task) - end - - function wr_io:on_writable(task) - -- Always writable: just schedule immediately. - runtime.current_scheduler:schedule(task) - return { unlink = function() end } - end - - function rd_io:close() - shared.closed = true - end - - function wr_io:close() - shared.closed = true - end - - -- Optional methods used by Stream; safe no-ops here. - function rd_io:seek() return nil, "not seekable" end - function wr_io:seek() return nil, "not seekable" end - function rd_io:nonblock() end - function rd_io:block() end - function wr_io:nonblock() end - function wr_io:block() end - - local rd = stream.open(rd_io, true, false) - local wr = stream.open(wr_io, false, true) - return rd, wr, shared + local shared = { + buf = '', + closed = false, + waitset = wait.new_waitset(), -- key "rd" for readability + } + + local rd_io = { shared = shared } + local wr_io = { shared = shared } + + -- Backend read: string-or-nil as per StreamBackend contract. + function rd_io:read_string(max) + if #self.shared.buf == 0 then + if self.shared.closed then + return '', nil -- EOF + end + return nil, nil -- would block + end + max = max or 1 + -- Deliberately read at most 1 byte to exercise partial reads. + local n = math.min(1, max, #self.shared.buf) + local s = self.shared.buf:sub(1, n) + self.shared.buf = self.shared.buf:sub(n + 1) + return s, nil + end + + -- Backend write: always writes 1 byte to exercise partial writes. + function wr_io:write_string(str) + if self.shared.closed then + return nil, 'closed' + end + if #str == 0 then + return 0, nil + end + local n = 1 + local ch = str:sub(1, n) + shared.buf = shared.buf .. ch + -- Notify any waiting readers. + shared.waitset:notify_all('rd', runtime.current_scheduler) + return n, nil + end + + function rd_io:on_readable(task) + return shared.waitset:add('rd', task) + end + + function wr_io:on_writable(task) + -- Always writable: just schedule immediately. + runtime.current_scheduler:schedule(task) + return { unlink = function () end } + end + + function rd_io:close() + shared.closed = true + end + + function wr_io:close() + shared.closed = true + end + + -- Optional methods used by Stream; safe no-ops here. + function rd_io:seek() return nil, 'not seekable' end + + function wr_io:seek() return nil, 'not seekable' end + + function rd_io:nonblock() end + + function rd_io:block() end + + function wr_io:nonblock() end + + function wr_io:block() end + + local rd = stream.open(rd_io, true, false) + local wr = stream.open(wr_io, false, true) + return rd, wr, shared end local function test() - local rd, wr, shared = make_stream_pair() + local rd, wr, shared = make_stream_pair() - wr:setvbuf('line') - assert(wr.line_buffering == true, "setvbuf('line') did not set line_buffering") + wr:setvbuf('line') + assert(wr.line_buffering == true, "setvbuf('line') did not set line_buffering") - local message = "hello, world\n" + local message = 'hello, world\n' - -- Writer runs in a fiber, so the first read will block and use on_readable. - fibers.spawn(function() - sleep.sleep(0.01) - local n, err = wr:write(message) - assert(err == nil, "write error: " .. tostring(err)) - assert(n == #message, "write wrote " .. tostring(n) .. " bytes, expected " .. #message) - wr:close() - end) + -- Writer runs in a fiber, so the first read will block and use on_readable. + fibers.spawn(function () + sleep.sleep(0.01) + local n, err = wr:write(message) + assert(err == nil, 'write error: ' .. tostring(err)) + assert(n == #message, 'write wrote ' .. tostring(n) .. ' bytes, expected ' .. #message) + wr:close() + end) - -- Read full line including terminator (exercise waitable + partial I/O). - local line, err = rd:read("*L") - assert(err == nil, "read('*L') returned error: " .. tostring(err)) - assert(line == message, - ("read('*L') returned %q, expected %q"):format(tostring(line), tostring(message))) + -- Read full line including terminator (exercise waitable + partial I/O). + local line, err = rd:read('*L') + assert(err == nil, "read('*L') returned error: " .. tostring(err)) + assert(line == message, + ("read('*L') returned %q, expected %q"):format(tostring(line), tostring(message))) - rd:close() + rd:close() - -- After close, no readers should remain registered. - assert(shared.waitset:size("rd") == 0, "waitset still has readers after close") + -- After close, no readers should remain registered. + assert(shared.waitset:size('rd') == 0, 'waitset still has readers after close') end local function main() - test() + test() end fibers.run(main) diff --git a/tests/test_op.lua b/tests/test_op.lua index 9854f46..05e8120 100644 --- a/tests/test_op.lua +++ b/tests/test_op.lua @@ -1,15 +1,15 @@ -- fibers/op compact but comprehensive test (no poll) -print("testing: fibers.op") +print('testing: fibers.op') -- look one level up -package.path = "../src/?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path local op = require 'fibers.op' local runtime = require 'fibers.runtime' local perform, choice = require 'fibers.performer'.perform, op.choice -local always = op.always -local never = op.never +local always = op.always +local never = op.never ------------------------------------------------------------ -- Helpers @@ -17,410 +17,410 @@ local never = op.never -- 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) - local t = suspension:complete_task(wrap_fn, val) - suspension.sched:schedule(t) - end - local ev = op.new_primitive(nil, try_fn, block_fn) - return ev, function() return tries end + local tries = 0 + local function try_fn() + tries = tries + 1 + return false + end + local function block_fn(suspension, wrap_fn) + local t = suspension:complete_task(wrap_fn, val) + suspension.sched:schedule(t) + end + local ev = op.new_primitive(nil, try_fn, block_fn) + return ev, function () return tries end end ------------------------------------------------------------ -- Run all tests inside a single top-level fiber ------------------------------------------------------------ -runtime.spawn_raw(function() - - -------------------------------------------------------- - -- 1) Base op: perform, or_else, wrap - -------------------------------------------------------- - do - local base = always(1) - assert(perform(base) == 1, "base: perform failed") - - -- or_else: op wins, fallback ignored - local base2 = always(2) - local ev1 = base2:or_else(function() return 9 end) - local palt1 = perform(ev1) - assert(palt1 == 2, "base: or_else should use op result") - - -- or_else: never-ready op → fallback wins - local ev2 = never():or_else(function() return 99 end) - local palt2 = perform(ev2) - assert(palt2 == 99, "base: or_else should use fallback when op can't commit") - - -- or_else: async op is not ready now, so fallback wins - do - local fallback_called = false - local ev_async, tries = async_task(123) - local ev = ev_async:or_else(function() - fallback_called = true - return -1 - end) - local r = perform(ev) - - assert(r == -1, "or_else(async): expected fallback to win") - assert(tries() == 1, "async_task: try_fn should be called exactly once") - assert(fallback_called == true, - "or_else(async): fallback should run when op is not ready") - end - - -- nested wrap: ((x + 1) * 2) - local ev3 = always(5) - :wrap(function(x) return x + 1 end) - :wrap(function(y) return y * 2 end) - local v3 = perform(ev3) - assert(v3 == 12, "nested wrap: wrong result") - end - - -------------------------------------------------------- - -- 2) Blocking path: async_task - -------------------------------------------------------- - do - local ev, tries = async_task(42) - local v = perform(ev) - assert(v == 42, "async_task: wrong result") - assert(tries() == 1, "async_task: try_fn not called exactly once") - end - - -------------------------------------------------------- - -- 3) Choice + wrap - -------------------------------------------------------- - do - -- choice over multiple ready ops - local choice_ev = choice(always(1), always(2), always(3)) - for _ = 1, 5 do - local v = perform(choice_ev) - assert(v == 1 or v == 2 or v == 3, - "choice(ready): result not in {1,2,3}") - end - - -- wrap on choice - local ev = choice(always(1), always(2)):wrap(function(x) - return x * 10 - end) - local v = perform(ev) - assert(v == 10 or v == 20, "wrap(choice): wrong result") - end - - -------------------------------------------------------- - -- 4) Guard: basic + in choice + with with_nack - -------------------------------------------------------- - do - -- basic guard - local calls = 0 - local g = function() - calls = calls + 1 - return always(42) - end - local ev = op.guard(g) - local v = perform(ev) - assert(v == 42, "guard basic: wrong result") - assert(calls == 1, "guard basic: builder not called once") - - -- guard in choice (ensures builder runs each sync) - local calls2 = 0 - local guarded = op.guard(function() - calls2 = calls2 + 1 - return always(10) - end) - local choice_ev = choice(guarded, always(20)) - local runs = 5 - for _ = 1, runs do - local r = perform(choice_ev) - 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 + with_nack (builder returns a with_nack op) - local guard_calls, cancelled = 0, false - local guarded_nack = op.guard(function() - guard_calls = guard_calls + 1 - return op.with_nack(function(nack_ev) - runtime.spawn_raw(function() - perform(nack_ev) - cancelled = true - end) - return never() - end) - end) - - local ev2 = choice(guarded_nack, always("OK")) - local v2 = perform(ev2) - assert(v2 == "OK", "guard+with_nack: wrong winner") - - runtime.yield() - assert(cancelled == true, "guard+with_nack: nack not fired") - assert(guard_calls == 1, "guard+with_nack: builder not once") - end - - -------------------------------------------------------- - -- 5) or_else on composite ops - -------------------------------------------------------- - do - -- composite ready: choice(always, always) - local comp_ready = choice(always(1), always(2)) - local ev1 = comp_ready:or_else(function() return 99 end) - local r1 = perform(ev1) - assert(r1 == 1 or r1 == 2, - "or_else(composite ready): wrong result") - - -- composite never-ready: choice(never, never) → fallback - local comp_never = choice(never(), never()) - local ev2 = comp_never:or_else(function() return 42 end) - local r2 = perform(ev2) - assert(r2 == 42, "or_else(composite none): fallback not used") - end - - -------------------------------------------------------- - -- 6) with_nack: winner vs loser, basic nesting - -------------------------------------------------------- - do - -- 6.1 with_nack branch wins: nack must NOT fire - do - local cancelled = false - local with_nack_ev = op.with_nack(function(nack_ev) - runtime.spawn_raw(function() - perform(nack_ev) - cancelled = true - end) - return always("WIN") - end) - - local ev = choice(with_nack_ev, never()) - local v = perform(ev) - assert(v == "WIN", "with_nack win: wrong winner") - - runtime.yield() - assert(cancelled == false, "with_nack win: nack fired unexpectedly") - end - - -- 6.2 with_nack branch loses: nack MUST fire - do - local cancelled = false - local with_nack_ev = op.with_nack(function(nack_ev) - runtime.spawn_raw(function() - perform(nack_ev) - cancelled = true - end) - return never() - end) - - local ev = choice(with_nack_ev, always("OTHER")) - local v = perform(ev) - assert(v == "OTHER", "with_nack loss: wrong winner") - - runtime.yield() - assert(cancelled == true, "with_nack loss: nack did not fire") - end - - -- 6.3 basic nested with_nack: - -- outer subtree wins via inner leaf → neither nack fires. - do - local outer_cancelled, inner_cancelled = false, false - - local outer = op.with_nack(function(outer_nack_ev) - runtime.spawn_raw(function() - perform(outer_nack_ev) - outer_cancelled = true - end) - - return op.with_nack(function(inner_nack_ev) - runtime.spawn_raw(function() - perform(inner_nack_ev) - inner_cancelled = true - end) - return always("INNER_WIN") - end) - end) - - local ev = choice(outer, never()) - local v = perform(ev) - assert(v == "INNER_WIN", - "nested with_nack: wrong result") - - runtime.yield() - assert(outer_cancelled == false, - "nested with_nack: outer nack fired unexpectedly") - assert(inner_cancelled == false, - "nested with_nack: inner nack fired unexpectedly") - end - end - - -------------------------------------------------------- - -- 7) bracket: RAII-style resource management over ops - -------------------------------------------------------- - do - ---------------------------------------------------- - -- 7.1 basic success: inner op wins → aborted=false - ---------------------------------------------------- - do - local acq_count = 0 - local rel_count = 0 - local use_count = 0 - local last_res, last_aborted - - local ev = op.bracket( - function() - acq_count = acq_count + 1 - return "RESOURCE" - end, - function(res, aborted) - rel_count = rel_count + 1 - last_res = res - last_aborted = aborted - end, - function(res) - use_count = use_count + 1 - assert(res == "RESOURCE", "bracket basic: wrong resource") - return always(99) - end - ) - - local v = perform(ev) - assert(v == 99, "bracket basic: wrong result") - - assert(acq_count == 1, "bracket basic: acquire not once") - assert(use_count == 1, "bracket basic: use not once") - assert(rel_count == 1, "bracket basic: release not once") - assert(last_res == "RESOURCE", "bracket basic: wrong res in release") - assert(last_aborted == false, - "bracket basic: aborted flag should be false on success") - end - - ---------------------------------------------------- - -- 7.2 losing branch in choice → aborted=true - ---------------------------------------------------- - do - local acq_count, use_count, rel_count = 0, 0, 0 - local last_aborted - - local bracket_ev = op.bracket( - function() - acq_count = acq_count + 1 - return "R" - end, - function(_, aborted) - rel_count = rel_count + 1 - last_aborted = aborted - end, - function(r) - use_count = use_count + 1 - assert(r == "R") - return never() - end - ) - - local ev = choice(bracket_ev, always("WIN")) - local v = perform(ev) - assert(v == "WIN", "bracket choice: wrong winner") - - assert(acq_count == 1, "bracket choice: acquire not once") - assert(use_count == 1, "bracket choice: use not once") - assert(rel_count == 1, "bracket choice: release not once") - assert(last_aborted == true, - "bracket choice: aborted flag should be true when losing") - end - - ---------------------------------------------------- - -- 7.3 bracket + or_else: - -- bracket arm never commits → aborted=true, fallback used - ---------------------------------------------------- - do - local acq_count, use_count, rel_count = 0, 0, 0 - local last_aborted, fallback_called - - local bracket_ev = op.bracket( - function() - acq_count = acq_count + 1 - return "R" - end, - function(_, aborted) - rel_count = rel_count + 1 - last_aborted = aborted - end, - function(r) - use_count = use_count + 1 - assert(r == "R") - return never() - end - ) - - local ev = bracket_ev:or_else(function() - fallback_called = true - return "FALLBACK" - end) - - local res = perform(ev) - - assert(res == "FALLBACK", - "bracket+or_else: expected fallback result") - assert(fallback_called == true, - "bracket+or_else: fallback thunk not called") - - assert(acq_count == 1, "bracket+or_else: acquire once") - assert(use_count == 1, "bracket+or_else: use once") - assert(rel_count == 1, "bracket+or_else: release once") - assert(last_aborted == true, - "bracket+or_else: aborted flag should be true when losing") - end - end - - -------------------------------------------------------- - -- 8) finally: cleanup on success and on abort - -- - -- In the new model: - -- finally(cleanup) is about lifetime: - -- * cleanup(false) on normal success - -- * cleanup(true) if the op participates in a choice and loses - -- It does not intercept Lua errors; those are handled by scopes. - -------------------------------------------------------- - do - -- 8.1 success path: cleanup(false) once, result propagated - do - local calls = {} - local base = always(7) - - local ev = base:finally(function(aborted) - calls[#calls + 1] = aborted - end) - - local r = perform(ev) - assert(r == 7, "finally(success): wrong result") - assert(#calls == 1, "finally(success): cleanup not called once") - assert(calls[1] == false, - "finally(success): aborted should be false") - end - - -- 8.2 abort path: op loses in a choice → cleanup(true) - do - local calls = {} - - -- This op never commits, so in choice it always loses. - local base = never():finally(function(aborted) - calls[#calls + 1] = aborted - end) - - local ev = choice(base, always("WIN")) - local r = perform(ev) - - assert(r == "WIN", - "finally(abort): wrong winner") - assert(#calls == 1, - "finally(abort): cleanup not called once") - assert(calls[1] == true, - "finally(abort): aborted should be true") - end - end - - print("fibers.op tests: ok") - runtime.stop() +runtime.spawn_raw(function () + + -------------------------------------------------------- + -- 1) Base op: perform, or_else, wrap + -------------------------------------------------------- + do + local base = always(1) + assert(perform(base) == 1, 'base: perform failed') + + -- or_else: op wins, fallback ignored + local base2 = always(2) + local ev1 = base2:or_else(function () return 9 end) + local palt1 = perform(ev1) + assert(palt1 == 2, 'base: or_else should use op result') + + -- or_else: never-ready op → fallback wins + local ev2 = never():or_else(function () return 99 end) + local palt2 = perform(ev2) + assert(palt2 == 99, "base: or_else should use fallback when op can't commit") + + -- or_else: async op is not ready now, so fallback wins + do + local fallback_called = false + local ev_async, tries = async_task(123) + local ev = ev_async:or_else(function () + fallback_called = true + return -1 + end) + local r = perform(ev) + + assert(r == -1, 'or_else(async): expected fallback to win') + assert(tries() == 1, 'async_task: try_fn should be called exactly once') + assert(fallback_called == true, + 'or_else(async): fallback should run when op is not ready') + end + + -- nested wrap: ((x + 1) * 2) + local ev3 = always(5) + :wrap(function (x) return x + 1 end) + :wrap(function (y) return y * 2 end) + local v3 = perform(ev3) + assert(v3 == 12, 'nested wrap: wrong result') + end + + -------------------------------------------------------- + -- 2) Blocking path: async_task + -------------------------------------------------------- + do + local ev, tries = async_task(42) + local v = perform(ev) + assert(v == 42, 'async_task: wrong result') + assert(tries() == 1, 'async_task: try_fn not called exactly once') + end + + -------------------------------------------------------- + -- 3) Choice + wrap + -------------------------------------------------------- + do + -- choice over multiple ready ops + local choice_ev = choice(always(1), always(2), always(3)) + for _ = 1, 5 do + local v = perform(choice_ev) + assert(v == 1 or v == 2 or v == 3, + 'choice(ready): result not in {1,2,3}') + end + + -- wrap on choice + local ev = choice(always(1), always(2)):wrap(function (x) + return x * 10 + end) + local v = perform(ev) + assert(v == 10 or v == 20, 'wrap(choice): wrong result') + end + + -------------------------------------------------------- + -- 4) Guard: basic + in choice + with with_nack + -------------------------------------------------------- + do + -- basic guard + local calls = 0 + local g = function () + calls = calls + 1 + return always(42) + end + local ev = op.guard(g) + local v = perform(ev) + assert(v == 42, 'guard basic: wrong result') + assert(calls == 1, 'guard basic: builder not called once') + + -- guard in choice (ensures builder runs each sync) + local calls2 = 0 + local guarded = op.guard(function () + calls2 = calls2 + 1 + return always(10) + end) + local choice_ev = choice(guarded, always(20)) + local runs = 5 + for _ = 1, runs do + local r = perform(choice_ev) + 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 + with_nack (builder returns a with_nack op) + local guard_calls, cancelled = 0, false + local guarded_nack = op.guard(function () + guard_calls = guard_calls + 1 + return op.with_nack(function (nack_ev) + runtime.spawn_raw(function () + perform(nack_ev) + cancelled = true + end) + return never() + end) + end) + + local ev2 = choice(guarded_nack, always('OK')) + local v2 = perform(ev2) + assert(v2 == 'OK', 'guard+with_nack: wrong winner') + + runtime.yield() + assert(cancelled == true, 'guard+with_nack: nack not fired') + assert(guard_calls == 1, 'guard+with_nack: builder not once') + end + + -------------------------------------------------------- + -- 5) or_else on composite ops + -------------------------------------------------------- + do + -- composite ready: choice(always, always) + local comp_ready = choice(always(1), always(2)) + local ev1 = comp_ready:or_else(function () return 99 end) + local r1 = perform(ev1) + assert(r1 == 1 or r1 == 2, + 'or_else(composite ready): wrong result') + + -- composite never-ready: choice(never, never) → fallback + local comp_never = choice(never(), never()) + local ev2 = comp_never:or_else(function () return 42 end) + local r2 = perform(ev2) + assert(r2 == 42, 'or_else(composite none): fallback not used') + end + + -------------------------------------------------------- + -- 6) with_nack: winner vs loser, basic nesting + -------------------------------------------------------- + do + -- 6.1 with_nack branch wins: nack must NOT fire + do + local cancelled = false + local with_nack_ev = op.with_nack(function (nack_ev) + runtime.spawn_raw(function () + perform(nack_ev) + cancelled = true + end) + return always('WIN') + end) + + local ev = choice(with_nack_ev, never()) + local v = perform(ev) + assert(v == 'WIN', 'with_nack win: wrong winner') + + runtime.yield() + assert(cancelled == false, 'with_nack win: nack fired unexpectedly') + end + + -- 6.2 with_nack branch loses: nack MUST fire + do + local cancelled = false + local with_nack_ev = op.with_nack(function (nack_ev) + runtime.spawn_raw(function () + perform(nack_ev) + cancelled = true + end) + return never() + end) + + local ev = choice(with_nack_ev, always('OTHER')) + local v = perform(ev) + assert(v == 'OTHER', 'with_nack loss: wrong winner') + + runtime.yield() + assert(cancelled == true, 'with_nack loss: nack did not fire') + end + + -- 6.3 basic nested with_nack: + -- outer subtree wins via inner leaf → neither nack fires. + do + local outer_cancelled, inner_cancelled = false, false + + local outer = op.with_nack(function (outer_nack_ev) + runtime.spawn_raw(function () + perform(outer_nack_ev) + outer_cancelled = true + end) + + return op.with_nack(function (inner_nack_ev) + runtime.spawn_raw(function () + perform(inner_nack_ev) + inner_cancelled = true + end) + return always('INNER_WIN') + end) + end) + + local ev = choice(outer, never()) + local v = perform(ev) + assert(v == 'INNER_WIN', + 'nested with_nack: wrong result') + + runtime.yield() + assert(outer_cancelled == false, + 'nested with_nack: outer nack fired unexpectedly') + assert(inner_cancelled == false, + 'nested with_nack: inner nack fired unexpectedly') + end + end + + -------------------------------------------------------- + -- 7) bracket: RAII-style resource management over ops + -------------------------------------------------------- + do + ---------------------------------------------------- + -- 7.1 basic success: inner op wins → aborted=false + ---------------------------------------------------- + do + local acq_count = 0 + local rel_count = 0 + local use_count = 0 + local last_res, last_aborted + + local ev = op.bracket( + function () + acq_count = acq_count + 1 + return 'RESOURCE' + end, + function (res, aborted) + rel_count = rel_count + 1 + last_res = res + last_aborted = aborted + end, + function (res) + use_count = use_count + 1 + assert(res == 'RESOURCE', 'bracket basic: wrong resource') + return always(99) + end + ) + + local v = perform(ev) + assert(v == 99, 'bracket basic: wrong result') + + assert(acq_count == 1, 'bracket basic: acquire not once') + assert(use_count == 1, 'bracket basic: use not once') + assert(rel_count == 1, 'bracket basic: release not once') + assert(last_res == 'RESOURCE', 'bracket basic: wrong res in release') + assert(last_aborted == false, + 'bracket basic: aborted flag should be false on success') + end + + ---------------------------------------------------- + -- 7.2 losing branch in choice → aborted=true + ---------------------------------------------------- + do + local acq_count, use_count, rel_count = 0, 0, 0 + local last_aborted + + local bracket_ev = op.bracket( + function () + acq_count = acq_count + 1 + return 'R' + end, + function (_, aborted) + rel_count = rel_count + 1 + last_aborted = aborted + end, + function (r) + use_count = use_count + 1 + assert(r == 'R') + return never() + end + ) + + local ev = choice(bracket_ev, always('WIN')) + local v = perform(ev) + assert(v == 'WIN', 'bracket choice: wrong winner') + + assert(acq_count == 1, 'bracket choice: acquire not once') + assert(use_count == 1, 'bracket choice: use not once') + assert(rel_count == 1, 'bracket choice: release not once') + assert(last_aborted == true, + 'bracket choice: aborted flag should be true when losing') + end + + ---------------------------------------------------- + -- 7.3 bracket + or_else: + -- bracket arm never commits → aborted=true, fallback used + ---------------------------------------------------- + do + local acq_count, use_count, rel_count = 0, 0, 0 + local last_aborted, fallback_called + + local bracket_ev = op.bracket( + function () + acq_count = acq_count + 1 + return 'R' + end, + function (_, aborted) + rel_count = rel_count + 1 + last_aborted = aborted + end, + function (r) + use_count = use_count + 1 + assert(r == 'R') + return never() + end + ) + + local ev = bracket_ev:or_else(function () + fallback_called = true + return 'FALLBACK' + end) + + local res = perform(ev) + + assert(res == 'FALLBACK', + 'bracket+or_else: expected fallback result') + assert(fallback_called == true, + 'bracket+or_else: fallback thunk not called') + + assert(acq_count == 1, 'bracket+or_else: acquire once') + assert(use_count == 1, 'bracket+or_else: use once') + assert(rel_count == 1, 'bracket+or_else: release once') + assert(last_aborted == true, + 'bracket+or_else: aborted flag should be true when losing') + end + end + + -------------------------------------------------------- + -- 8) finally: cleanup on success and on abort + -- + -- In the new model: + -- finally(cleanup) is about lifetime: + -- * cleanup(false) on normal success + -- * cleanup(true) if the op participates in a choice and loses + -- It does not intercept Lua errors; those are handled by scopes. + -------------------------------------------------------- + do + -- 8.1 success path: cleanup(false) once, result propagated + do + local calls = {} + local base = always(7) + + local ev = base:finally(function (aborted) + calls[#calls + 1] = aborted + end) + + local r = perform(ev) + assert(r == 7, 'finally(success): wrong result') + assert(#calls == 1, 'finally(success): cleanup not called once') + assert(calls[1] == false, + 'finally(success): aborted should be false') + end + + -- 8.2 abort path: op loses in a choice → cleanup(true) + do + local calls = {} + + -- This op never commits, so in choice it always loses. + local base = never():finally(function (aborted) + calls[#calls + 1] = aborted + end) + + local ev = choice(base, always('WIN')) + local r = perform(ev) + + assert(r == 'WIN', + 'finally(abort): wrong winner') + assert(#calls == 1, + 'finally(abort): cleanup not called once') + assert(calls[1] == true, + 'finally(abort): aborted should be true') + end + end + + print('fibers.op tests: ok') + runtime.stop() end) runtime.main() diff --git a/tests/test_runtime.lua b/tests/test_runtime.lua index c0e37de..be2f17f 100644 --- a/tests/test_runtime.lua +++ b/tests/test_runtime.lua @@ -2,66 +2,66 @@ print('testing: fibers.fiber') -- look one level up -package.path = "../src/?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path local runtime = require 'fibers.runtime' local time = require 'fibers.utils.time' local function equal(x, y) - if type(x) ~= type(y) then return false end - if type(x) == 'table' then - for k, v in pairs(x) do - if not equal(v, y[k]) then return false end - end - for k, _ in pairs(y) do - if x[k] == nil then return false end - end - return true - else - return x == y - end + if type(x) ~= type(y) then return false end + if type(x) == 'table' then + for k, v in pairs(x) do + if not equal(v, y[k]) then return false end + end + for k, _ in pairs(y) do + if x[k] == nil then return false end + end + return true + else + return x == y + end end local log = {} local function record(x) table.insert(log, x) end -runtime.spawn_raw(function() - record('a'); runtime.yield(); record('b'); runtime.yield(); record('c') +runtime.spawn_raw(function () + record('a'); runtime.yield(); record('b'); runtime.yield(); record('c') end) assert(equal(log, {})) runtime.current_scheduler:run() -assert(equal(log, {'a'})) +assert(equal(log, { 'a' })) runtime.current_scheduler:run() -assert(equal(log, {'a', 'b'})) +assert(equal(log, { 'a', 'b' })) runtime.current_scheduler:run() -assert(equal(log, {'a', 'b', 'c'})) +assert(equal(log, { 'a', 'b', 'c' })) runtime.current_scheduler:run() -assert(equal(log, {'a', 'b', 'c'})) +assert(equal(log, { 'a', 'b', 'c' })) -- Test performance local start_time = time.monotonic() local fiber_count = 1e4 local count = 0 local function inc() - count = count + 1 + count = count + 1 end -for _=1, fiber_count do - runtime.spawn_raw(function() - inc(); runtime.yield(); inc(); runtime.yield(); inc() - end) +for _ = 1, fiber_count do + runtime.spawn_raw(function () + inc(); runtime.yield(); inc(); runtime.yield(); inc() + end) end local end_time = time.monotonic() -print("Fiber creation time: "..(end_time - start_time)/fiber_count) +print('Fiber creation time: ' .. (end_time - start_time) / fiber_count) start_time = time.monotonic() -for _=1,3*fiber_count do -- run fibers, each fiber yields 3 times - runtime.current_scheduler:run() +for _ = 1, 3 * fiber_count do -- run fibers, each fiber yields 3 times + runtime.current_scheduler:run() end end_time = time.monotonic() -print("Fiber operation time: "..(end_time - start_time)/(2*3*fiber_count)) +print('Fiber operation time: ' .. (end_time - start_time) / (2 * 3 * fiber_count)) -assert(count == 3*fiber_count) +assert(count == 3 * fiber_count) print('test: ok') diff --git a/tests/test_sched.lua b/tests/test_sched.lua index 980de47..802d2a7 100644 --- a/tests/test_sched.lua +++ b/tests/test_sched.lua @@ -1,8 +1,8 @@ --- Tests the Scheduler implementation. -print("test: fibers.sched") +print('test: fibers.sched') -- look one level up -package.path = "../src/?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path local sched = require 'fibers.sched' local time = require 'fibers.utils.time' @@ -11,17 +11,17 @@ local time = require 'fibers.utils.time' local start_time = time.monotonic() local scheduler = sched.new() local end_time = time.monotonic() -print("Scheduler initialization time: " .. (end_time - start_time)) +print('Scheduler initialization time: ' .. (end_time - start_time)) local count = 0 local totalImprecision = 0 local function task_run(task) - local now = scheduler:now() - local t = task.scheduled - count = count + 1 - local imprecision = math.abs(t - now) - totalImprecision = totalImprecision + imprecision + local now = scheduler:now() + local t = task.scheduled + count = count + 1 + local imprecision = math.abs(t - now) + totalImprecision = totalImprecision + imprecision end local event_count = 1e4 @@ -29,22 +29,22 @@ local t = scheduler:now() -- Measure task scheduling time start_time = time.monotonic() -for _=1,event_count do - local dt = math.random()/1e2 - t = t + dt - scheduler:schedule_at_time(t, {run=task_run, scheduled=t}) +for _ = 1, event_count do + local dt = math.random() / 1e2 + t = t + dt + scheduler:schedule_at_time(t, { run = task_run, scheduled = t }) end end_time = time.monotonic() -print("Task scheduling time: " .. (end_time - start_time)/event_count) +print('Task scheduling time: ' .. (end_time - start_time) / event_count) while count < event_count do - local nextEventTime = scheduler:next_wake_time() - scheduler:run(nextEventTime) + local nextEventTime = scheduler:next_wake_time() + scheduler:run(nextEventTime) end assert(count == event_count) local averageImprecision = totalImprecision / event_count -print("Average imprecision: " .. averageImprecision) +print('Average imprecision: ' .. averageImprecision) -print("test: ok") +print('test: ok') diff --git a/tests/test_scope.lua b/tests/test_scope.lua index fbb8c21..3c2b288 100644 --- a/tests/test_scope.lua +++ b/tests/test_scope.lua @@ -1,146 +1,146 @@ --- Tests the Scope implementation. -print("test: fibers.scope") +print('test: fibers.scope') -- look one level up -package.path = "../src/?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path -local runtime = require "fibers.runtime" -local scope = require "fibers.scope" -local op = require "fibers.op" -local performer = require "fibers.performer" -local cond_mod = require "fibers.cond" +local runtime = require 'fibers.runtime' +local scope = require 'fibers.scope' +local op = require 'fibers.op' +local performer = require 'fibers.performer' +local cond_mod = require 'fibers.cond' ------------------------------------------------------------------------------- -- 1. Structural tests ------------------------------------------------------------------------------- local function test_outside_fibers() - local root = scope.root() - - -- current() outside any fiber should be the root (process-wide current scope) - assert(scope.current() == root, "outside fibers, current() should be root") - - local outer_scope - local inner_scope - - local st, err = scope.run(function(s) - outer_scope = s - - -- Inside run, current() should be this child scope - assert(scope.current() == s, "inside scope.run, current() should be child scope") - assert(s:parent() == root, "outer scope parent must be root") - - -- root should see this child in its children list - local rc = root:children() - local found_outer = false - for _, c in ipairs(rc) do - if c == s then - found_outer = true - break - end - end - assert(found_outer, "root:children() should contain outer scope") - - -- Nested run creates a grandchild of s - local st2, err2 = scope.run(function(child2) - inner_scope = child2 - assert(scope.current() == child2, "inside nested run, current() should be nested child") - assert(child2:parent() == s, "nested scope parent must be outer scope") - - local sc = s:children() - local found_inner = false - for _, c in ipairs(sc) do - if c == child2 then - found_inner = true - break - end - end - assert(found_inner, "outer scope children() should contain nested scope") - end) - - assert(st2 == "ok" and err2 == nil, - "nested scope.run should complete with status ok") - - -- After nested run, current() should be back to the outer scope - assert(scope.current() == s, "after nested run, current() should be outer scope again") - end) - - assert(st == "ok" and err == nil, "outer scope.run should complete with status ok") - - assert(outer_scope ~= nil, "outer_scope should have been set") - assert(inner_scope ~= nil, "inner_scope should have been set") - assert(outer_scope ~= inner_scope, "outer and inner scopes must differ") - - -- After scope.run returns, current() outside fibers should be root again - assert(scope.current() == scope.root(), "after scope.run, current() should be root outside fibers") + local root = scope.root() + + -- current() outside any fiber should be the root (process-wide current scope) + assert(scope.current() == root, 'outside fibers, current() should be root') + + local outer_scope + local inner_scope + + local st, err = scope.run(function (s) + outer_scope = s + + -- Inside run, current() should be this child scope + assert(scope.current() == s, 'inside scope.run, current() should be child scope') + assert(s:parent() == root, 'outer scope parent must be root') + + -- root should see this child in its children list + local rc = root:children() + local found_outer = false + for _, c in ipairs(rc) do + if c == s then + found_outer = true + break + end + end + assert(found_outer, 'root:children() should contain outer scope') + + -- Nested run creates a grandchild of s + local st2, err2 = scope.run(function (child2) + inner_scope = child2 + assert(scope.current() == child2, 'inside nested run, current() should be nested child') + assert(child2:parent() == s, 'nested scope parent must be outer scope') + + local sc = s:children() + local found_inner = false + for _, c in ipairs(sc) do + if c == child2 then + found_inner = true + break + end + end + assert(found_inner, 'outer scope children() should contain nested scope') + end) + + assert(st2 == 'ok' and err2 == nil, + 'nested scope.run should complete with status ok') + + -- After nested run, current() should be back to the outer scope + assert(scope.current() == s, 'after nested run, current() should be outer scope again') + end) + + assert(st == 'ok' and err == nil, 'outer scope.run should complete with status ok') + + assert(outer_scope ~= nil, 'outer_scope should have been set') + assert(inner_scope ~= nil, 'inner_scope should have been set') + assert(outer_scope ~= inner_scope, 'outer and inner scopes must differ') + + -- After scope.run returns, current() outside fibers should be root again + assert(scope.current() == scope.root(), 'after scope.run, current() should be root outside fibers') end local function test_inside_fibers() - local root = scope.root() - - local child_in_fiber - local grandchild_in_fiber - - -- Use a cond to wait for the spawned fiber to finish. - local done = cond_mod.new() - - -- Spawn a fiber anchored to the root scope. - root:spawn(function(s) - -- In this fiber, s is the scope used for spawn -> root - assert(s == root, "spawn(fn) on root should pass root as scope") - assert(scope.current() == root, "inside spawned fiber, current() should be root initially") - - -- Create a child scope inside the fiber - local st, err = scope.run(function(child) - child_in_fiber = child - assert(scope.current() == child, "inside scope.run in fiber, current() should be child") - assert(child:parent() == root, "child-in-fiber parent must be root") - - -- Create a grandchild scope - local st2, err2 = scope.run(function(grandchild) - grandchild_in_fiber = grandchild - assert(scope.current() == grandchild, "inside nested run in fiber, current() should be grandchild") - assert(grandchild:parent() == child, "grandchild parent must be child") - end) - - assert(st2 == "ok" and err2 == nil, - "nested scope.run in fiber should complete with status ok") - - -- After nested run, current() should be back to child - assert(scope.current() == child, "after nested run in fiber, current() should be child again") - end) - - assert(st == "ok" and err == nil, - "scope.run in fiber should complete with status ok") - - -- After inner run, current() should be back to root for this fiber - assert(scope.current() == root, "after scope.run in fiber, current() should be root again") - - done:signal() - end) - - -- Drive until the child fiber finishes. - performer.perform(done:wait_op()) - - -- After that, we are still inside the test fiber; current() should be root. - assert(scope.current() == root, "after inner fiber completes, current() should be root in test fiber") - - -- Check that scopes created inside the fiber were recorded - assert(child_in_fiber ~= nil, "child_in_fiber should have been set") - assert(grandchild_in_fiber ~= nil, "grandchild_in_fiber should have been set") - assert(child_in_fiber:parent() == root, "child_in_fiber parent must be root") - assert(grandchild_in_fiber:parent() == child_in_fiber, "grandchild_in_fiber parent must be child_in_fiber") - - -- Check that root children include the child created in this fiber. - local rc = root:children() - local found_child = false - for _, s in ipairs(rc) do - if s == child_in_fiber then - found_child = true - break - end - end - assert(found_child, "root:children() should contain child_in_fiber") + local root = scope.root() + + local child_in_fiber + local grandchild_in_fiber + + -- Use a cond to wait for the spawned fiber to finish. + local done = cond_mod.new() + + -- Spawn a fiber anchored to the root scope. + root:spawn(function (s) + -- In this fiber, s is the scope used for spawn -> root + assert(s == root, 'spawn(fn) on root should pass root as scope') + assert(scope.current() == root, 'inside spawned fiber, current() should be root initially') + + -- Create a child scope inside the fiber + local st, err = scope.run(function (child) + child_in_fiber = child + assert(scope.current() == child, 'inside scope.run in fiber, current() should be child') + assert(child:parent() == root, 'child-in-fiber parent must be root') + + -- Create a grandchild scope + local st2, err2 = scope.run(function (grandchild) + grandchild_in_fiber = grandchild + assert(scope.current() == grandchild, 'inside nested run in fiber, current() should be grandchild') + assert(grandchild:parent() == child, 'grandchild parent must be child') + end) + + assert(st2 == 'ok' and err2 == nil, + 'nested scope.run in fiber should complete with status ok') + + -- After nested run, current() should be back to child + assert(scope.current() == child, 'after nested run in fiber, current() should be child again') + end) + + assert(st == 'ok' and err == nil, + 'scope.run in fiber should complete with status ok') + + -- After inner run, current() should be back to root for this fiber + assert(scope.current() == root, 'after scope.run in fiber, current() should be root again') + + done:signal() + end) + + -- Drive until the child fiber finishes. + performer.perform(done:wait_op()) + + -- After that, we are still inside the test fiber; current() should be root. + assert(scope.current() == root, 'after inner fiber completes, current() should be root in test fiber') + + -- Check that scopes created inside the fiber were recorded + assert(child_in_fiber ~= nil, 'child_in_fiber should have been set') + assert(grandchild_in_fiber ~= nil, 'grandchild_in_fiber should have been set') + assert(child_in_fiber:parent() == root, 'child_in_fiber parent must be root') + assert(grandchild_in_fiber:parent() == child_in_fiber, 'grandchild_in_fiber parent must be child_in_fiber') + + -- Check that root children include the child created in this fiber. + local rc = root:children() + local found_child = false + for _, s in ipairs(rc) do + if s == child_in_fiber then + found_child = true + break + end + end + assert(found_child, 'root:children() should contain child_in_fiber') end ------------------------------------------------------------------------------- @@ -148,168 +148,168 @@ end ------------------------------------------------------------------------------- local function test_with_op_basic() - local parent = scope.current() - local child_scope - - local ev = scope.with_op(function(child) - child_scope = child - -- inside build_op, current scope should be the child - assert(scope.current() == child, "inside with_op build_op, current() should be child scope") - assert(child:parent() == parent, "with_op child parent should be current scope") - - -- simple op that returns two values - return op.always(true):wrap(function() - return 99, "ok" - end) - end) - - local a, b = performer.perform(ev) - assert(a == 99 and b == "ok", "with_op should propagate child op results") - - -- After perform, current() should be restored to the parent. - assert(scope.current() == parent, - "after with_op perform, current() should be restored to parent scope") - - assert(child_scope ~= nil, "with_op should have created a child scope") - local st, err = child_scope:status() - assert(st == "ok" and err == nil, "with_op child scope should end ok on success") + local parent = scope.current() + local child_scope + + local ev = scope.with_op(function (child) + child_scope = child + -- inside build_op, current scope should be the child + assert(scope.current() == child, 'inside with_op build_op, current() should be child scope') + assert(child:parent() == parent, 'with_op child parent should be current scope') + + -- simple op that returns two values + return op.always(true):wrap(function () + return 99, 'ok' + end) + end) + + local a, b = performer.perform(ev) + assert(a == 99 and b == 'ok', 'with_op should propagate child op results') + + -- After perform, current() should be restored to the parent. + assert(scope.current() == parent, + 'after with_op perform, current() should be restored to parent scope') + + assert(child_scope ~= nil, 'with_op should have created a child scope') + local st, err = child_scope:status() + assert(st == 'ok' and err == nil, 'with_op child scope should end ok on success') end -- Failure in the with_op builder should be confined to the with_op child scope. local function test_with_op_failure_confined_to_child() - local outer_scope - local child_scope + local outer_scope + local child_scope - local st, serr = scope.run(function(s) - outer_scope = s + local st, serr = scope.run(function (s) + outer_scope = s - local ev = scope.with_op(function(child) - child_scope = child - assert(scope.current() == child, - "inside failing with_op, current() should be child scope") - assert(child:parent() == s, - "with_op child parent should be the surrounding scope.run scope") + local ev = scope.with_op(function (child) + child_scope = child + assert(scope.current() == child, + 'inside failing with_op, current() should be child scope') + assert(child:parent() == s, + 'with_op child parent should be the surrounding scope.run scope') - error("with_op builder failure") - end) + error('with_op builder failure') + end) - -- The error above is caught by the Scope:spawn wrapper for this body fiber. - performer.perform(ev) + -- The error above is caught by the Scope:spawn wrapper for this body fiber. + performer.perform(ev) - -- Not reached. - end) + -- Not reached. + end) - -- The outer scope remains ok; the failure is local to the with_op child. - assert(st == "ok" and serr == nil, - "outer scope.run should still succeed when with_op child fails") + -- The outer scope remains ok; the failure is local to the with_op child. + assert(st == 'ok' and serr == nil, + 'outer scope.run should still succeed when with_op child fails') - assert(outer_scope ~= nil, "outer_scope should have been set") - assert(child_scope ~= nil, "with_op failure test should have created child scope") + assert(outer_scope ~= nil, 'outer_scope should have been set') + assert(child_scope ~= nil, 'with_op failure test should have created child scope') - local cst, cerr = child_scope:status() - assert(cst == "failed", "with_op child should be failed after builder error") - assert(tostring(cerr):find("with_op builder failure", 1, true), - "with_op child error should mention builder failure") + local cst, cerr = child_scope:status() + assert(cst == 'failed', 'with_op child should be failed after builder error') + assert(tostring(cerr):find('with_op builder failure', 1, true), + 'with_op child error should mention builder failure') end -- with_op used in a choice where it loses should lead to a cancelled child scope. local function test_with_op_abort_on_choice() - local outer_scope - local child_scope - - local st, serr, _, winner = scope.run(function(s) - outer_scope = s - - local ev_with = scope.with_op(function(child) - child_scope = child - assert(scope.current() == child, - "inside with_op arm of choice, current() should be child scope") - assert(child:parent() == s, - "with_op child parent in choice should be outer scope") - - -- This arm never becomes ready; it will lose the choice. - return op.never() - end) - - local ev_choice = op.choice(ev_with, op.always("right")) - local res = performer.perform(ev_choice) - assert(res == "right", "choice should pick the always('right') arm") - - -- After the choice, current() should be restored. - assert(scope.current() == s, - "after with_op choice, current() should be restored to outer scope") - - return res - end) - - assert(st == "ok" and serr == nil, - "outer scope.run should succeed when with_op arm loses a choice") - assert(winner == "right", - "outer scope.run should return the winning choice result") - - assert(outer_scope ~= nil, "outer_scope should have been set") - assert(child_scope ~= nil, "with_op choice test should have created child scope") - - local cst, cerr = child_scope:status() - assert(cst == "cancelled", - "with_op child should be cancelled when its op loses a choice") - assert(cerr == "scope aborted", - "with_op aborted child error should be 'scope aborted'") + local outer_scope + local child_scope + + local st, serr, _, winner = scope.run(function (s) + outer_scope = s + + local ev_with = scope.with_op(function (child) + child_scope = child + assert(scope.current() == child, + 'inside with_op arm of choice, current() should be child scope') + assert(child:parent() == s, + 'with_op child parent in choice should be outer scope') + + -- This arm never becomes ready; it will lose the choice. + return op.never() + end) + + local ev_choice = op.choice(ev_with, op.always('right')) + local res = performer.perform(ev_choice) + assert(res == 'right', "choice should pick the always('right') arm") + + -- After the choice, current() should be restored. + assert(scope.current() == s, + 'after with_op choice, current() should be restored to outer scope') + + return res + end) + + assert(st == 'ok' and serr == nil, + 'outer scope.run should succeed when with_op arm loses a choice') + assert(winner == 'right', + 'outer scope.run should return the winning choice result') + + assert(outer_scope ~= nil, 'outer_scope should have been set') + assert(child_scope ~= nil, 'with_op choice test should have created child scope') + + local cst, cerr = child_scope:status() + assert(cst == 'cancelled', + 'with_op child should be cancelled when its op loses a choice') + assert(cerr == 'scope aborted', + "with_op aborted child error should be 'scope aborted'") end -- Failure in a fiber spawned under a with_op child scope should fail that child, -- but not its outer scope. local function test_with_op_child_fiber_failure() - local outer_scope - local child_scope - - local st, serr = scope.run(function(s) - outer_scope = s - - local ev = scope.with_op(function(child) - child_scope = child - assert(child:parent() == s, - "with_op child parent should be outer scope in child-fiber test") - - -- A condition used only to keep one child fiber blocked. - local c = cond_mod.new() - - -- Failing child fiber under the with_op scope. - child:spawn(function(_) - error("with_op child fiber failure") - end) - - -- Another child fiber that blocks on a cond and is cancelled - -- via the with_op scope's cancellation. - child:spawn(function(_) - local ok2, reason2 = performer.perform(c:wait_op()) - -- Under failure, this fiber should see a cancellation result. - assert(ok2 == false, "blocked child fiber should observe cancellation ok=false") - assert(reason2 ~= nil, "blocked child fiber should receive a cancellation reason") - end) - - -- The main op for with_op completes successfully. - return op.always("ok") - end) - - local res = performer.perform(ev) - assert(res == "ok", "with_op main op should still return its result") - - -- At this point, with_op's release will have waited for the child scope - -- to close, including both spawned child fibers and finalisers. - end) - - assert(st == "ok" and serr == nil, - "outer scope.run should remain ok after with_op child-fiber failure") - - assert(outer_scope ~= nil, "outer_scope should have been set") - assert(child_scope ~= nil, "with_op child-fiber test should have created child scope") - - local cst, cerr = child_scope:status() - assert(cst == "failed", - "with_op child scope should be failed after a child fiber failure") - assert(tostring(cerr):find("with_op child fiber failure", 1, true), - "with_op child scope error should mention the child fiber failure") + local outer_scope + local child_scope + + local st, serr = scope.run(function (s) + outer_scope = s + + local ev = scope.with_op(function (child) + child_scope = child + assert(child:parent() == s, + 'with_op child parent should be outer scope in child-fiber test') + + -- A condition used only to keep one child fiber blocked. + local c = cond_mod.new() + + -- Failing child fiber under the with_op scope. + child:spawn(function (_) + error('with_op child fiber failure') + end) + + -- Another child fiber that blocks on a cond and is cancelled + -- via the with_op scope's cancellation. + child:spawn(function (_) + local ok2, reason2 = performer.perform(c:wait_op()) + -- Under failure, this fiber should see a cancellation result. + assert(ok2 == false, 'blocked child fiber should observe cancellation ok=false') + assert(reason2 ~= nil, 'blocked child fiber should receive a cancellation reason') + end) + + -- The main op for with_op completes successfully. + return op.always('ok') + end) + + local res = performer.perform(ev) + assert(res == 'ok', 'with_op main op should still return its result') + + -- At this point, with_op's release will have waited for the child scope + -- to close, including both spawned child fibers and finalisers. + end) + + assert(st == 'ok' and serr == nil, + 'outer scope.run should remain ok after with_op child-fiber failure') + + assert(outer_scope ~= nil, 'outer_scope should have been set') + assert(child_scope ~= nil, 'with_op child-fiber test should have created child scope') + + local cst, cerr = child_scope:status() + assert(cst == 'failed', + 'with_op child scope should be failed after a child fiber failure') + assert(tostring(cerr):find('with_op child fiber failure', 1, true), + 'with_op child scope error should mention the child fiber failure') end ------------------------------------------------------------------------------- @@ -317,51 +317,51 @@ end ------------------------------------------------------------------------------- local function test_run_success_and_failure() - local root = scope.root() - - -- Success case: scope.run returns status ok and body results. - local success_scope - local st, err, _, a, b = scope.run(function(s) - success_scope = s - local st0, err0 = s:status() - assert(st0 == "running" and err0 == nil, "inside body, status should be running") - return 42, "x" - end) - - assert(st == "ok" and err == nil, - "scope.run should report status ok on success") - assert(a == 42 and b == "x", "scope.run should return body results on success") - - local st_ok, err_ok = success_scope:status() - assert(st_ok == "ok" and err_ok == nil, "successful scope should end with status ok and no error") - assert(success_scope:parent() == root, "success scope parent should be root") - - -- Failure case: body error becomes scope failure; scope.run does not throw. - local st_fail, err_fail = scope.run(function() - error("body failure") - end) - - assert(st_fail == "failed", "scope.run should report status failed on body error") - assert(err_fail ~= nil, "failed scope should have a primary error recorded") - assert(tostring(err_fail):find("body failure", 1, true), - "failed scope primary error should mention the body failure") + local root = scope.root() + + -- Success case: scope.run returns status ok and body results. + local success_scope + local st, err, _, a, b = scope.run(function (s) + success_scope = s + local st0, err0 = s:status() + assert(st0 == 'running' and err0 == nil, 'inside body, status should be running') + return 42, 'x' + end) + + assert(st == 'ok' and err == nil, + 'scope.run should report status ok on success') + assert(a == 42 and b == 'x', 'scope.run should return body results on success') + + local st_ok, err_ok = success_scope:status() + assert(st_ok == 'ok' and err_ok == nil, 'successful scope should end with status ok and no error') + assert(success_scope:parent() == root, 'success scope parent should be root') + + -- Failure case: body error becomes scope failure; scope.run does not throw. + local st_fail, err_fail = scope.run(function () + error('body failure') + end) + + assert(st_fail == 'failed', 'scope.run should report status failed on body error') + assert(err_fail ~= nil, 'failed scope should have a primary error recorded') + assert(tostring(err_fail):find('body failure', 1, true), + 'failed scope primary error should mention the body failure') end local function test_run_explicit_cancel() - -- If the body explicitly cancels the scope, scope.run should - -- report status 'cancelled' and the cancellation reason. - local cancelled_scope - local st, serr = scope.run(function(s) - cancelled_scope = s - s:cancel("stop here") - end) - - assert(st == "cancelled", "scope.run should report cancelled when scope is cancelled inside body") - assert(serr == "stop here", "cancelled scope error should be the cancellation reason") - - local st2, serr2 = cancelled_scope:status() - assert(st2 == "cancelled", "cancelled scope should have status 'cancelled'") - assert(serr2 == "stop here", "cancelled scope error should be the cancellation reason") + -- If the body explicitly cancels the scope, scope.run should + -- report status 'cancelled' and the cancellation reason. + local cancelled_scope + local st, serr = scope.run(function (s) + cancelled_scope = s + s:cancel('stop here') + end) + + assert(st == 'cancelled', 'scope.run should report cancelled when scope is cancelled inside body') + assert(serr == 'stop here', 'cancelled scope error should be the cancellation reason') + + local st2, serr2 = cancelled_scope:status() + assert(st2 == 'cancelled', "cancelled scope should have status 'cancelled'") + assert(serr2 == 'stop here', 'cancelled scope error should be the cancellation reason') end ------------------------------------------------------------------------------- @@ -369,55 +369,55 @@ end ------------------------------------------------------------------------------- local function test_finalisers_lifo_and_failure() - local order = {} - local scope_ref - - local st, serr = scope.run(function(s) - scope_ref = s - s:finally(function() table.insert(order, "first") end) - s:finally(function() table.insert(order, "second") end) - error("boom in body") - end) - - assert(st == "failed", "scope.run should report failure when body errors") - assert(tostring(serr):find("boom in body", 1, true), - "primary error should mention the body error") - - local st2, serr2 = scope_ref:status() - assert(st2 == "failed", "scope should be failed after body error") - assert(tostring(serr2):find("boom in body", 1, true), - "scope error should mention the body error") - - assert(#order == 2, "two finalisers should have run") - assert(order[1] == "second" and order[2] == "first", - "finalisers should run in LIFO order even on failure") + local order = {} + local scope_ref + + local st, serr = scope.run(function (s) + scope_ref = s + s:finally(function () table.insert(order, 'first') end) + s:finally(function () table.insert(order, 'second') end) + error('boom in body') + end) + + assert(st == 'failed', 'scope.run should report failure when body errors') + assert(tostring(serr):find('boom in body', 1, true), + 'primary error should mention the body error') + + local st2, serr2 = scope_ref:status() + assert(st2 == 'failed', 'scope should be failed after body error') + assert(tostring(serr2):find('boom in body', 1, true), + 'scope error should mention the body error') + + assert(#order == 2, 'two finalisers should have run') + assert(order[1] == 'second' and order[2] == 'first', + 'finalisers should run in LIFO order even on failure') end -- Defer failures after a successful body should turn the scope to 'failed' -- and surface the finaliser error as primary, but still preserve body results. local function test_extra_failure_marks_scope_failed() - local scope_ref - - local st, serr, _, body_res = scope.run(function(s) - scope_ref = s - s:finally(function() - error("finaliser failure") - end) - return "body-result" - end) - - assert(st == "failed", - "scope.run should report failed if a finaliser fails") - assert(tostring(serr):find("finaliser failure", 1, true), - "finaliser failure should be the primary error") - - local st2, serr2 = scope_ref:status() - assert(st2 == "failed", "scope status should be failed after finaliser failure") - assert(tostring(serr2):find("finaliser failure", 1, true), - "scope error should mention the finaliser failure") - - assert(body_res == "body-result", - "scope.run should still return body results even if finalisers fail") + local scope_ref + + local st, serr, _, body_res = scope.run(function (s) + scope_ref = s + s:finally(function () + error('finaliser failure') + end) + return 'body-result' + end) + + assert(st == 'failed', + 'scope.run should report failed if a finaliser fails') + assert(tostring(serr):find('finaliser failure', 1, true), + 'finaliser failure should be the primary error') + + local st2, serr2 = scope_ref:status() + assert(st2 == 'failed', 'scope status should be failed after finaliser failure') + assert(tostring(serr2):find('finaliser failure', 1, true), + 'scope error should mention the finaliser failure') + + assert(body_res == 'body-result', + 'scope.run should still return body results even if finalisers fail') end ------------------------------------------------------------------------------- @@ -425,90 +425,90 @@ end ------------------------------------------------------------------------------- local function test_sync_wraps_op_failure() - -- Op whose post-wrap raises: tests that scope sees failure. - local ev = op.always(123):wrap(function(v) - assert(v == 123, "inner always should pass its value") - error("op post-wrap failure") - end) - - local failed_scope - local st, serr = scope.run(function(s) - failed_scope = s - -- This performance will cause this fiber to fail; - -- scope should record status 'failed'. - performer.perform(ev) - end) - - assert(st == "failed", "scope.run should report failure when op post-wrap fails") - assert(tostring(serr):find("op post-wrap failure", 1, true), - "scope error should mention the op failure") - - local st2, serr2 = failed_scope:status() - assert(st2 == "failed", "scope should be failed after op failure") - assert(tostring(serr2):find("op post-wrap failure", 1, true), - "scope error should mention the op failure") + -- Op whose post-wrap raises: tests that scope sees failure. + local ev = op.always(123):wrap(function (v) + assert(v == 123, 'inner always should pass its value') + error('op post-wrap failure') + end) + + local failed_scope + local st, serr = scope.run(function (s) + failed_scope = s + -- This performance will cause this fiber to fail; + -- scope should record status 'failed'. + performer.perform(ev) + end) + + assert(st == 'failed', 'scope.run should report failure when op post-wrap fails') + assert(tostring(serr):find('op post-wrap failure', 1, true), + 'scope error should mention the op failure') + + local st2, serr2 = failed_scope:status() + assert(st2 == 'failed', 'scope should be failed after op failure') + assert(tostring(serr2):find('op post-wrap failure', 1, true), + 'scope error should mention the op failure') end local function test_sync_respects_cancellation() - -- Race a never-ready op against cancellation; cancellation should win - -- and be reflected as (ok=false, reason) at the sync level, and - -- as status 'cancelled' at the scope level. - local ev = op.never() - - local cancelled_scope - local st, serr, _, ok_op, reason_op = scope.run(function(s) - cancelled_scope = s - s:cancel("cancel before sync") - - local ok2, reason2 = s:sync(ev) - assert(ok2 == false, "Scope:sync should return ok=false after cancellation") - assert(reason2 == "cancel before sync", - "Scope:sync should return cancellation reason") - return ok2, reason2 - end) - - assert(st == "cancelled", "scope should be cancelled") - assert(serr == "cancel before sync", "cancellation reason should be preserved") - - assert(ok_op == false, "scope.run should return the op ok flag from body") - assert(reason_op == "cancel before sync", - "scope.run should return the cancellation reason from body") - - local st2, serr2 = cancelled_scope:status() - assert(st2 == "cancelled", "cancelled_scope should be cancelled") - assert(serr2 == "cancel before sync", "cancelled_scope error should be the cancellation reason") + -- Race a never-ready op against cancellation; cancellation should win + -- and be reflected as (ok=false, reason) at the sync level, and + -- as status 'cancelled' at the scope level. + local ev = op.never() + + local cancelled_scope + local st, serr, _, ok_op, reason_op = scope.run(function (s) + cancelled_scope = s + s:cancel('cancel before sync') + + local ok2, reason2 = s:sync(ev) + assert(ok2 == false, 'Scope:sync should return ok=false after cancellation') + assert(reason2 == 'cancel before sync', + 'Scope:sync should return cancellation reason') + return ok2, reason2 + end) + + assert(st == 'cancelled', 'scope should be cancelled') + assert(serr == 'cancel before sync', 'cancellation reason should be preserved') + + assert(ok_op == false, 'scope.run should return the op ok flag from body') + assert(reason_op == 'cancel before sync', + 'scope.run should return the cancellation reason from body') + + local st2, serr2 = cancelled_scope:status() + assert(st2 == 'cancelled', 'cancelled_scope should be cancelled') + assert(serr2 == 'cancel before sync', 'cancelled_scope error should be the cancellation reason') end -- Cancellation racing with a blocking sync: cancel the scope while a fiber -- is blocked on a wait_op. local function test_sync_cancellation_race() - local race_scope + local race_scope - local st, serr, _, ok_op, reason_op = scope.run(function(s) - race_scope = s - local cond = cond_mod.new() + local st, serr, _, ok_op, reason_op = scope.run(function (s) + race_scope = s + local cond = cond_mod.new() - -- Canceller fiber: let the main fiber block first, then cancel. - s:spawn(function(_) - runtime.yield() - s:cancel("race cancel") - end) + -- Canceller fiber: let the main fiber block first, then cancel. + s:spawn(function (_) + runtime.yield() + s:cancel('race cancel') + end) - local ok2, reason2 = s:sync(cond:wait_op()) - return ok2, reason2 - end) + local ok2, reason2 = s:sync(cond:wait_op()) + return ok2, reason2 + end) - assert(st == "cancelled", "scope.run should report cancelled in race test") - assert(serr == "race cancel", - "race cancellation reason should be 'race cancel'") + assert(st == 'cancelled', 'scope.run should report cancelled in race test') + assert(serr == 'race cancel', + "race cancellation reason should be 'race cancel'") - assert(ok_op == false, "blocking op should observe ok=false when cancelled") - assert(reason_op == "race cancel", - "blocking op should see the race cancellation reason") + assert(ok_op == false, 'blocking op should observe ok=false when cancelled') + assert(reason_op == 'race cancel', + 'blocking op should see the race cancellation reason') - local st2, serr2 = race_scope:status() - assert(st2 == "cancelled" and serr2 == "race cancel", - "race_scope should be cancelled with the correct reason") + local st2, serr2 = race_scope:status() + assert(st2 == 'cancelled' and serr2 == 'race cancel', + 'race_scope should be cancelled with the correct reason') end ------------------------------------------------------------------------------- @@ -516,58 +516,58 @@ end ------------------------------------------------------------------------------- local function test_join_and_not_ok_op() - -- Failed scope: body error. - local failed_scope - local st_fail, err_fail = scope.run(function(s) - failed_scope = s - error("join test failure") - end) - - assert(st_fail == "failed", "failed scope.run should report failed") - assert(tostring(err_fail):find("join test failure", 1, true), - "failed scope error should mention the body failure") - - do - local ev = failed_scope:join_op() - local st, jerr = performer.perform(ev) - assert(st == "failed", "join_op on failed scope should report 'failed'") - assert(tostring(jerr):find("join test failure", 1, true), - "join_op error should mention the body failure") - end - - do - local ev = failed_scope:not_ok_op() - local reason = performer.perform(ev) - -- For a failed scope we also call cancel(error), so not_ok_op() - -- should be triggered and report the same error. - assert(tostring(reason):find("join test failure", 1, true), - "not_ok_op() on failed scope should report the failure reason") - end - - -- Cancelled scope (explicit cancel, not body error). - local cancelled_scope - local st_cancel, err_cancel = scope.run(function(s) - cancelled_scope = s - s:cancel("stop again") - end) - - assert(st_cancel == "cancelled", "cancelled scope.run should report cancelled") - assert(err_cancel == "stop again", - "cancelled scope.run should report the cancellation reason") - - do - local ev = cancelled_scope:join_op() - local st, jerr = performer.perform(ev) - assert(st == "cancelled" and jerr == "stop again", - "join_op on cancelled scope should report 'cancelled' and reason") - end - - do - local ev = cancelled_scope:not_ok_op() - local reason = performer.perform(ev) - assert(reason == "stop again", - "not_ok_op() on cancelled scope should report cancellation reason") - end + -- Failed scope: body error. + local failed_scope + local st_fail, err_fail = scope.run(function (s) + failed_scope = s + error('join test failure') + end) + + assert(st_fail == 'failed', 'failed scope.run should report failed') + assert(tostring(err_fail):find('join test failure', 1, true), + 'failed scope error should mention the body failure') + + do + local ev = failed_scope:join_op() + local st, jerr = performer.perform(ev) + assert(st == 'failed', "join_op on failed scope should report 'failed'") + assert(tostring(jerr):find('join test failure', 1, true), + 'join_op error should mention the body failure') + end + + do + local ev = failed_scope:not_ok_op() + local reason = performer.perform(ev) + -- For a failed scope we also call cancel(error), so not_ok_op() + -- should be triggered and report the same error. + assert(tostring(reason):find('join test failure', 1, true), + 'not_ok_op() on failed scope should report the failure reason') + end + + -- Cancelled scope (explicit cancel, not body error). + local cancelled_scope + local st_cancel, err_cancel = scope.run(function (s) + cancelled_scope = s + s:cancel('stop again') + end) + + assert(st_cancel == 'cancelled', 'cancelled scope.run should report cancelled') + assert(err_cancel == 'stop again', + 'cancelled scope.run should report the cancellation reason') + + do + local ev = cancelled_scope:join_op() + local st, jerr = performer.perform(ev) + assert(st == 'cancelled' and jerr == 'stop again', + "join_op on cancelled scope should report 'cancelled' and reason") + end + + do + local ev = cancelled_scope:not_ok_op() + local reason = performer.perform(ev) + assert(reason == 'stop again', + 'not_ok_op() on cancelled scope should report cancellation reason') + end end ------------------------------------------------------------------------------- @@ -575,33 +575,33 @@ end ------------------------------------------------------------------------------- local function test_fail_fast_from_child_fiber() - local test_scope + local test_scope - local st, serr = scope.run(function(s) - test_scope = s + local st, serr = scope.run(function (s) + test_scope = s - -- Use a condition to ensure the child fiber runs before we exit the body. - local cond = cond_mod.new() + -- Use a condition to ensure the child fiber runs before we exit the body. + local cond = cond_mod.new() - -- Spawn a child fiber that signals, then fails. - s:spawn(function(_) - cond:signal() - error("child fiber failure") - end) + -- Spawn a child fiber that signals, then fails. + s:spawn(function (_) + cond:signal() + error('child fiber failure') + end) - -- Wait for the cond via performer, so we do not exit - -- the body until after the child has signalled. - performer.perform(cond:wait_op()) - end) + -- Wait for the cond via performer, so we do not exit + -- the body until after the child has signalled. + performer.perform(cond:wait_op()) + end) - assert(st == "failed", "scope.run should report failed when a child fiber fails") - assert(tostring(serr):find("child fiber failure", 1, true), - "primary error should mention child fiber failure") + assert(st == 'failed', 'scope.run should report failed when a child fiber fails') + assert(tostring(serr):find('child fiber failure', 1, true), + 'primary error should mention child fiber failure') - local st2, serr2 = test_scope:status() - assert(st2 == "failed", "scope status should be failed after child fiber failure") - assert(tostring(serr2):find("child fiber failure", 1, true), - "scope primary error should mention child fiber failure") + local st2, serr2 = test_scope:status() + assert(st2 == 'failed', 'scope status should be failed after child fiber failure') + assert(tostring(serr2):find('child fiber failure', 1, true), + 'scope primary error should mention child fiber failure') end ------------------------------------------------------------------------------- @@ -609,37 +609,37 @@ end ------------------------------------------------------------------------------- local function main() - io.stdout:write("Running scope tests...\n") + io.stdout:write('Running scope tests...\n') - -- Run all tests inside a single top-level fiber so that scope.run - -- and performer.perform are always called from within the scheduler. - runtime.spawn_raw(function() - test_outside_fibers() - test_inside_fibers() + -- Run all tests inside a single top-level fiber so that scope.run + -- and performer.perform are always called from within the scheduler. + runtime.spawn_raw(function () + test_outside_fibers() + test_inside_fibers() - test_with_op_basic() - test_with_op_failure_confined_to_child() - test_with_op_abort_on_choice() - test_with_op_child_fiber_failure() + test_with_op_basic() + test_with_op_failure_confined_to_child() + test_with_op_abort_on_choice() + test_with_op_child_fiber_failure() - test_run_success_and_failure() - test_run_explicit_cancel() + test_run_success_and_failure() + test_run_explicit_cancel() - test_finalisers_lifo_and_failure() - test_extra_failure_marks_scope_failed() + test_finalisers_lifo_and_failure() + test_extra_failure_marks_scope_failed() - test_sync_wraps_op_failure() - test_sync_respects_cancellation() - test_sync_cancellation_race() + test_sync_wraps_op_failure() + test_sync_respects_cancellation() + test_sync_cancellation_race() - test_join_and_not_ok_op() - test_fail_fast_from_child_fiber() + test_join_and_not_ok_op() + test_fail_fast_from_child_fiber() - io.stdout:write("OK\n") - runtime.stop() - end) + io.stdout:write('OK\n') + runtime.stop() + end) - runtime.main() + runtime.main() end main() diff --git a/tests/test_sleep.lua b/tests/test_sleep.lua index 01969cd..5a13ebb 100644 --- a/tests/test_sleep.lua +++ b/tests/test_sleep.lua @@ -2,7 +2,7 @@ print('testing: fibers.sleep') -- look one level up -package.path = "../src/?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path local sleep = require 'fibers.sleep' local runtime = require 'fibers.runtime' @@ -11,18 +11,18 @@ local done = 0 -- local wakeup_times = {} local count = 1e3 for _ = 1, count do - local function fn() - local start, dt = runtime.now(), math.random() - sleep.sleep(dt) - local wakeup_time = runtime.now() - assert(wakeup_time >= start + dt) - done = done + 1 - -- table.insert(wakeup_times, wakeup_time - (start + dt)) - end - runtime.spawn_raw(fn) + local function fn() + local start, dt = runtime.now(), math.random() + sleep.sleep(dt) + local wakeup_time = runtime.now() + assert(wakeup_time >= start + dt) + done = done + 1 + -- table.insert(wakeup_times, wakeup_time - (start + dt)) + end + runtime.spawn_raw(fn) end for t = runtime.now(), runtime.now() + 1.5, 0.01 do - runtime.current_scheduler:run(t) + runtime.current_scheduler:run(t) end assert(done == count) diff --git a/tests/test_timer.lua b/tests/test_timer.lua index 68e2802..fecb743 100644 --- a/tests/test_timer.lua +++ b/tests/test_timer.lua @@ -1,90 +1,90 @@ --- Tests the Timer implementation. -print("test: fibers.timer") +print('test: fibers.timer') -- look one level up -package.path = "../src/?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path local timer = require 'fibers.timer' local time = require 'fibers.utils.time' -local noop_sched = { schedule = function() end } +local noop_sched = { schedule = function () end } local function test_advance_time() - local wheel = timer.new(10) - local hour = 60 * 60 - local start_time = time.monotonic() - wheel:advance(hour, noop_sched) - local end_time = time.monotonic() - print("Time to advance wheel by an hour: "..(end_time - start_time).." seconds") + local wheel = timer.new(10) + local hour = 60 * 60 + local start_time = time.monotonic() + wheel:advance(hour, noop_sched) + local end_time = time.monotonic() + print('Time to advance wheel by an hour: ' .. (end_time - start_time) .. ' seconds') end local function test_event_scheduling(event_count) - local wheel = timer.new(time.monotonic()) - local t = wheel.now - local start_time = time.monotonic() - for _=1, event_count do - local dt = math.random() - t = t + dt - wheel:add_absolute(t, t) - end - local end_time = time.monotonic() - print("Time to add "..event_count.." events: "..(end_time - start_time).." seconds") + local wheel = timer.new(time.monotonic()) + local t = wheel.now + local start_time = time.monotonic() + for _ = 1, event_count do + local dt = math.random() + t = t + dt + wheel:add_absolute(t, t) + end + local end_time = time.monotonic() + print('Time to add ' .. event_count .. ' events: ' .. (end_time - start_time) .. ' seconds') end local function test_event_expiration(event_count) - local wheel = timer.new(time.monotonic()) - local last = 0 - local count = 0 - local check = {} - local t = wheel.now + local wheel = timer.new(time.monotonic()) + local last = 0 + local count = 0 + local check = {} + local t = wheel.now - function check:schedule(tw) - local now = wheel.now - assert(last <= tw) - last, count = tw, count + 1 - assert(now - 1e-3 < tw) - assert(tw < now + 1e-3) - end + function check:schedule(tw) + local now = wheel.now + assert(last <= tw) + last, count = tw, count + 1 + assert(now - 1e-3 < tw) + assert(tw < now + 1e-3) + end - for _=1, event_count do - local dt = math.random() - t = t + dt - wheel:add_absolute(t, t) - end + for _ = 1, event_count do + local dt = math.random() + t = t + dt + wheel:add_absolute(t, t) + end - local start_time = time.monotonic() - wheel:advance(t + 1, check) - local end_time = time.monotonic() - print("Time to advance wheel to expire "..event_count.." events: "..(end_time - start_time).." seconds") - assert(count == event_count) + local start_time = time.monotonic() + wheel:advance(t + 1, check) + local end_time = time.monotonic() + print('Time to advance wheel to expire ' .. event_count .. ' events: ' .. (end_time - start_time) .. ' seconds') + assert(count == event_count) end local function test_large_intervals() - local wheel = timer.new(time.monotonic()) - local far_future = 1e6 -- Far future time - local event_triggered = false - wheel:add_absolute(wheel.now + far_future, 'far_future_event') - wheel:advance(wheel.now + far_future + 1e-3, {schedule = function() event_triggered = true end}) - assert(event_triggered, "Far future event was not triggered") + local wheel = timer.new(time.monotonic()) + local far_future = 1e6 -- Far future time + local event_triggered = false + wheel:add_absolute(wheel.now + far_future, 'far_future_event') + wheel:advance(wheel.now + far_future + 1e-3, { schedule = function () event_triggered = true end }) + assert(event_triggered, 'Far future event was not triggered') end local function test_small_intervals() - local wheel = timer.new(time.monotonic()) - local very_near_future = 1e-6 -- Very near future time - local event_triggered = false - wheel:add_absolute(wheel.now + very_near_future, 'near_future_event') - wheel:advance(wheel.now + very_near_future + 1e-3, {schedule = function() event_triggered = true end}) - assert(event_triggered, "Near future event was not triggered") + local wheel = timer.new(time.monotonic()) + local very_near_future = 1e-6 -- Very near future time + local event_triggered = false + wheel:add_absolute(wheel.now + very_near_future, 'near_future_event') + wheel:advance(wheel.now + very_near_future + 1e-3, { schedule = function () event_triggered = true end }) + assert(event_triggered, 'Near future event was not triggered') end local function test_advance_now_update() - local wheel = timer.new(time.monotonic()) - local advance_time = 100 -- Advance by 100 seconds - local start_time = wheel.now - wheel:advance(start_time + advance_time, noop_sched) - local expected_time = start_time + advance_time - assert(wheel.now == expected_time, "Advance method failed to update 'now' correctly") - print("Test for 'now' update in advance method passed") + local wheel = timer.new(time.monotonic()) + local advance_time = 100 -- Advance by 100 seconds + local start_time = wheel.now + wheel:advance(start_time + advance_time, noop_sched) + local expected_time = start_time + advance_time + assert(wheel.now == expected_time, "Advance method failed to update 'now' correctly") + print("Test for 'now' update in advance method passed") end -- Run tests @@ -95,4 +95,4 @@ test_large_intervals() test_small_intervals() test_advance_now_update() -print("All tests passed") +print('All tests passed') diff --git a/tests/test_utils-bytes.lua b/tests/test_utils-bytes.lua index 6240601..4dbe0af 100644 --- a/tests/test_utils-bytes.lua +++ b/tests/test_utils-bytes.lua @@ -1,7 +1,7 @@ print('testing: fibers.utils.bytes') -- look one level up -package.path = "../src/?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path -- Tests for fibers.utils.bytes -- - always exercises the pure Lua backend @@ -16,34 +16,34 @@ package.path = "../src/?.lua;" .. package.path -- ffi/cffi library is available. local function get_ffi() - local is_LuaJIT = rawget(_G, "jit") and true or false - local ok, ffi_mod - if is_LuaJIT then - ok, ffi_mod = pcall(require, "ffi") - else - ok, ffi_mod = pcall(require, "cffi") - end - if not ok then return nil end - return ffi_mod + local is_LuaJIT = rawget(_G, 'jit') and true or false + local ok, ffi_mod + if is_LuaJIT then + ok, ffi_mod = pcall(require, 'ffi') + else + ok, ffi_mod = pcall(require, 'cffi') + end + if not ok then return nil end + return ffi_mod end -local bytes_shim = require "fibers.utils.bytes" -local lua_backend = require "fibers.utils.bytes.lua" +local bytes_shim = require 'fibers.utils.bytes' +local lua_backend = require 'fibers.utils.bytes.lua' -local ok_ffi_backend, ffi_backend = pcall(require, "fibers.utils.bytes.ffi") +local ok_ffi_backend, ffi_backend = pcall(require, 'fibers.utils.bytes.ffi') if not ok_ffi_backend then - ffi_backend = nil + ffi_backend = nil end local ffi_obj = get_ffi() local function assert_eq(actual, expected, msg) - if actual ~= expected then - error(string.format("%s (expected %q, got %q)", - msg or "assert_eq failed", - tostring(expected), - tostring(actual))) - end + if actual ~= expected then + error(string.format('%s (expected %q, got %q)', + msg or 'assert_eq failed', + tostring(expected), + tostring(actual))) + end end ------------------------------------------------------------ @@ -51,73 +51,73 @@ end ------------------------------------------------------------ local function test_ring_basic(impl) - local RingBuf = impl.RingBuf - print(" RingBuf basic...") - - local rb = RingBuf.new(16) - - assert_eq(rb:read_avail(), 0, "read_avail at start") - assert_eq(rb:write_avail(), 16, "write_avail at start") - assert(rb:is_empty(), "is_empty at start") - assert(not rb:is_full(), "is_full at start") - - -- Write "hello" - rb:put("hello") - assert_eq(rb:read_avail(), 5, "read_avail after put('hello')") - assert_eq(rb:write_avail(), 11, "write_avail after put('hello')") - - -- tostring should not consume - local s_view = rb:tostring() - assert_eq(s_view, "hello", "tostring after put 'hello'") - assert_eq(rb:read_avail(), 5, "read_avail unchanged after tostring") - - -- Take back - local s = rb:take(5) - assert_eq(s, "hello", "take(5) returns 'hello'") - assert_eq(rb:read_avail(), 0, "read_avail after full take") - assert(rb:is_empty(), "is_empty after draining") - - rb:reset() - assert_eq(rb:read_avail(), 0, "read_avail after reset") - assert(rb:is_empty(), "is_empty after reset") + local RingBuf = impl.RingBuf + print(' RingBuf basic...') + + local rb = RingBuf.new(16) + + assert_eq(rb:read_avail(), 0, 'read_avail at start') + assert_eq(rb:write_avail(), 16, 'write_avail at start') + assert(rb:is_empty(), 'is_empty at start') + assert(not rb:is_full(), 'is_full at start') + + -- Write "hello" + rb:put('hello') + assert_eq(rb:read_avail(), 5, "read_avail after put('hello')") + assert_eq(rb:write_avail(), 11, "write_avail after put('hello')") + + -- tostring should not consume + local s_view = rb:tostring() + assert_eq(s_view, 'hello', "tostring after put 'hello'") + assert_eq(rb:read_avail(), 5, 'read_avail unchanged after tostring') + + -- Take back + local s = rb:take(5) + assert_eq(s, 'hello', "take(5) returns 'hello'") + assert_eq(rb:read_avail(), 0, 'read_avail after full take') + assert(rb:is_empty(), 'is_empty after draining') + + rb:reset() + assert_eq(rb:read_avail(), 0, 'read_avail after reset') + assert(rb:is_empty(), 'is_empty after reset') end local function test_ring_wrap(impl) - local RingBuf = impl.RingBuf - print(" RingBuf wrap-around...") + local RingBuf = impl.RingBuf + print(' RingBuf wrap-around...') - local rb = RingBuf.new(8) -- small to force wrap + local rb = RingBuf.new(8) -- small to force wrap - -- Sequence: put 6, take 4, put 4 → buffer should contain "efWXYZ" - rb:put("abcdef") - assert_eq(rb:read_avail(), 6, "wrap: read_avail after first put(6)") + -- Sequence: put 6, take 4, put 4 → buffer should contain "efWXYZ" + rb:put('abcdef') + assert_eq(rb:read_avail(), 6, 'wrap: read_avail after first put(6)') - local s1 = rb:take(4) - assert_eq(s1, "abcd", "wrap: first take(4)") - assert_eq(rb:read_avail(), 2, "wrap: read_avail after first take") + local s1 = rb:take(4) + assert_eq(s1, 'abcd', 'wrap: first take(4)') + assert_eq(rb:read_avail(), 2, 'wrap: read_avail after first take') - rb:put("WXYZ") - assert_eq(rb:read_avail(), 6, "wrap: read_avail after second put(4)") + rb:put('WXYZ') + assert_eq(rb:read_avail(), 6, 'wrap: read_avail after second put(4)') - local s_all = rb:tostring() - assert_eq(s_all, "efWXYZ", "wrap: content after wrap sequence") + local s_all = rb:tostring() + assert_eq(s_all, 'efWXYZ', 'wrap: content after wrap sequence') - local off = rb:find("WX") - assert_eq(off, 2, "wrap: find('WX') offset") + local off = rb:find('WX') + assert_eq(off, 2, "wrap: find('WX') offset") end local function test_linear_basic(impl) - local LinearBuf = impl.LinearBuf - print(" LinearBuf basic...") + local LinearBuf = impl.LinearBuf + print(' LinearBuf basic...') - local lb = LinearBuf.new(32) + local lb = LinearBuf.new(32) - lb:append("hello") - lb:append(" world") - assert_eq(lb:tostring(), "hello world", "LinearBuf tostring after appends") + lb:append('hello') + lb:append(' world') + assert_eq(lb:tostring(), 'hello world', 'LinearBuf tostring after appends') - lb:reset() - assert_eq(lb:tostring(), "", "LinearBuf tostring after reset") + lb:reset() + assert_eq(lb:tostring(), '', 'LinearBuf tostring after reset') end ------------------------------------------------------------ @@ -125,32 +125,32 @@ end ------------------------------------------------------------ local function test_linear_reserve_commit_ffi(impl, ffi_mod) - -- Only applicable if we have an ffi/cffi module. - if not ffi_mod then - return - end - if not impl or not impl.LinearBuf or not impl.LinearBuf.new then - return - end - - local lb = impl.LinearBuf.new(32) - - -- Only run if this backend actually exposes a pointer-level API. - if type(lb.reserve) ~= "function" or type(lb.commit) ~= "function" then - return - end - - print(" LinearBuf reserve/commit (FFI)...") - - -- reserve/commit path - local p = lb:reserve(5) - ffi_mod.copy(p, "hello", 5) - lb:commit(5) - assert_eq(lb:tostring(), "hello", "LinearBuf tostring after reserve/commit") - - -- append path on top - lb:append(" world") - assert_eq(lb:tostring(), "hello world", "LinearBuf tostring after append") + -- Only applicable if we have an ffi/cffi module. + if not ffi_mod then + return + end + if not impl or not impl.LinearBuf or not impl.LinearBuf.new then + return + end + + local lb = impl.LinearBuf.new(32) + + -- Only run if this backend actually exposes a pointer-level API. + if type(lb.reserve) ~= 'function' or type(lb.commit) ~= 'function' then + return + end + + print(' LinearBuf reserve/commit (FFI)...') + + -- reserve/commit path + local p = lb:reserve(5) + ffi_mod.copy(p, 'hello', 5) + lb:commit(5) + assert_eq(lb:tostring(), 'hello', 'LinearBuf tostring after reserve/commit') + + -- append path on top + lb:append(' world') + assert_eq(lb:tostring(), 'hello world', 'LinearBuf tostring after append') end ------------------------------------------------------------ @@ -158,47 +158,47 @@ end ------------------------------------------------------------ local function run_backend_tests(name, impl, ffi_mod) - print(("testing bytes backend: %s"):format(name)) + print(('testing bytes backend: %s'):format(name)) - if not impl or not impl.RingBuf or not impl.LinearBuf then - error(("backend %s missing RingBuf/LinearBuf"):format(name)) - end + if not impl or not impl.RingBuf or not impl.LinearBuf then + error(('backend %s missing RingBuf/LinearBuf'):format(name)) + end - test_ring_basic(impl) - test_ring_wrap(impl) - test_linear_basic(impl) - test_linear_reserve_commit_ffi(impl, ffi_mod) + test_ring_basic(impl) + test_ring_wrap(impl) + test_linear_basic(impl) + test_linear_reserve_commit_ffi(impl, ffi_mod) - print(("backend %s: OK\n"):format(name)) + print(('backend %s: OK\n'):format(name)) end ------------------------------------------------------------ -- Main ------------------------------------------------------------ -print("testing fibers.utils.bytes") +print('testing fibers.utils.bytes') -- Always test pure Lua backend if not lua_backend or not lua_backend.is_supported or not lua_backend.is_supported() then - error("Lua bytes backend not available or not supported") + error('Lua bytes backend not available or not supported') end -run_backend_tests("lua", lua_backend, nil) +run_backend_tests('lua', lua_backend, nil) -- Test FFI backend if present and supported if ffi_backend and ffi_backend.is_supported and ffi_backend.is_supported() then - if not ffi_obj then - print("ffi backend available but no ffi/cffi library; skipping pointer-level FFI test") - end - run_backend_tests("ffi", ffi_backend, ffi_obj) + if not ffi_obj then + print('ffi backend available but no ffi/cffi library; skipping pointer-level FFI test') + end + run_backend_tests('ffi', ffi_backend, ffi_obj) else - print("ffi backend not available or not supported; skipping FFI backend tests\n") + print('ffi backend not available or not supported; skipping FFI backend tests\n') end -- Test the top-level shim (whatever it chose) if bytes_shim and bytes_shim.RingBuf and bytes_shim.LinearBuf then - run_backend_tests("shim", bytes_shim, ffi_obj) + run_backend_tests('shim', bytes_shim, ffi_obj) else - error("bytes shim backend missing RingBuf/LinearBuf") + error('bytes shim backend missing RingBuf/LinearBuf') end -print("all bytes tests done") +print('all bytes tests done') diff --git a/tests/test_utils-bytes_stress.lua b/tests/test_utils-bytes_stress.lua index 15ebaa9..e6a3592 100644 --- a/tests/test_utils-bytes_stress.lua +++ b/tests/test_utils-bytes_stress.lua @@ -13,25 +13,25 @@ print('testing: fibers.utils.bytes stress') -- look one level up -package.path = "../src/?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path -local bytes_shim = require "fibers.utils.bytes" -local lua_backend = require "fibers.utils.bytes.lua" +local bytes_shim = require 'fibers.utils.bytes' +local lua_backend = require 'fibers.utils.bytes.lua' -local ok_ffi_backend, ffi_backend = pcall(require, "fibers.utils.bytes.ffi") +local ok_ffi_backend, ffi_backend = pcall(require, 'fibers.utils.bytes.ffi') if not ok_ffi_backend then - ffi_backend = nil + ffi_backend = nil end local function assert_eq(actual, expected, msg) - if actual ~= expected then - error(string.format( - "%s (expected %q, got %q)", - msg or "assert_eq failed", - tostring(expected), - tostring(actual) - )) - end + if actual ~= expected then + error(string.format( + '%s (expected %q, got %q)', + msg or 'assert_eq failed', + tostring(expected), + tostring(actual) + )) + end end ---------------------------------------------------------------------- @@ -39,13 +39,13 @@ end ---------------------------------------------------------------------- local function random_bytes(len) - -- Deterministic pseudo-random ASCII payload; simple but fine for testing. - local t = {} - for i = 1, len do - local b = 32 + ((i * 37) % 90) -- printable range - t[i] = string.char(b) - end - return table.concat(t) + -- Deterministic pseudo-random ASCII payload; simple but fine for testing. + local t = {} + for i = 1, len do + local b = 32 + ((i * 37) % 90) -- printable range + t[i] = string.char(b) + end + return table.concat(t) end ---------------------------------------------------------------------- @@ -53,71 +53,71 @@ end ---------------------------------------------------------------------- local function stress_ring(impl, label) - local RingBuf = impl.RingBuf - print((" RingBuf stress (%s)..."):format(label)) - - -- Use a modest capacity so wrap-around / full conditions are exercised. - local cap = 4096 - local rb = RingBuf.new(cap) - - -- Reference model: Lua string containing unread data. - local ref = "" - - math.randomseed(12345) - - local iterations = 20000 - - for step = 1, iterations do - local do_write - if #ref == 0 then - do_write = true - elseif rb:write_avail() == 0 then - do_write = false - else - do_write = (math.random() < 0.5) - end - - if do_write then - -- Write between 1 and 64 bytes, limited by write_avail. - local max_len = math.min(64, rb:write_avail()) - if max_len > 0 then - local len = math.random(1, max_len) - local chunk = random_bytes(len) - - rb:put(chunk) - ref = ref .. chunk - end - else - -- Read between 1 and 32 bytes, limited by read_avail/ref length. - local avail = rb:read_avail() - if avail > 0 then - local max_len = math.min(32, avail, #ref) - local len = math.random(1, max_len) - - local got = rb:take(len) - local exp = ref:sub(1, len) - if got ~= exp then - error(string.format( - "RingBuf stress mismatch at step %d: expected %q, got %q", - step, exp, got - )) - end - ref = ref:sub(len + 1) - end - end - - -- Occasionally cross-check the snapshot. - if step % 5000 == 0 then - local snap = rb:tostring() - if snap ~= ref then - error(string.format("RingBuf snapshot mismatch at step %d", step)) - end - end - end - - -- Final consistency check. - local snap = rb:tostring() - assert_eq(snap, ref, "RingBuf final snapshot mismatch") + local RingBuf = impl.RingBuf + print((' RingBuf stress (%s)...'):format(label)) + + -- Use a modest capacity so wrap-around / full conditions are exercised. + local cap = 4096 + local rb = RingBuf.new(cap) + + -- Reference model: Lua string containing unread data. + local ref = '' + + math.randomseed(12345) + + local iterations = 20000 + + for step = 1, iterations do + local do_write + if #ref == 0 then + do_write = true + elseif rb:write_avail() == 0 then + do_write = false + else + do_write = (math.random() < 0.5) + end + + if do_write then + -- Write between 1 and 64 bytes, limited by write_avail. + local max_len = math.min(64, rb:write_avail()) + if max_len > 0 then + local len = math.random(1, max_len) + local chunk = random_bytes(len) + + rb:put(chunk) + ref = ref .. chunk + end + else + -- Read between 1 and 32 bytes, limited by read_avail/ref length. + local avail = rb:read_avail() + if avail > 0 then + local max_len = math.min(32, avail, #ref) + local len = math.random(1, max_len) + + local got = rb:take(len) + local exp = ref:sub(1, len) + if got ~= exp then + error(string.format( + 'RingBuf stress mismatch at step %d: expected %q, got %q', + step, exp, got + )) + end + ref = ref:sub(len + 1) + end + end + + -- Occasionally cross-check the snapshot. + if step % 5000 == 0 then + local snap = rb:tostring() + if snap ~= ref then + error(string.format('RingBuf snapshot mismatch at step %d', step)) + end + end + end + + -- Final consistency check. + local snap = rb:tostring() + assert_eq(snap, ref, 'RingBuf final snapshot mismatch') end ---------------------------------------------------------------------- @@ -125,48 +125,48 @@ end ---------------------------------------------------------------------- local function stress_linear(impl, label) - local LinearBuf = impl.LinearBuf - print((" LinearBuf stress (%s)..."):format(label)) - - -- FFI backend may honour a capacity; Lua backend may ignore it. - local cap = 128 - local lb = LinearBuf.new(cap) - - local ref = "" - math.randomseed(54321) - - local iterations = 10000 - - for step = 1, iterations do - -- Occasionally reset to exercise that path as well. - if step % 2000 == 0 then - lb:reset() - ref = "" - end - - local len = math.random(0, 128) - if len == 0 then - -- Occasionally just check without changing. - local snap = lb:tostring() - if snap ~= ref then - error(string.format("LinearBuf snapshot mismatch at step %d", step)) - end - else - local chunk = random_bytes(len) - lb:append(chunk) - ref = ref .. chunk - end - - if step % 2500 == 0 then - local snap = lb:tostring() - if snap ~= ref then - error(string.format("LinearBuf snapshot mismatch at step %d", step)) - end - end - end - - local snap = lb:tostring() - assert_eq(snap, ref, "LinearBuf final snapshot mismatch") + local LinearBuf = impl.LinearBuf + print((' LinearBuf stress (%s)...'):format(label)) + + -- FFI backend may honour a capacity; Lua backend may ignore it. + local cap = 128 + local lb = LinearBuf.new(cap) + + local ref = '' + math.randomseed(54321) + + local iterations = 10000 + + for step = 1, iterations do + -- Occasionally reset to exercise that path as well. + if step % 2000 == 0 then + lb:reset() + ref = '' + end + + local len = math.random(0, 128) + if len == 0 then + -- Occasionally just check without changing. + local snap = lb:tostring() + if snap ~= ref then + error(string.format('LinearBuf snapshot mismatch at step %d', step)) + end + else + local chunk = random_bytes(len) + lb:append(chunk) + ref = ref .. chunk + end + + if step % 2500 == 0 then + local snap = lb:tostring() + if snap ~= ref then + error(string.format('LinearBuf snapshot mismatch at step %d', step)) + end + end + end + + local snap = lb:tostring() + assert_eq(snap, ref, 'LinearBuf final snapshot mismatch') end ---------------------------------------------------------------------- @@ -174,38 +174,38 @@ end ---------------------------------------------------------------------- local function run_backend(label, impl) - print(("testing bytes backend (stress): %s"):format(label)) + print(('testing bytes backend (stress): %s'):format(label)) - if not impl or not impl.RingBuf or not impl.LinearBuf then - error(("backend %s missing RingBuf/LinearBuf"):format(label)) - end + if not impl or not impl.RingBuf or not impl.LinearBuf then + error(('backend %s missing RingBuf/LinearBuf'):format(label)) + end - stress_ring(impl, label) - stress_linear(impl, label) + stress_ring(impl, label) + stress_linear(impl, label) - print(("backend %s: OK\n"):format(label)) + print(('backend %s: OK\n'):format(label)) end -print("testing fibers.utils.bytes (stress)") +print('testing fibers.utils.bytes (stress)') -- Always test pure Lua backend if not lua_backend or not lua_backend.is_supported or not lua_backend.is_supported() then - error("Lua bytes backend not available or not supported") + error('Lua bytes backend not available or not supported') end -run_backend("lua", lua_backend) +run_backend('lua', lua_backend) -- Test FFI backend if present and supported if ffi_backend and ffi_backend.is_supported and ffi_backend.is_supported() then - run_backend("ffi", ffi_backend) + run_backend('ffi', ffi_backend) else - print("ffi backend not available or not supported; skipping FFI stress\n") + print('ffi backend not available or not supported; skipping FFI stress\n') end -- Also test the shim (which chooses a default backend) if bytes_shim and bytes_shim.RingBuf and bytes_shim.LinearBuf then - run_backend("shim", bytes_shim) + run_backend('shim', bytes_shim) else - error("bytes shim backend missing RingBuf/LinearBuf") + error('bytes shim backend missing RingBuf/LinearBuf') end -print("all bytes stress tests done") +print('all bytes stress tests done') diff --git a/tests/test_wait.lua b/tests/test_wait.lua index 8379553..2b637a8 100644 --- a/tests/test_wait.lua +++ b/tests/test_wait.lua @@ -2,10 +2,10 @@ print('testing: fibers.wait') -- look one level up -package.path = "../src/?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path -local wait = require 'fibers.wait' -local op = require 'fibers.op' +local wait = require 'fibers.wait' +local op = require 'fibers.op' local fibers = require 'fibers' ---------------------------------------------------------------------- @@ -13,71 +13,71 @@ local fibers = require 'fibers' ---------------------------------------------------------------------- local function check(name, fn) - io.write(name, " ... ") - fn() - io.write("ok\n") + io.write(name, ' ... ') + fn() + io.write('ok\n') end ---------------------------------------------------------------------- -- Waitset tests ---------------------------------------------------------------------- -check("Waitset add/unlink/notify_all", function() - local ws = wait.new_waitset() +check('Waitset add/unlink/notify_all', function () + local ws = wait.new_waitset() - local scheduled = {} - local fake_sched = { - schedule = function(self, task) - scheduled[#scheduled + 1] = task - end, - } + local scheduled = {} + local fake_sched = { + schedule = function (self, task) + scheduled[#scheduled + 1] = task + end, + } - local t1 = { run = function() end } - local t2 = { run = function() end } + local t1 = { run = function () end } + local t2 = { run = function () end } - local tok1 = ws:add("k", t1) - local tok2 = ws:add("k", t2) + local tok1 = ws:add('k', t1) + local _ = ws:add('k', t2) - assert(ws:size("k") == 2, "size after add should be 2") + assert(ws:size('k') == 2, 'size after add should be 2') - -- Unlink first token; bucket should still be non-empty. - local emptied = tok1:unlink() - assert(emptied == false, "unlink of first waiter should not empty bucket") - assert(ws:size("k") == 1, "size after unlink should be 1") + -- Unlink first token; bucket should still be non-empty. + local emptied = tok1:unlink() + assert(emptied == false, 'unlink of first waiter should not empty bucket') + assert(ws:size('k') == 1, 'size after unlink should be 1') - -- notify_all should schedule remaining task and clear bucket. - ws:notify_all("k", fake_sched) - assert(#scheduled == 1 and scheduled[1] == t2, "notify_all should schedule remaining waiter") - assert(ws:is_empty("k"), "bucket should be empty after notify_all") + -- notify_all should schedule remaining task and clear bucket. + ws:notify_all('k', fake_sched) + assert(#scheduled == 1 and scheduled[1] == t2, 'notify_all should schedule remaining waiter') + assert(ws:is_empty('k'), 'bucket should be empty after notify_all') - -- Unlink on already-unlinked token is a no-op. - emptied = tok1:unlink() - assert(emptied == false, "unlink on stale token should be benign") + -- Unlink on already-unlinked token is a no-op. + emptied = tok1:unlink() + assert(emptied == false, 'unlink on stale token should be benign') end) -check("Waitset notify_one", function() - local ws = wait.new_waitset() +check('Waitset notify_one', function () + local ws = wait.new_waitset() - local scheduled = {} - local fake_sched = { - schedule = function(self, task) - scheduled[#scheduled + 1] = task - end, - } + local scheduled = {} + local fake_sched = { + schedule = function (self, task) + scheduled[#scheduled + 1] = task + end, + } - local t1 = { run = function() end } - local t2 = { run = function() end } + local t1 = { run = function () end } + local t2 = { run = function () end } - ws:add("k", t1) - ws:add("k", t2) + ws:add('k', t1) + ws:add('k', t2) - ws:notify_one("k", fake_sched) - assert(#scheduled == 1, "notify_one should schedule exactly one task") - assert(ws:size("k") == 1, "one waiter should remain after notify_one") + ws:notify_one('k', fake_sched) + assert(#scheduled == 1, 'notify_one should schedule exactly one task') + assert(ws:size('k') == 1, 'one waiter should remain after notify_one') - ws:notify_one("k", fake_sched) - assert(#scheduled == 2, "second notify_one should schedule second task") - assert(ws:is_empty("k"), "bucket should be empty after second notify_one") + ws:notify_one('k', fake_sched) + assert(#scheduled == 2, 'second notify_one should schedule second task') + assert(ws:is_empty('k'), 'bucket should be empty after second notify_one') end) ---------------------------------------------------------------------- @@ -85,59 +85,59 @@ end) ---------------------------------------------------------------------- -- 1. Fast path: step completes immediately; register() is never called. -check("waitable fast path (no blocking)", function() - local step_calls = 0 - - local function step() - step_calls = step_calls + 1 - -- done == true, plus two result values. - return true, "ok", 42 - end - - local register_called = false - local function register(_task, _susp, _wrap) - register_called = true - error("register should not be called on fast path") - end - - local ev = wait.waitable(register, step) - - local a, b = op.perform_raw(ev) - assert(step_calls == 1, "step should be called once on fast path") - assert(a == "ok" and b == 42, "results should be returned from step") - assert(not register_called, "register should not be called on fast path") +check('waitable fast path (no blocking)', function () + local step_calls = 0 + + local function step() + step_calls = step_calls + 1 + -- done == true, plus two result values. + return true, 'ok', 42 + end + + local register_called = false + local function register(_, _, _) + register_called = true + error('register should not be called on fast path') + end + + local ev = wait.waitable(register, step) + + local a, b = op.perform_raw(ev) + assert(step_calls == 1, 'step should be called once on fast path') + assert(a == 'ok' and b == 42, 'results should be returned from step') + assert(not register_called, 'register should not be called on fast path') end) -- 2. Blocking path: first step() returns done=false, second returns done=true. -- We run this under the scheduler so the suspension path is exercised. -check("waitable blocking path under scheduler", function() - local step_calls = 0 - - local function step() - step_calls = step_calls + 1 - if step_calls == 1 then - -- Not ready on first probe. - return false - end - -- Ready on second probe. - return true, "ready" - end - - -- register(): in a real backend this would arrange for task:run() - -- once the external condition changes. For this test we just run - -- the task immediately, which still exercises the suspension logic. - local function register(task, _suspension, _wrap) - task:run() - return { unlink = function() end } - end - - local ev = wait.waitable(register, step) - - fibers.run(function() - local res = fibers.perform(ev) - assert(res == "ready", "waitable should eventually return 'ready'") - assert(step_calls == 2, "step should be called twice (try + one wake)") - end) +check('waitable blocking path under scheduler', function () + local step_calls = 0 + + local function step() + step_calls = step_calls + 1 + if step_calls == 1 then + -- Not ready on first probe. + return false + end + -- Ready on second probe. + return true, 'ready' + end + + -- register(): in a real backend this would arrange for task:run() + -- once the external condition changes. For this test we just run + -- the task immediately, which still exercises the suspension logic. + local function register(task, _, _) + task:run() + return { unlink = function () end } + end + + local ev = wait.waitable(register, step) + + fibers.run(function () + local res = fibers.perform(ev) + assert(res == 'ready', "waitable should eventually return 'ready'") + assert(step_calls == 2, 'step should be called twice (try + one wake)') + end) end) -io.write("All wait.lua tests completed\n") +io.write('All wait.lua tests completed\n') diff --git a/tests/test_waitgroup.lua b/tests/test_waitgroup.lua index 2d7125d..3cc5f5d 100644 --- a/tests/test_waitgroup.lua +++ b/tests/test_waitgroup.lua @@ -2,7 +2,7 @@ print('testing: fibers.waitgroup') -- look one level up -package.path = "../src/?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path -- test_waitgroup.lua local fibers = require 'fibers' @@ -13,114 +13,114 @@ local time = require 'fibers.utils.time' local perform, choice = fibers.perform, fibers.choice local function test_nowait() - local wg = waitgroup.new() - local task = wg:wait_op():or_else(function() - error("blocked on empty waitgroup") - end) - perform(task) - print("No wait test: ok") + local wg = waitgroup.new() + local task = wg:wait_op():or_else(function () + error('blocked on empty waitgroup') + end) + perform(task) + print('No wait test: ok') end local function test_simple() - local wg = waitgroup.new() - local numFibers = 5 - - -- Spawn fibers and add to the waitgroup - for _ = 1, numFibers do - wg:add(1) - fibers.spawn(function() - sleep.sleep(0.1) -- Simulate some work - wg:done() - end) - end - - perform( - wg:wait_op():wrap(function() - error("waitgroup didn't block when it should have") - end) - :or_else(function() end) - ) - - wg:wait() - print("Simple test: ok") + local wg = waitgroup.new() + local numFibers = 5 + + -- Spawn fibers and add to the waitgroup + for _ = 1, numFibers do + wg:add(1) + fibers.spawn(function () + sleep.sleep(0.1) -- Simulate some work + wg:done() + end) + end + + perform( + wg:wait_op():wrap(function () + error("waitgroup didn't block when it should have") + end) + :or_else(function () end) + ) + + wg:wait() + print('Simple test: ok') end local function test_reuse() - local wg = waitgroup.new() - -- Spawn fibers and add to the waitgroup - wg:add(1) - fibers.spawn(function() - sleep.sleep(0.1) -- Simulate some work - wg:done() - end) - - wg:wait() - - wg:add(1) - local blocked = false - fibers.spawn(function() - sleep.sleep(0.1) -- Simulate some work - wg:done() - end) - - perform( - wg:wait_op() - :or_else(function () blocked = true end) - ) - assert(blocked, "Reused Waitgroup should block.") - - wg:wait() - - print("Reuse test: ok") + local wg = waitgroup.new() + -- Spawn fibers and add to the waitgroup + wg:add(1) + fibers.spawn(function () + sleep.sleep(0.1) -- Simulate some work + wg:done() + end) + + wg:wait() + + wg:add(1) + local blocked = false + fibers.spawn(function () + sleep.sleep(0.1) -- Simulate some work + wg:done() + end) + + perform( + wg:wait_op() + :or_else(function () blocked = true end) + ) + assert(blocked, 'Reused Waitgroup should block.') + + wg:wait() + + print('Reuse test: ok') end local function test_complex() - local wg = waitgroup.new() - local numFibers = 5 - - local function one_sec_work(w) - w:add(1) - fibers.spawn(function() - sleep.sleep(0.1) -- Simulate some work - w:done() - end) - end - - local start = time.monotonic() - - -- Spawn fibers and add to the waitgroup - for _ = 1, numFibers do one_sec_work(wg) end - - local done = false - - local extra_work_done = false - local function extra_work() - if not extra_work_done then - extra_work_done = true - one_sec_work(wg) - end - end - - while not done do - perform( - choice( - wg:wait_op():wrap(function() done = true end), - sleep.sleep_op(0.09):wrap(extra_work) - ) - ) - end - - assert(time.monotonic() - start > 0.05) - print("Complex test: ok") + local wg = waitgroup.new() + local numFibers = 5 + + local function one_sec_work(w) + w:add(1) + fibers.spawn(function () + sleep.sleep(0.1) -- Simulate some work + w:done() + end) + end + + local start = time.monotonic() + + -- Spawn fibers and add to the waitgroup + for _ = 1, numFibers do one_sec_work(wg) end + + local done = false + + local extra_work_done = false + local function extra_work() + if not extra_work_done then + extra_work_done = true + one_sec_work(wg) + end + end + + while not done do + perform( + choice( + wg:wait_op():wrap(function () done = true end), + sleep.sleep_op(0.09):wrap(extra_work) + ) + ) + end + + assert(time.monotonic() - start > 0.05) + print('Complex test: ok') end local function main() - test_nowait() - test_simple() - test_reuse() - test_complex() + test_nowait() + test_simple() + test_reuse() + test_complex() end -- Start the main function in fiber context From 327d9472f36b8d99a2346b4095cd76c22f2825f0 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sat, 13 Dec 2025 11:41:26 +0000 Subject: [PATCH 097/138] adds Wingo and Snabb acknowledgement! --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 3d0e11c..377f553 100644 --- a/README.md +++ b/README.md @@ -399,3 +399,9 @@ fibers.run(main) ``` From that point on, application code is structured in terms of scopes, operations and normal Lua functions. The runtime ensures that blocking work is expressed as ops, long-lived work is owned by scopes, and everything is shut down cleanly. + +--- + +## Acknowledgements + +The design of *fibers* owes a substantial debt to Andy Wingo’s work on lightweight concurrency and Concurrent ML in Lua. In particular, the library was shaped by his article [“lightweight concurrency in lua”](https://wingolog.org/archives/2018/05/16/lightweight-concurrency-in-lua) and by the original [`fibers`](https://github.com/snabbco/snabb/tree/master/src/lib/fibers) and [stream](https://github.com/snabbco/snabb/tree/master/src/lib/stream) implementations in Snabb’s codebase, which provided both the conceptual model and many of the practical patterns used here. Any good ideas you find in *fibers* are quite likely to have appeared there first! From 618b5c669646aeef057ce49c47b0da08942bb2b4 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Wed, 17 Dec 2025 02:27:39 +0000 Subject: [PATCH 098/138] clearer comments --- src/fibers/scope.lua | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/fibers/scope.lua b/src/fibers/scope.lua index fefa3f9..3393f9d 100644 --- a/src/fibers/scope.lua +++ b/src/fibers/scope.lua @@ -18,6 +18,7 @@ local pack = rawget(table, 'pack') or function (...) end ---@alias ScopeStatus "running"|"ok"|"failed"|"cancelled" +---@alias ScopeFinaliser fun(aborted: boolean, status: ScopeStatus, primary_err: any) --- Supervision scope for structured concurrency. ---@class Scope @@ -28,7 +29,7 @@ end ---@field _extra_errors any[] ---@field failure_mode string # e.g. "fail_fast" ---@field _wg Waitgroup ----@field _finalisers fun(self: Scope)[] # LIFO finalisers +---@field _finalisers ScopeFinaliser[] # LIFO finalisers; called as f(aborted, status, primary_err) ---@field _cancel_cond Cond ---@field _join_cond Cond ---@field _join_worker_started boolean @@ -164,8 +165,11 @@ function Scope:new_child() return new_scope(self) end ---- Register a finaliser to run when the scope closes (LIFO). ----@param handler fun(self: Scope) +--- Finalisers are called as handler(aborted, status, primary_err), where: +--- aborted = (status ~= "ok") +--- status = terminal status ("ok"|"failed"|"cancelled") +--- primary_err = primary error/reason (nil when status == "ok") +---@param handler ScopeFinaliser function Scope:finally(handler) assert(type(handler) == 'function', 'scope:finally expects a function') local finalisers = self._finalisers From 3a35c319d5d5b4c2e822bb5b52d86fc529fbe0d4 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Wed, 17 Dec 2025 02:49:54 +0000 Subject: [PATCH 099/138] adds op examples --- examples/ops/always.lua | 9 +++++++++ examples/ops/boolean_choice.lua | 14 ++++++++++++++ examples/ops/bracket.lua | 28 ++++++++++++++++++++++++++++ examples/ops/choice.lua | 13 +++++++++++++ examples/ops/first_ready.lua | 14 ++++++++++++++ examples/ops/guard.lua | 32 ++++++++++++++++++++++++++++++++ examples/ops/named_choice.lua | 14 ++++++++++++++ examples/ops/never-or_else.lua | 8 ++++++++ examples/ops/on_abort.lua | 14 ++++++++++++++ examples/ops/race.lua | 19 +++++++++++++++++++ examples/ops/with_nack_loses.lua | 17 +++++++++++++++++ examples/ops/with_nack_wins.lua | 30 ++++++++++++++++++++++++++++++ examples/ops/wrap.lua | 8 ++++++++ 13 files changed, 220 insertions(+) create mode 100644 examples/ops/always.lua create mode 100644 examples/ops/boolean_choice.lua create mode 100644 examples/ops/bracket.lua create mode 100644 examples/ops/choice.lua create mode 100644 examples/ops/first_ready.lua create mode 100644 examples/ops/guard.lua create mode 100644 examples/ops/named_choice.lua create mode 100644 examples/ops/never-or_else.lua create mode 100644 examples/ops/on_abort.lua create mode 100644 examples/ops/race.lua create mode 100644 examples/ops/with_nack_loses.lua create mode 100644 examples/ops/with_nack_wins.lua create mode 100644 examples/ops/wrap.lua diff --git a/examples/ops/always.lua b/examples/ops/always.lua new file mode 100644 index 0000000..0c9b489 --- /dev/null +++ b/examples/ops/always.lua @@ -0,0 +1,9 @@ +package.path = '../../src/?.lua;' .. package.path + +local fibers = require 'fibers' + +fibers.run(function () + local op = fibers.always('tea', 2) + local a, b = fibers.perform(op) + print(a, b) -- tea 2 +end) diff --git a/examples/ops/boolean_choice.lua b/examples/ops/boolean_choice.lua new file mode 100644 index 0000000..e5dc260 --- /dev/null +++ b/examples/ops/boolean_choice.lua @@ -0,0 +1,14 @@ +package.path = '../../src/?.lua;' .. package.path + +local fibers = require 'fibers' +local sleep = require 'fibers.sleep' + +fibers.run(function () + local op = fibers.boolean_choice( + sleep.sleep_op(0.05):wrap(function () return 'T' end), + sleep.sleep_op(0.01):wrap(function () return 'F' end) + ) + + local ok, v = fibers.perform(op) + print(ok, v) -- false F +end) diff --git a/examples/ops/bracket.lua b/examples/ops/bracket.lua new file mode 100644 index 0000000..4066ec7 --- /dev/null +++ b/examples/ops/bracket.lua @@ -0,0 +1,28 @@ +package.path = '../../src/?.lua;' .. package.path + +local fibers = require 'fibers' +local sleep = require 'fibers.sleep' + +fibers.run(function () + local function acquire() + print('acquire') + return { id = 1 } + end + + local function release(res, aborted) + print('release', res.id, 'aborted=', aborted) + end + + local function use(res) + return sleep.sleep_op(0.10):wrap(function () return 'used', res.id end) + end + + local protected = fibers.bracket(acquire, release, use) + + local op = fibers.choice( + protected, + sleep.sleep_op(0.01):wrap(function () return 'timeout' end) + ) + + print(fibers.perform(op)) +end) diff --git a/examples/ops/choice.lua b/examples/ops/choice.lua new file mode 100644 index 0000000..994cdb2 --- /dev/null +++ b/examples/ops/choice.lua @@ -0,0 +1,13 @@ +package.path = '../../src/?.lua;' .. package.path + +local fibers = require 'fibers' +local sleep = require 'fibers.sleep' + +fibers.run(function () + local op = fibers.choice( + sleep.sleep_op(0.05):wrap(function () return 'slow' end), + sleep.sleep_op(0.01):wrap(function () return 'fast' end) + ) + + print(fibers.perform(op)) -- fast +end) diff --git a/examples/ops/first_ready.lua b/examples/ops/first_ready.lua new file mode 100644 index 0000000..b4c3e83 --- /dev/null +++ b/examples/ops/first_ready.lua @@ -0,0 +1,14 @@ +package.path = '../../src/?.lua;' .. package.path + +local fibers = require 'fibers' +local sleep = require 'fibers.sleep' + +fibers.run(function () + local op = fibers.first_ready { + sleep.sleep_op(0.05):wrap(function () return 'A' end), + sleep.sleep_op(0.01):wrap(function () return 'B' end), + } + + local i, v = fibers.perform(op) + print(i, v) -- 2 B +end) diff --git a/examples/ops/guard.lua b/examples/ops/guard.lua new file mode 100644 index 0000000..79a6a66 --- /dev/null +++ b/examples/ops/guard.lua @@ -0,0 +1,32 @@ +package.path = '../../src/?.lua;' .. package.path + +local fibers = require 'fibers' +local op = require 'fibers.op' +local sleep = require 'fibers.sleep' + +fibers.run(function () + + local function fake_upstream() + local index, states = 1, { true, true, false, true } + return function () + local retval = states[index] + index = index + 1 + return retval + end + end + + local is_open = fake_upstream() + + local function breaker_op() + return fibers.guard(function () + local ok_op = op.always("ok") + local cooldown_op = sleep.sleep_op(0.1):wrap(function () return 'COOLDOWN' end) + return is_open() and ok_op or cooldown_op + end) + end + + for i = 1, 4 do + local ok = fibers.perform(breaker_op()) + print(i, ok) + end +end) diff --git a/examples/ops/named_choice.lua b/examples/ops/named_choice.lua new file mode 100644 index 0000000..4ba663f --- /dev/null +++ b/examples/ops/named_choice.lua @@ -0,0 +1,14 @@ +package.path = '../../src/?.lua;' .. package.path + +local fibers = require 'fibers' +local sleep = require 'fibers.sleep' + +fibers.run(function () + local op = fibers.named_choice { + slow = sleep.sleep_op(0.05):wrap(function () return 'S' end), + fast = sleep.sleep_op(0.01):wrap(function () return 'F' end), + } + + local name, v = fibers.perform(op) + print(name, v) -- fast F +end) diff --git a/examples/ops/never-or_else.lua b/examples/ops/never-or_else.lua new file mode 100644 index 0000000..e8ab78d --- /dev/null +++ b/examples/ops/never-or_else.lua @@ -0,0 +1,8 @@ +package.path = '../../src/?.lua;' .. package.path + +local fibers = require 'fibers' + +fibers.run(function () + local op = fibers.never():or_else(function () return 'fallback' end) + print(fibers.perform(op)) -- fallback +end) diff --git a/examples/ops/on_abort.lua b/examples/ops/on_abort.lua new file mode 100644 index 0000000..af9e1ee --- /dev/null +++ b/examples/ops/on_abort.lua @@ -0,0 +1,14 @@ +package.path = '../../src/?.lua;' .. package.path + +local fibers = require 'fibers' +local sleep = require 'fibers.sleep' + +fibers.run(function () + local slow = sleep.sleep_op(0.10) + :on_abort(function () print('slow arm aborted') end) + :wrap(function () return 'slow done' end) + + local fast = sleep.sleep_op(0.01):wrap(function () return 'fast done' end) + + print(fibers.perform(fibers.choice(slow, fast))) +end) diff --git a/examples/ops/race.lua b/examples/ops/race.lua new file mode 100644 index 0000000..33085fe --- /dev/null +++ b/examples/ops/race.lua @@ -0,0 +1,19 @@ +package.path = '../../src/?.lua;' .. package.path + +local fibers = require 'fibers' +local sleep = require 'fibers.sleep' + +fibers.run(function () + local op = fibers.race( + { + sleep.sleep_op(0.05):wrap(function () return 'A' end), + sleep.sleep_op(0.01):wrap(function () return 'B' end), + }, + function (i, v) + return ('winner=%d'):format(i), v + end + ) + + local which, v = fibers.perform(op) + print(which, v) -- winner=2 B +end) diff --git a/examples/ops/with_nack_loses.lua b/examples/ops/with_nack_loses.lua new file mode 100644 index 0000000..bd7ee2d --- /dev/null +++ b/examples/ops/with_nack_loses.lua @@ -0,0 +1,17 @@ +package.path = '../../src/?.lua;' .. package.path + +local fibers = require 'fibers' +local op = require 'fibers.op' + +fibers.run(function () + local ev = op.with_nack(function (nack) + -- Arrange to observe nack + return op.choice( + op.never():wrap(function () return 'impossible' end), + nack:wrap(function () return 'nacked' end) + ) + end) + + local chosen = fibers.perform(op.choice(ev, op.always('other'))) + print(chosen) -- other (and the nack branch is enabled, but only if someone perform) +end) diff --git a/examples/ops/with_nack_wins.lua b/examples/ops/with_nack_wins.lua new file mode 100644 index 0000000..6917ef2 --- /dev/null +++ b/examples/ops/with_nack_wins.lua @@ -0,0 +1,30 @@ +package.path = '../../src/?.lua;' .. package.path + +local fibers = require 'fibers' +local op = require 'fibers.op' +local cond = require 'fibers.cond' +local sleep = require 'fibers.sleep' + +fibers.run(function () + local ev = op.with_nack(function (nack) + local done = cond.new() + + fibers.spawn(function () + local which = fibers.perform(op.choice( + nack:wrap(function () return 'nack' end), + done:wait_op():wrap(function () return 'done' end) + )) + if which == 'nack' then print('lost') end + end) + + return sleep.sleep_op(0.05):wrap(function () + done:signal() -- this arm won; stop the watcher + return 'slow done' + end) + end) + + print(fibers.perform(op.choice( + ev, + sleep.sleep_op(0.01):wrap(function () return 'fast done' end) + ))) +end) diff --git a/examples/ops/wrap.lua b/examples/ops/wrap.lua new file mode 100644 index 0000000..99186e0 --- /dev/null +++ b/examples/ops/wrap.lua @@ -0,0 +1,8 @@ +package.path = '../../src/?.lua;' .. package.path + +local fibers = require 'fibers' + +fibers.run(function () + local op = fibers.always(21):wrap(function (n) return n * 2 end) + print(fibers.perform(op)) -- 42 +end) From 86f90a501b2db303aa7f218ee27d75ca199ef562 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Wed, 17 Dec 2025 03:37:07 +0000 Subject: [PATCH 100/138] ensure that oneshots are cleaned up --- src/fibers/cond.lua | 4 +- src/fibers/oneshot.lua | 22 ++++- src/fibers/op.lua | 42 +++++++- tests/test_oneshot.lua | 214 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 274 insertions(+), 8 deletions(-) create mode 100644 tests/test_oneshot.lua diff --git a/src/fibers/cond.lua b/src/fibers/cond.lua index ae89a14..c6b259d 100644 --- a/src/fibers/cond.lua +++ b/src/fibers/cond.lua @@ -28,11 +28,13 @@ function Cond:wait_op() ---@param resumer Suspension ---@param wrap_fn WrapFn function (resumer, wrap_fn) - os:add_waiter(function () + local cancel = os:add_waiter(function () if resumer:waiting() then resumer:complete(wrap_fn) end end) + -- ensure the waiter closure does not remain referenced if the wait is cancelled + resumer:add_cleanup(cancel) end ) end diff --git a/src/fibers/oneshot.lua b/src/fibers/oneshot.lua index c9fa38c..a8d310e 100644 --- a/src/fibers/oneshot.lua +++ b/src/fibers/oneshot.lua @@ -7,11 +7,12 @@ ---@class Oneshot ---@field triggered boolean ----@field waiters OneshotWaiter[] +---@field waiters table[] # list of { fn = OneshotWaiter|nil } ---@field on_after_signal fun()|nil local Oneshot = {} Oneshot.__index = Oneshot +local function noop() end --- Create a new one-shot. ---@param on_after_signal? fun() # optional callback run after signalling all waiters ---@return Oneshot @@ -26,14 +27,21 @@ end --- Register a waiter. --- If already triggered, the thunk is run immediately. ---@param thunk OneshotWaiter +---@return fun() cancel # idempotent deregistration thunk function Oneshot:add_waiter(thunk) if self.triggered then thunk() - return + return noop end local ws = self.waiters - ws[#ws + 1] = thunk + local rec = { fn = thunk } + ws[#ws + 1] = rec + + return function () + -- idempotent; clearing fn drops the closure reference + rec.fn = nil + end end --- Trigger the one-shot. @@ -45,9 +53,13 @@ function Oneshot:signal() local ws = self.waiters for i = 1, #ws do - local f = ws[i] + local rec = ws[i] ws[i] = nil - if f then f() end + if rec then + local f = rec.fn + rec.fn = nil + if f then f() end + end end local cb = self.on_after_signal diff --git a/src/fibers/op.lua b/src/fibers/op.lua index c5598e8..d47685d 100644 --- a/src/fibers/op.lua +++ b/src/fibers/op.lua @@ -42,6 +42,34 @@ function Suspension:waiting() return self.state == 'waiting' end +function Suspension:add_cleanup(f) + assert(type(f) == 'function', 'cleanup must be a function') + if self.cleaned then + -- already completed; run immediately (best-effort) + safe.pcall(f) + return + end + local cs = self.cleanups + if not cs then + cs = {} + self.cleanups = cs + end + cs[#cs + 1] = f +end + +function Suspension:_run_cleanups() + if self.cleaned then return end + self.cleaned = true + + local cs = self.cleanups + self.cleanups = nil + if not cs then return end + + for i = #cs, 1, -1 do + safe.pcall(cs[i]) + cs[i] = nil + end +end --- Mark a suspension as complete and enqueue it on the scheduler. ---@param wrap WrapFn ---@param ... any @@ -50,6 +78,7 @@ function Suspension:complete(wrap, ...) self.state = 'synchronized' self.wrap = wrap self.val = pack(...) + self:_run_cleanups() self.sched:schedule(self) end @@ -60,6 +89,7 @@ end function Suspension:complete_and_run(wrap, ...) assert(self:waiting()) self.state = 'synchronized' + self:_run_cleanups() return self.fiber:resume(wrap, ...) end @@ -81,7 +111,13 @@ end ---@param fib any ---@return Suspension local function new_suspension(sched, fib) - return setmetatable({ state = 'waiting', sched = sched, fiber = fib }, Suspension) + return setmetatable({ + state = 'waiting', + sched = sched, + fiber = fib, + cleanups = nil, + cleaned = false, + }, Suspension) end --- A CompleteTask completes a suspension (if still waiting) when run. @@ -266,11 +302,13 @@ local function new_cond(opts) local function block(suspension, wrap_fn) -- If already triggered, add_waiter will run the thunk immediately. - os:add_waiter(function () + local cancel = os:add_waiter(function () if suspension:waiting() then suspension:complete(wrap_fn) end end) + -- ensure we drop the waiter closure if this suspension completes via another arm + suspension:add_cleanup(cancel) end return new_primitive(nil, try, block) diff --git a/tests/test_oneshot.lua b/tests/test_oneshot.lua new file mode 100644 index 0000000..cf0cba1 --- /dev/null +++ b/tests/test_oneshot.lua @@ -0,0 +1,214 @@ +--- Tests the Oneshot implementation. +print('testing: fibers.oneshot') + +-- look one level up +package.path = '../src/?.lua;' .. package.path + +local oneshot = require 'fibers.oneshot' + +local fibers = require 'fibers' +local op = require 'fibers.op' +local cond = require 'fibers.cond' +local runtime = require 'fibers.runtime' +local safe = require 'coxpcall' + +local function count_live_waiters(os) + -- Patched oneshot: waiters are records { fn = function|nil }. + -- Older oneshot (pre-patch): waiters were bare functions. + local ws = os.waiters or {} + local n = 0 + for i = 1, #ws do + local v = ws[i] + if type(v) == 'function' then + n = n + 1 + elseif type(v) == 'table' and type(v.fn) == 'function' then + n = n + 1 + end + end + return n +end + +local function assert_equal(actual, expected, msg) + if actual ~= expected then + error((msg or 'assert_equal failed') .. + (': expected %s, got %s'):format(tostring(expected), tostring(actual)), 2) + end +end + +local function assert_true(v, msg) + if not v then error(msg or 'assert_true failed', 2) end +end + +local function assert_false(v, msg) + if v then error(msg or 'assert_false failed', 2) end +end + +local function run_test(name, fn) + local ok, err = safe.pcall(fn) + if ok then + print(name .. ': ok') + return + end + error(name .. ': FAILED\n' .. tostring(err), 0) +end + +local function test_waiters_run_on_signal_and_clear() + local os = oneshot.new() + local log = {} + + os:add_waiter(function () log[#log + 1] = 'w1' end) + os:add_waiter(function () log[#log + 1] = 'w2' end) + + assert_equal(#log, 0, 'waiters should not run before signal') + assert_false(os:is_triggered(), 'should not be triggered before signal') + + os:signal() + + assert_true(os:is_triggered(), 'should be triggered after signal') + assert_equal(#log, 2, 'expected two waiter runs') + assert_equal(log[1], 'w1') + assert_equal(log[2], 'w2') + assert_equal(count_live_waiters(os), 0, 'no live waiters should remain after signal') +end + +local function test_add_waiter_after_signal_runs_immediately() + local os = oneshot.new() + os:signal() + + local ran = false + os:add_waiter(function () ran = true end) + + assert_true(ran, 'waiter should run immediately after signal') + assert_equal(count_live_waiters(os), 0, 'no live waiters should be stored after signal') +end + +local function test_signal_is_idempotent() + local os = oneshot.new() + local n = 0 + + os:add_waiter(function () n = n + 1 end) + + os:signal() + os:signal() + os:signal() + + assert_equal(n, 1, 'waiter must run only once') +end + +local function test_on_after_signal_runs_after_waiters() + local log = {} + local os = oneshot.new(function () + log[#log + 1] = 'after' + end) + + os:add_waiter(function () log[#log + 1] = 'w1' end) + os:add_waiter(function () log[#log + 1] = 'w2' end) + + os:signal() + + assert_equal(#log, 3) + assert_equal(log[1], 'w1') + assert_equal(log[2], 'w2') + assert_equal(log[3], 'after') +end + +local function test_add_waiter_returns_canceller_and_cancel_prevents_run() + local os = oneshot.new() + local ran = false + + local cancel = os:add_waiter(function () ran = true end) + assert_true(type(cancel) == 'function', 'expected add_waiter to return a canceller function') + + -- idempotent + cancel() + cancel() + + os:signal() + + assert_false(ran, 'cancelled waiter must not run on signal') + assert_equal(count_live_waiters(os), 0, 'no live waiters should remain after signal') +end + +local function test_add_waiter_after_signal_returns_noop_canceller() + local os = oneshot.new() + os:signal() + + local ran = false + local cancel = os:add_waiter(function () ran = true end) + + assert_true(ran, 'waiter should run immediately') + assert_true(type(cancel) == 'function', 'expected a canceller function') + cancel() + cancel() +end + +local function test_reentrant_add_waiter_during_signal() + local os = oneshot.new() + local log = {} + + os:add_waiter(function () + log[#log + 1] = 'a' + os:add_waiter(function () + log[#log + 1] = 'b' + end) + end) + + os:signal() + + assert_equal(#log, 2) + assert_equal(log[1], 'a') + assert_equal(log[2], 'b') +end + +local function test_integration_choice_cleans_losing_cond_waiter() + -- This asserts the specific regression you were targeting: if a wait is abandoned, + -- its waiter closure should not remain referenced by the oneshot. + local c1 = cond.new() + local c2 = cond.new() + + local chosen = nil + + fibers.spawn(function () + chosen = fibers.perform(op.choice( + c1:wait_op():wrap(function () return 'c1' end), + c2:wait_op():wrap(function () return 'c2' end) + )) + end) + + -- Allow the spawned fiber to run and block, registering both waiters. + runtime.yield() + + assert_equal(count_live_waiters(c1._os), 1, 'expected c1 to have 1 live waiter while blocked') + assert_equal(count_live_waiters(c2._os), 1, 'expected c2 to have 1 live waiter while blocked') + + -- Complete one arm. + c1:signal() + + -- Allow completion to propagate. + runtime.yield() + + assert_equal(chosen, 'c1', 'expected choice winner to be c1') + + -- Winner and loser should not retain live waiter closures after completion. + assert_equal(count_live_waiters(c1._os), 0, 'expected c1 to have 0 live waiters after signal') + assert_equal(count_live_waiters(c2._os), 0, 'expected c2 to have 0 live waiters after losing the choice') +end + +local function main() + local tests = { + { 'Basic signal behaviour', test_waiters_run_on_signal_and_clear }, + { 'add_waiter after signal runs immediately', test_add_waiter_after_signal_runs_immediately }, + { 'signal idempotence', test_signal_is_idempotent }, + { 'on_after_signal ordering', test_on_after_signal_runs_after_waiters }, + { 'canceller prevents run', test_add_waiter_returns_canceller_and_cancel_prevents_run }, + { 'noop canceller after signal', test_add_waiter_after_signal_returns_noop_canceller }, + { 're-entrant add_waiter during signal', test_reentrant_add_waiter_during_signal }, + { 'integration: choice cleans losing waiter', test_integration_choice_cleans_losing_cond_waiter }, + } + + for _, t in ipairs(tests) do + run_test(t[1], t[2]) + end +end + +fibers.run(main) From 074ce5c2467195e15b857f95648258dd7aec82a4 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Wed, 17 Dec 2025 03:42:01 +0000 Subject: [PATCH 101/138] improves probe randomness --- src/fibers/op.lua | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/fibers/op.lua b/src/fibers/op.lua index d47685d..0b0248a 100644 --- a/src/fibers/op.lua +++ b/src/fibers/op.lua @@ -437,9 +437,9 @@ end 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 start = math.random(n) + for k = 0, n - 1 do + local idx = ((start + k - 1) % n) + 1 local op = ops[idx] local retval = pack(op.try_fn()) if retval[1] then From cf7fc0108963e3f5085f14d81c5ce75d4163b0f2 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Wed, 17 Dec 2025 04:23:35 +0000 Subject: [PATCH 102/138] adds more complex demo --- demos/scoped_line_server.lua | 246 +++++++++++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 demos/scoped_line_server.lua diff --git a/demos/scoped_line_server.lua b/demos/scoped_line_server.lua new file mode 100644 index 0000000..03c023f --- /dev/null +++ b/demos/scoped_line_server.lua @@ -0,0 +1,246 @@ +-- scoped_line_server.lua +-- +-- Single-file runnable example for the fibers runtime: +-- * UNIX-domain line server +-- * per-connection worker pool using channels +-- * external “work” simulated via /bin/sh (sleep + tr) +-- * timeouts and cancellation via ops + scopes +-- * no pcall in application logic: failures propagate via scopes +-- +-- Run: +-- lua scoped_line_server.lua +-- +-- Optional: +-- SH=/usr/bin/sh lua demo.lua + +package.path = '../src/?.lua;' .. package.path + +local fibers = require 'fibers' +local socket = require 'fibers.io.socket' +local sleep = require 'fibers.sleep' +local chan = require 'fibers.channel' +local exec = require 'fibers.io.exec' + +local function log(msg) + io.stderr:write(('[demo] %s\n'):format(msg)) +end + +local SH = os.getenv('SH') or '/bin/sh' + +math.randomseed(os.time()) +local SOCK_PATH = ('/tmp/fibers-demo-%d-%d.sock'):format(os.time(), math.random(1, 10^9)) + +-- External work implemented via sh: +-- * sleep briefly (fall back to 1s if fractional sleep unsupported) +-- * uppercase the input via tr +local WORK_SH = table.concat({ + 'sleep 0.2 2>/dev/null || sleep 1', + 'printf "%s" "$1" | tr a-z A-Z', +}, '; ') + +local function perform_named_choice(arms) + return fibers.perform(fibers.named_choice(arms)) +end + +local function handle_client(scope, stream) + scope:finally(function() + stream:close() + end) + + local jobs = chan.new(16) -- backpressure when workers are busy + + local WORKERS = 2 + for _ = 1, WORKERS do + fibers.spawn(function() + while true do + local job = jobs:get() + if job == nil then + return + end + + -- argv: sh -c