diff --git a/src/fibers/io/exec.lua b/src/fibers/io/exec.lua index 6d5cea9..230a101 100644 --- a/src/fibers/io/exec.lua +++ b/src/fibers/io/exec.lua @@ -478,12 +478,11 @@ function Command:shutdown_op(grace) 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) + -- boolean_choice already prefixes the winning arm with a boolean. + -- Do not add another boolean here, otherwise shutdown_op returns + -- true, , , when the run arm wins. + self:run_op(), + sleep.sleep_op(g) ) return choice_ev:wrap(function (is_exit, status, code, signal, e) @@ -583,12 +582,8 @@ function Command:_shutdown_uninterruptible(grace) -- Race exit against grace timer without involving scope cancellation. local is_exit, _, _, _, _ = op.perform_raw( op.boolean_choice( - self:run_op():wrap(function (st, c, sig, perr) - return true, st, c, sig, perr - end), - sleep.sleep_op(g):wrap(function () - return false - end) + self:run_op(), + sleep.sleep_op(g) ) ) diff --git a/src/fibers/io/exec_backend.lua b/src/fibers/io/exec_backend.lua index 7778775..20f32ed 100644 --- a/src/fibers/io/exec_backend.lua +++ b/src/fibers/io/exec_backend.lua @@ -1,6 +1,6 @@ -- -- Backend selector for process management. --- Prefers pidfd backend where available, falls back to SIGCHLD/self-pipe. +-- Prefers pidfd where available, then pure POSIX process backends, then nixio. -- ---@module 'fibers.io.exec_backend' @@ -22,7 +22,8 @@ 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.posix_reaper', -- luaposix reaper/sentinel backend (LuaJIT-safe) + 'fibers.io.exec_backend.nixio', -- nixio reaper/sentinel backend } ---@type ExecBackendModule|nil diff --git a/src/fibers/io/exec_backend/nixio.lua b/src/fibers/io/exec_backend/nixio.lua index 3bdf2d5..5e814d3 100644 --- a/src/fibers/io/exec_backend/nixio.lua +++ b/src/fibers/io/exec_backend/nixio.lua @@ -26,6 +26,7 @@ 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 reaper_common = require 'fibers.io.exec_backend.reaper_common' local ok, nixio = pcall(require, 'nixio') if not ok or not nixio then @@ -203,7 +204,7 @@ local function child_exec(child_spec, child_only, parent_fds, sentinel_w) end ---------------------------------------------------------------------- --- Backend state helpers and parsing +-- Parent-side reaper helpers ---------------------------------------------------------------------- ---@class NixioExecState @@ -219,65 +220,46 @@ end ---@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 +local function reap_reaper(state, blocking) + if state._reaper_reaped or not state.reaper_pid then 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 + local pid + if blocking then + pid = nixio.waitpid(state.reaper_pid) else - state.err = state.err or ("unknown status tag '" .. tostring(tag) .. "'") - state.exited = true - state._have_status = true - return + pid = nixio.waitpid(state.reaper_pid, 'nohang') 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 +local function read_sentinel(fd) + local chunk, _ = fd:read(const.buffersize or 256) + if not chunk then + local eno = nixio.errno() + if eno == const.EAGAIN or eno == const.EWOULDBLOCK or eno == const.EINTR then + return nil, 'wait', nil + end + return nil, 'error', 'reaper sentinel closed' + end + if #chunk == 0 then + return nil, 'eof', nil + end + return chunk, 'data', nil +end + +local reaper_ops = { + read_sentinel = read_sentinel, + close_fd = close_fd, + reap_reaper = reap_reaper, + kill_pid = nil, -- filled after send_signal helpers are defined + default_term = const.SIGTERM or 15, +} + ---------------------------------------------------------------------- -- Reaper process path ---------------------------------------------------------------------- @@ -357,89 +339,6 @@ local function reaper_main(child_spec, child_only, parent_fds, sentinel_r, senti 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 ---------------------------------------------------------------------- @@ -489,19 +388,7 @@ local function spawn(spec) 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, - } + local state = reaper_common.new_state(reaper_pid, sentinel_r) -- Handshake: read sentinel until we have seen a pid line and/or a -- terminal status. This guarantees child_pid is known before any @@ -521,16 +408,7 @@ local function spawn(spec) 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 + reaper_common.feed_status_chunk(state, chunk) end -- Switch sentinel to non-blocking for normal event-loop use. @@ -543,61 +421,32 @@ end --- poll(state) -> done, code, signal, err local function poll_backend(state) - return poll_state(state) + return reaper_common.poll_state(state, reaper_ops) 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) + return reaper_common.register_wait(state, task) end --- Send a signal to the real child. ----@param state NixioExecState ----@param sig integer|nil +---@param pid integer +---@param sig integer ---@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) +local function kill_pid(pid, sig) + local ok1, err = nixio.kill(pid, sig) if not ok1 then return false, err or errno_msg('kill') end return true, nil end +reaper_ops.kill_pid = kill_pid + +local function send_signal(state, sig) + return reaper_common.send_signal(state, sig, reaper_ops) +end + local function terminate(state) return send_signal(state, const.SIGTERM or 15) end @@ -607,10 +456,7 @@ local function kill_proc(state) end local function close_state(state) - close_fd(state.sentinel) - state.sentinel = nil - reap_reaper(state) - return true, nil + return reaper_common.close_state(state, reaper_ops) end local function is_supported() diff --git a/src/fibers/io/exec_backend/posix_reaper.lua b/src/fibers/io/exec_backend/posix_reaper.lua new file mode 100644 index 0000000..e085cc1 --- /dev/null +++ b/src/fibers/io/exec_backend/posix_reaper.lua @@ -0,0 +1,498 @@ +-- fibers/io/exec_backend/posix_reaper.lua +-- +-- luaposix-based exec backend using a per-command reaper process and +-- a sentinel pipe for completion notifications. +-- +-- This backend is intended for runtimes where a Lua SIGCHLD handler is not +-- safe or reliable, notably LuaJIT + luaposix. It preserves the evented +-- parent-side model: the scheduler waits for ordinary fd readability on a +-- sentinel pipe rather than polling time. +-- +-- Topology per command: +-- parent +-- ├─ reaper process (Lua, this module) +-- │ └─ real 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 the Fibers poller to wait for sentinel_r readability. When +-- terminal status arrives, the parent also reaps the intermediate reaper. + +---@module 'fibers.io.exec_backend.posix_reaper' + +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 reaper_common = require 'fibers.io.exec_backend.reaper_common' + +local ok_unistd, unistd = pcall(require, 'posix.unistd') +local ok_wait, syswait = pcall(require, 'posix.sys.wait') +local ok_signal, psig = pcall(require, 'posix.signal') +local ok_fcntl, fcntl = pcall(require, 'posix.fcntl') +local ok_errno, errno = pcall(require, 'posix.errno') +local ok_stdlib, stdlib = pcall(require, 'posix.stdlib') + +if not (ok_unistd and ok_wait and ok_signal and ok_fcntl and ok_errno and ok_stdlib) then + return { is_supported = function () return false end } +end + +local bit = rawget(_G, 'bit') or require 'bit32' + +local DEV_NULL = '/dev/null' + +---------------------------------------------------------------------- +-- Small helpers +---------------------------------------------------------------------- + +local function errno_msg(prefix, err, eno) + if err and err ~= '' then + return err + end + if eno then + return ('%s (errno %s)'):format(prefix, tostring(eno)) + end + return prefix +end + +local function close_fd(fd) + if fd ~= nil then + pcall(unistd.close, fd) + end +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 ok, err2, eno2 = fcntl.fcntl(fd, fcntl.F_SETFL, bit.bor(flags, fcntl.O_NONBLOCK)) + if ok == nil then + return nil, errno_msg('fcntl(F_SETFL)', err2, eno2) + end + return true, nil +end + +local function set_cloexec(fd) + if not (fcntl.F_GETFD and fcntl.F_SETFD) then + return true, nil + end + 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 ok, err2, eno2 = fcntl.fcntl(fd, fcntl.F_SETFD, bit.bor(flags, fcntl.FD_CLOEXEC or 0)) + if ok == nil then + return nil, errno_msg('fcntl(F_SETFD)', err2, eno2) + end + return true, nil +end + +local function write_all(fd, s) + local off = 1 + while off <= #s do + local n, err, eno = unistd.write(fd, s:sub(off)) + if n == nil then + if eno == errno.EINTR then + -- Retry. + else + return nil, errno_msg('write', err, eno) + end + else + off = off + n + end + end + return true, nil +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 + +---------------------------------------------------------------------- +-- Stdio integration for exec_backend.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 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 + +---------------------------------------------------------------------- +-- Child exec path +---------------------------------------------------------------------- + +---@param spec table -- child-facing spec with *fd fields +---@param child_only table|nil +---@param parent_fds table|nil +---@param sentinel_w integer|nil +local function child_exec(spec, child_only, parent_fds, sentinel_w) + -- The real child must not keep the sentinel writer open across exec. + close_fd(sentinel_w) + + 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) + + stdio.close_child_only(child_only, close_fd) + stdio.close_parent_fds(parent_fds, close_fd) + + local cmd, argt = build_argt(spec.argv) + unistd.execp(cmd, argt) + unistd._exit(127) +end + +---------------------------------------------------------------------- +-- Reaper process path +---------------------------------------------------------------------- + +local function wait_blocking(pid) + while true do + local rpid, how, value, err, eno = syswait.wait(pid) + if rpid ~= nil then + return rpid, how, value, nil, nil + end + if eno ~= errno.EINTR then + return nil, nil, nil, err, eno + end + end +end + +--- Run in the per-command reaper process. +---@param child_spec table +---@param child_only table|nil +---@param parent_fds table|nil +---@param sentinel_r integer +---@param sentinel_w integer +local function reaper_main(child_spec, child_only, parent_fds, sentinel_r, sentinel_w) + -- The reaper does not need parent pipe ends or the parent's sentinel read end. + stdio.close_parent_fds(parent_fds, close_fd) + close_fd(sentinel_r) + + local child_pid, err, eno = unistd.fork() + if not child_pid then + write_all(sentinel_w, 'failed ' .. errno_msg('fork child', err, eno) .. '\n') + close_fd(sentinel_w) + unistd._exit(127) + end + + if child_pid == 0 then + child_exec(child_spec, child_only, parent_fds, sentinel_w) + unistd._exit(127) + end + + -- In the reaper. + stdio.close_child_only(child_only, close_fd) + write_all(sentinel_w, ('pid %d\n'):format(child_pid)) + + local pid, how, what, werr, weno = wait_blocking(child_pid) + local line + if not pid then + line = 'failed ' .. errno_msg('wait child', werr, weno) .. '\n' + elseif how == 'exited' then + line = ('exited %d\n'):format(tonumber(what) or 0) + else + line = ('signaled %d\n'):format(tonumber(what) or 0) + end + + write_all(sentinel_w, line) + close_fd(sentinel_w) + unistd._exit(0) +end + +---------------------------------------------------------------------- +-- Parent-side reaper helpers +---------------------------------------------------------------------- + +---@class PosixReaperState +---@field reaper_pid integer +---@field pid integer|nil +---@field child_pid integer|nil +---@field sentinel integer|nil +---@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 reap_reaper(state, blocking) + if state._reaper_reaped or not state.reaper_pid then + return + end + + while true do + local pid, how, _, err, eno + if blocking then + pid, how, _, err, eno = syswait.wait(state.reaper_pid) + else + pid, how, _, err, eno = syswait.wait(state.reaper_pid, syswait.WNOHANG) + end + + if pid == nil then + if eno == errno.EINTR then + -- Retry. + elseif eno == errno.ECHILD then + state._reaper_reaped = true + return + else + state.err = state.err or errno_msg('wait reaper', err, eno) + return + end + elseif pid == 0 or how == 'running' then + return + else + state._reaper_reaped = true + return + end + end +end + +local function read_sentinel(fd) + local chunk, err, eno = unistd.read(fd, 4096) + if chunk == nil then + if eno == errno.EAGAIN or eno == errno.EWOULDBLOCK or eno == errno.EINTR then + return nil, 'wait', nil + end + return nil, 'error', errno_msg('read sentinel', err, eno) + end + if #chunk == 0 then + return nil, 'eof', nil + end + return chunk, 'data', nil +end + +local reaper_ops = { + read_sentinel = read_sentinel, + close_fd = close_fd, + reap_reaper = reap_reaper, + kill_pid = nil, -- filled after send_signal helpers are defined + default_term = psig.SIGTERM or 15, +} + +---------------------------------------------------------------------- +-- exec_backend.core ops +---------------------------------------------------------------------- + +---@param spec ExecProcSpec +---@return PosixReaperState|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 + + local sentinel_r, sentinel_w, serr = make_pipe() + if not sentinel_r then + stdio.close_child_only(child_only, close_fd) + stdio.close_parent_fds(parent_fds, close_fd) + return nil, nil, serr or 'pipe (sentinel) failed' + end + + set_cloexec(sentinel_r) + set_cloexec(sentinel_w) + + local reaper_pid, ferr, feno = unistd.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, errno_msg('fork reaper', ferr, feno) + end + + if reaper_pid == 0 then + reaper_main(child_spec, child_only, parent_fds, sentinel_r, sentinel_w) + unistd._exit(127) + end + + -- Parent. + stdio.close_child_only(child_only, close_fd) + close_fd(sentinel_w) + + local state = reaper_common.new_state(reaper_pid, sentinel_r) + + -- Handshake: block only at spawn time until the reaper has reported the + -- real child pid or a terminal failure. This guarantees send_signal() + -- targets the exec child rather than the intermediate reaper. + while not state.child_pid and not state._have_status do + local chunk, rerr, reno = unistd.read(sentinel_r, 4096) + if chunk == nil then + close_fd(sentinel_r) + state.sentinel = nil + reap_reaper(state, false) + stdio.close_parent_fds(parent_fds, close_fd) + return nil, nil, errno_msg('sentinel handshake read', rerr, reno) + end + if #chunk == 0 then + close_fd(sentinel_r) + state.sentinel = nil + reap_reaper(state, false) + stdio.close_parent_fds(parent_fds, close_fd) + return nil, nil, 'sentinel closed during handshake' + end + + reaper_common.feed_status_chunk(state, chunk) + end + + local ok_nb, nb_err = set_nonblock(sentinel_r) + if not ok_nb then + close_fd(sentinel_r) + state.sentinel = nil + stdio.close_parent_fds(parent_fds, close_fd) + return nil, nil, nb_err + end + + local streams = stdio.build_parent_streams(parent_fds, open_stream) + return state, streams, nil +end + +local function poll_backend(state) + return reaper_common.poll_state(state, reaper_ops) +end + +local function register_wait(state, task, _, _) + return reaper_common.register_wait(state, task) +end + +local function kill_pid(pid, sig) + local rc, err, eno = psig.kill(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 + +reaper_ops.kill_pid = kill_pid + +local function send_signal(state, sig) + return reaper_common.send_signal(state, sig, reaper_ops) +end + +local function terminate(state) + return send_signal(state, psig.SIGTERM or 15) +end + +local function kill_proc(state) + return send_signal(state, psig.SIGKILL or 9) +end + +local function close_state(state) + return reaper_common.close_state(state, reaper_ops) +end + +local function is_supported() + return type(unistd.fork) == 'function' + and type(unistd.execp) == 'function' + and type(unistd.pipe) == 'function' + and type(unistd.read) == 'function' + and type(unistd.write) == 'function' + and type(unistd._exit) == 'function' + and type(syswait.wait) == 'function' + and syswait.WNOHANG ~= nil + and type(psig.kill) == 'function' + and type(fcntl.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/reaper_common.lua b/src/fibers/io/exec_backend/reaper_common.lua new file mode 100644 index 0000000..1ebe988 --- /dev/null +++ b/src/fibers/io/exec_backend/reaper_common.lua @@ -0,0 +1,232 @@ +-- fibers/io/exec_backend/reaper_common.lua +-- +-- Shared parent-side support for exec backends that use a per-command +-- reaper process and a sentinel pipe. Backend-specific modules still own +-- fork/exec, stdio setup and fd operations; this module owns only the common +-- sentinel protocol and parent-side state transitions. +-- +-- Sentinel protocol: +-- pid \n +-- exited \n +-- signaled \n +-- failed \n + +---@module 'fibers.io.exec_backend.reaper_common' + +local poller = require 'fibers.io.poller' +local runtime = require 'fibers.runtime' + +local M = {} + +---@param reaper_pid integer +---@param sentinel any +---@return table +function M.new_state(reaper_pid, sentinel) + return { + reaper_pid = reaper_pid, + pid = reaper_pid, -- updated to child pid by pid line + child_pid = nil, + sentinel = sentinel, + exited = false, + code = nil, + signal = nil, + err = nil, + _buf = '', + _have_status = false, + _reaper_reaped = false, + } +end + +---@param line string +---@param state table +function M.parse_status_line_into_state(line, state) + line = tostring(line or ''):gsub('\r', '') + local tag, rest = line:match('^(%S+)%s*(.*)$') + + if tag == 'pid' then + local cpid = tonumber(rest) + if cpid then + state.child_pid = cpid + state.pid = cpid + end + return + elseif tag == 'exited' then + state.code = tonumber(rest) or 0 + state.signal = nil + state.err = state.err or nil + state.exited = true + state._have_status = true + return + elseif tag == 'signaled' or tag == 'signalled' then + state.code = nil + state.signal = tonumber(rest) or 0 + state.err = state.err or nil + state.exited = true + state._have_status = true + return + elseif tag == 'failed' then + state.code = nil + state.signal = nil + state.err = rest ~= '' and rest or 'exec backend failed' + state.exited = true + state._have_status = true + return + end + + state.code = nil + state.signal = nil + state.err = 'unknown status line from reaper: ' .. tostring(line) + state.exited = true + state._have_status = true +end + +---@param state table +---@param chunk string +function M.feed_status_chunk(state, chunk) + if not chunk or chunk == '' then + return + 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 + M.parse_status_line_into_state(line, state) + end +end + +local function close_sentinel(state, ops) + if state.sentinel ~= nil then + ops.close_fd(state.sentinel) + state.sentinel = nil + end +end + +---@param state table +---@param ops table backend operations +local function mark_sentinel_gone(state, ops, err) + close_sentinel(state, ops) + if not state._have_status then + state.exited = true + state.err = state.err or err or 'reaper sentinel closed' + end + ops.reap_reaper(state, true) +end + +---@param state table +---@param ops table backend operations +function M.drain_sentinel(state, ops) + local sentinel = state.sentinel + if not sentinel then + return + end + + while true do + local chunk, status, err = ops.read_sentinel(sentinel) + + if chunk ~= nil then + if #chunk == 0 then + mark_sentinel_gone(state, ops, 'reaper sentinel closed') + break + end + + M.feed_status_chunk(state, chunk) + + if state.exited then + close_sentinel(state, ops) + ops.reap_reaper(state, true) + break + end + elseif status == 'wait' then + break + elseif status == 'eof' then + mark_sentinel_gone(state, ops, 'reaper sentinel closed') + break + else + mark_sentinel_gone(state, ops, err or 'read sentinel failed') + break + end + end +end + +---@param state table +---@param ops table backend operations +---@return boolean done, integer|nil code, integer|nil signal, string|nil err +function M.poll_state(state, ops) + if state.exited then + return true, state.code, state.signal, state.err + end + + if not state.sentinel then + if not state._have_status then + state.exited = true + state.err = state.err or 'reaper sentinel closed' + end + ops.reap_reaper(state, true) + return true, state.code, state.signal, state.err + end + + M.drain_sentinel(state, ops) + + if state.exited then + return true, state.code, state.signal, state.err + end + return false, nil, nil, nil +end + +---@param state table +---@param task table +---@return table token +function M.register_wait(state, task) + if not state.sentinel then + 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 + +---@param state table +---@param sig integer|nil +---@param ops table backend operations +---@return boolean ok, string|nil err +function M.send_signal(state, sig, ops) + sig = sig or ops.default_term or 15 + + if state.exited then + return true, nil + end + + M.poll_state(state, ops) + if state.exited then + return true, nil + end + + local target = state.child_pid or state.pid or state.reaper_pid + if not target then + return false, 'no child or reaper pid available' + end + + return ops.kill_pid(target, sig) +end + +---@param state table +---@param ops table backend operations +---@return boolean ok, string|nil err +function M.close_state(state, ops) + close_sentinel(state, ops) + -- Terminal commands should reap the intermediate reaper synchronously so + -- completed commands do not accumulate as zombies. If close() is used on a + -- still-running backend, keep non-blocking behaviour. + ops.reap_reaper(state, state.exited or state._have_status) + return true, nil +end + +return M diff --git a/src/fibers/io/exec_backend/sigchld.lua b/src/fibers/io/exec_backend/sigchld.lua index b6f77a7..8325043 100644 --- a/src/fibers/io/exec_backend/sigchld.lua +++ b/src/fibers/io/exec_backend/sigchld.lua @@ -156,14 +156,22 @@ local function install_self_pipe_and_handler() sig_r, sig_w = r, w local function handler() - unistd.write(sig_w, 'x') + -- LuaJIT can be unstable when a signal handler calls back into + -- luaposix while another luaposix C function, such as poll(), is + -- active. Under LuaJIT, rely on poll() being interrupted with + -- EINTR; the posix poller wakes all waiters on EINTR so wait_op() + -- can reap with waitpid(WNOHANG). Under PUC Lua, keep the + -- traditional self-pipe write. + if not jit then + unistd.write(sig_w, 'x') + end end if jit and jit.off then jit.off(handler, true) end - local flags = psignal.SA_RESTART + local flags = jit and nil or psignal.SA_RESTART local old, serr, seno if flags ~= nil then old, serr, seno = psignal.signal(psignal.SIGCHLD, handler, flags) @@ -474,8 +482,19 @@ local function close_state(state) end local function is_supported() - if rawget(_G, 'jit') then return false end -- rare LuaJit instability + -- LuaJIT + luaposix signal callbacks are not reliable in exec stress paths. + -- The selector falls through to exec_backend.posix_reaper for that runtime, + -- preserving evented completion via sentinel pipes without a Lua SIGCHLD + -- handler. Keep this SIGCHLD self-pipe backend for ordinary Lua. + if rawget(_G, 'jit') then + return false + end return psignal.SIGCHLD ~= nil + and type(unistd.fork) == 'function' + and type(unistd.execp) == 'function' + and type(unistd.pipe) == 'function' + and type(syswait.wait) == 'function' + and type(psignal.kill) == 'function' end local ops = { diff --git a/src/fibers/io/poller/select.lua b/src/fibers/io/poller/select.lua index 6aaa382..b0901b7 100644 --- a/src/fibers/io/poller/select.lua +++ b/src/fibers/io/poller/select.lua @@ -18,6 +18,28 @@ local errno_mod = require 'posix.errno' local poll_fn = poll_mod.poll +local function wake_all_waiters(rd_waitset, wr_waitset) + local events = {} + + for fd, list in pairs(rd_waitset.buckets) do + if list and #list > 0 then + local e = events[fd] or {} + e.rd = true + events[fd] = e + end + end + + for fd, list in pairs(wr_waitset.buckets) do + if list and #list > 0 then + local e = events[fd] or {} + e.wr = true + events[fd] = e + end + end + + return events +end + ---------------------------------------------------------------------- -- Backend ops for poller.core ---------------------------------------------------------------------- @@ -66,9 +88,13 @@ local function poll_backend(_, timeout_ms, 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. + -- Treat EINTR as a benign interruption. Unlike epoll, the + -- luaposix SIGCHLD backend may rely on poll() being interrupted + -- rather than on a self-pipe write under LuaJIT. Wake current + -- waiters spuriously so their non-blocking step functions can + -- observe any completed work and then re-register if needed. if eno == errno_mod.EINTR then - return {} + return wake_all_waiters(rd_waitset, wr_waitset) end error(('%s (errno %s)'):format(tostring(err), tostring(eno))) end diff --git a/src/fibers/oneshot.lua b/src/fibers/oneshot.lua index a8d310e..938c4c7 100644 --- a/src/fibers/oneshot.lua +++ b/src/fibers/oneshot.lua @@ -35,12 +35,49 @@ function Oneshot:add_waiter(thunk) end local ws = self.waiters - local rec = { fn = thunk } - ws[#ws + 1] = rec + local rec = { fn = thunk, idx = #ws + 1 } + ws[rec.idx] = rec return function () - -- idempotent; clearing fn drops the closure reference + -- Idempotent. A cancelled waiter must be physically removed, not + -- merely tombstoned. Scope cancellation/fault one-shots are normally + -- long-lived and most waits are cancelled by losing-choice cleanup; + -- leaving empty records in the waiter array makes long-lived scopes + -- grow with every completed scoped perform. + if rec.fn == nil then return end rec.fn = nil + + -- During or after signalling, signal() owns the waiter array. Clearing + -- fn above is enough and avoids mutating the array being iterated. + if self.triggered then return end + + local i = rec.idx + if i and ws[i] == rec then + local last_i = #ws + local last = ws[last_i] + ws[last_i] = nil + if last ~= rec then + ws[i] = last + if last then last.idx = i end + end + rec.idx = nil + return + end + + -- Fallback for defensive correctness if an index became stale. + for j = #ws, 1, -1 do + if ws[j] == rec then + local last_i = #ws + local last = ws[last_i] + ws[last_i] = nil + if last ~= rec then + ws[j] = last + if last then last.idx = j end + end + rec.idx = nil + return + end + end end end @@ -52,12 +89,14 @@ function Oneshot:signal() self.triggered = true local ws = self.waiters + self.waiters = {} for i = 1, #ws do local rec = ws[i] ws[i] = nil if rec then local f = rec.fn rec.fn = nil + rec.idx = nil if f then f() end end end diff --git a/tests/io_backend_family.lua b/tests/io_backend_family.lua new file mode 100644 index 0000000..5c220af --- /dev/null +++ b/tests/io_backend_family.lua @@ -0,0 +1,147 @@ +-- tests/io_backend_family.lua +-- +-- Test helper for forcing a single fibers.io backend family in a fresh Lua +-- process. This deliberately prevents mixed fd/poller/exec backend selection. +-- It must be required before any fibers.io selector or higher-level fibers.io +-- module is loaded. + +local M = {} + +local families = { + ffi = { + fd = 'fibers.io.fd_backend.ffi', + poller = 'fibers.io.poller.epoll', + exec = 'fibers.io.exec_backend.pidfd', + disable = { + 'fibers.io.fd_backend.posix', + 'fibers.io.fd_backend.nixio', + 'fibers.io.poller.select', + 'fibers.io.poller.nixio', + 'fibers.io.exec_backend.sigchld', + 'fibers.io.exec_backend.posix_reaper', + 'fibers.io.exec_backend.nixio', + }, + }, + posix = { + fd = 'fibers.io.fd_backend.posix', + poller = 'fibers.io.poller.select', + exec = function () + if rawget(_G, 'jit') then + return 'fibers.io.exec_backend.posix_reaper' + end + return 'fibers.io.exec_backend.sigchld' + end, + disable = { + 'fibers.io.fd_backend.ffi', + 'fibers.io.fd_backend.nixio', + 'fibers.io.poller.epoll', + 'fibers.io.poller.nixio', + 'fibers.io.exec_backend.pidfd', + 'fibers.io.exec_backend.nixio', + }, + }, + nixio = { + fd = 'fibers.io.fd_backend.nixio', + poller = 'fibers.io.poller.nixio', + exec = 'fibers.io.exec_backend.nixio', + disable = { + 'fibers.io.fd_backend.ffi', + 'fibers.io.fd_backend.posix', + 'fibers.io.poller.epoll', + 'fibers.io.poller.select', + 'fibers.io.exec_backend.pidfd', + 'fibers.io.exec_backend.sigchld', + 'fibers.io.exec_backend.posix_reaper', + }, + }, +} + +local selectors = { + 'fibers.io.fd_backend', + 'fibers.io.poller', + 'fibers.io.exec_backend', + 'fibers.io.file', + 'fibers.io.socket', + 'fibers.io.stream', + 'fibers.io.exec', +} + +local function already_loaded(name) + return package.loaded[name] ~= nil +end + +local function fail_if_io_loaded() + for _, name in ipairs(selectors) do + assert(not already_loaded(name), + ('cannot force backend family after %s has already been loaded'):format(name)) + end +end + +local function disabled_loader(name) + return function() + error(('backend module %s disabled by io_backend_family test gate'):format(name), 0) + end +end + +function M.expected(family) + local spec = families[family] + assert(spec, ('unknown backend family %q'):format(tostring(family))) + return spec +end + +local function expected_exec(spec) + if type(spec.exec) == 'function' then + return spec.exec() + end + return spec.exec +end + +function M.force(family) + local spec = M.expected(family) + fail_if_io_loaded() + for _, name in ipairs(spec.disable) do + package.loaded[name] = nil + package.preload[name] = disabled_loader(name) + end + _G.__FIBERS_TEST_IO_BACKEND_FAMILY = family + return spec +end + +local function assert_supported(name) + local ok, mod = pcall(require, name) + assert(ok, ('failed to require %s: %s'):format(name, tostring(mod))) + assert(type(mod) == 'table', ('%s did not return a table'):format(name)) + assert(type(mod.is_supported) == 'function', ('%s has no is_supported()'):format(name)) + assert(mod.is_supported(), ('%s is not supported in this runtime'):format(name)) + return mod +end + +function M.assert_selected(family) + local spec = M.expected(family) + assert_supported(spec.fd) + assert_supported(spec.poller) + local exec_name = expected_exec(spec) + assert_supported(exec_name) + + local fd_backend = require 'fibers.io.fd_backend' + local poller = require 'fibers.io.poller' + local exec_be = require 'fibers.io.exec_backend' + + assert(fd_backend == package.loaded[spec.fd], + ('fd_backend selector did not choose %s'):format(spec.fd)) + assert(poller == package.loaded[spec.poller], + ('poller selector did not choose %s'):format(spec.poller)) + assert(exec_be == package.loaded[exec_name], + ('exec_backend selector did not choose %s'):format(exec_name)) + + for _, name in ipairs(spec.disable) do + -- The selector deliberately probes earlier candidates with pcall(require, name). + -- A disabled preload loader may therefore leave package.loaded[name] as + -- false/nil depending on the Lua implementation. What must not happen is + -- successful loading of a backend module table. + assert(type(package.loaded[name]) ~= 'table', + ('disabled backend was unexpectedly loaded: %s'):format(name)) + end +end + +return M diff --git a/tests/test.lua b/tests/test.lua index 01418d5..c15984b 100644 --- a/tests/test.lua +++ b/tests/test.lua @@ -11,8 +11,10 @@ local modules = { { 'io', 'mem' }, { 'io', 'stream' }, { 'io', 'socket' }, + { 'io', 'upload_stress' }, { 'io', 'exec_backend' }, { 'io', 'exec' }, + { 'io', 'backend_families' }, { 'timer' }, { 'alarm' }, { 'sched' }, @@ -20,6 +22,7 @@ local modules = { { 'channel' }, { 'mailbox' }, { 'pulse' }, + { 'oneshot' }, { 'cond' }, { 'sleep' }, { 'sleep', 'timer_cancel' }, diff --git a/tests/test_io-backend_families.lua b/tests/test_io-backend_families.lua new file mode 100644 index 0000000..bd5fcf7 --- /dev/null +++ b/tests/test_io-backend_families.lua @@ -0,0 +1,57 @@ +-- tests/test_io-backend_families.lua +-- +-- Cycles the relevant fibers.io tests across the supported backend families. +-- Each family is run in a fresh Lua process. This is necessary because the +-- selector modules are cached in package.loaded after first use. + +print('testing: fibers.io backend families') + +local function split_csv(s) + local out = {} + for item in tostring(s):gmatch('[^,%s]+') do + table.insert(out, item) + end + return out +end + +local function shell_quote(s) + s = tostring(s) + return "'" .. s:gsub("'", "'\\''") .. "'" +end + +local function command_ok(a, b, c) + -- Lua 5.1/LuaJIT: os.execute returns a numeric status, usually 0 on success. + -- Lua 5.2+: returns true/nil plus exit metadata. + if type(a) == 'boolean' then + return a, c or 0 + end + if type(a) == 'number' then + return a == 0, a + end + return false, tostring(a) +end + +if os.getenv('LUA_FIBERS_SKIP_BACKEND_FAMILIES') == '1' then + print('skip - fibers.io backend families: LUA_FIBERS_SKIP_BACKEND_FAMILIES=1') + return +end + +local families = split_csv(os.getenv('LUA_FIBERS_BACKEND_FAMILIES') or 'ffi,posix,nixio') +local lua = os.getenv('LUA') or (arg and arg[-1]) or 'lua' + +assert(#families > 0, 'no backend families selected') + +for _, family in ipairs(families) do + local cmd = table.concat({ + shell_quote(lua), + shell_quote('test_io_backend_family_child.lua'), + '--family', + shell_quote(family), + }, ' ') + print(('running backend family subprocess: %s'):format(family)) + local ok, code = command_ok(os.execute(cmd)) + assert(ok, ('backend family %s failed; command=%s status=%s'):format( + family, cmd, tostring(code))) +end + +print('fibers.io backend family tests passed') diff --git a/tests/test_io-upload_stress.lua b/tests/test_io-upload_stress.lua new file mode 100644 index 0000000..225e398 --- /dev/null +++ b/tests/test_io-upload_stress.lua @@ -0,0 +1,163 @@ +-- tests/test_io-upload_stress.lua +-- +-- Small upload-shaped regression test for long-lived scopes. It exercises the +-- socket -> stream -> file path repeatedly in the same scope and asserts that +-- the scope's cancellation/fault one-shots do not retain cancelled waiters from +-- completed I/O operations. + +print('testing: fibers.io upload stress') + +package.path = '../src/?.lua;' .. package.path +package.path = package.path .. ';/usr/lib/lua/?.lua;/usr/lib/lua/?/init.lua' + +if os.getenv('LUA_FIBERS_SKIP_UPLOAD_STRESS') == '1' then + print('test_io-upload_stress.lua: skipped by LUA_FIBERS_SKIP_UPLOAD_STRESS=1') + return +end + +local fibers = require 'fibers' +local socket_mod = require 'fibers.io.socket' +local file_mod = require 'fibers.io.file' +local waitgroup = require 'fibers.waitgroup' +local safe = require 'coxpcall' + +local perform = fibers.perform + +local MB = tonumber(os.getenv('LUA_FIBERS_UPLOAD_STRESS_MB') or '4') +local REPEAT = tonumber(os.getenv('LUA_FIBERS_UPLOAD_STRESS_REPEAT') or '3') +local WRITE_CHUNK = tonumber(os.getenv('LUA_FIBERS_UPLOAD_STRESS_WRITE_CHUNK') or tostring(64 * 1024)) +local READ_CHUNK = tonumber(os.getenv('LUA_FIBERS_UPLOAD_STRESS_READ_CHUNK') or tostring(32 * 1024)) + +assert(MB and MB > 0, 'bad LUA_FIBERS_UPLOAD_STRESS_MB') +assert(REPEAT and REPEAT > 0, 'bad LUA_FIBERS_UPLOAD_STRESS_REPEAT') +assert(WRITE_CHUNK and WRITE_CHUNK > 0, 'bad LUA_FIBERS_UPLOAD_STRESS_WRITE_CHUNK') +assert(READ_CHUNK and READ_CHUNK > 0, 'bad LUA_FIBERS_UPLOAD_STRESS_READ_CHUNK') + +local TOTAL_BYTES = MB * 1024 * 1024 + +local function waiter_slots(os) + local ws = os and os.waiters + return ws and #ws or 0 +end + +local function collect_full() + collectgarbage('collect') + collectgarbage('collect') +end + +local function checked_spawn(scope, wg, errors, fn) + wg:add(1) + scope:spawn(function () + local ok, err = safe.pcall(fn) + if not ok then + errors[#errors + 1] = err + end + wg:done() + end) +end + +local function close_best_effort(obj) + if obj and obj.close then + safe.pcall(function () obj:close() end) + end +end + +local function write_all(stream, data) + local n, err = perform(stream:write_op(data)) + assert(err == nil, 'write failed: ' .. tostring(err)) + assert(n == #data, ('short write: %s of %s'):format(tostring(n), tostring(#data))) +end + +local function run_upload_once(scope, iter) + local tmpdir = os.getenv('TMPDIR') or '/tmp' + local path = string.format('%s/fibers_upload_stress.%d.%d.%d.sock', + tmpdir, os.time(), math.random(1, 1000000), iter) + + local listener, lerr = socket_mod.listen_unix(path, { ephemeral = true }) + assert(listener, 'listen_unix failed: ' .. tostring(lerr)) + + local out, ferr = file_mod.tmpfile() + assert(out, 'tmpfile failed: ' .. tostring(ferr)) + + local wg = waitgroup.new() + local errors = {} + local chunk = string.rep('u', WRITE_CHUNK) + + checked_spawn(scope, wg, errors, function () + local conn, aerr = listener:accept() + assert(conn, 'accept failed: ' .. tostring(aerr)) + + local got = 0 + while true do + local data, cnt, rerr = perform(conn:core_read_op { + min = 1, + max = READ_CHUNK, + eof_ok = true, + }) + assert(rerr == nil, 'read failed: ' .. tostring(rerr)) + if not data or cnt == 0 then + break + end + got = got + cnt + write_all(out, data) + end + + assert(got == TOTAL_BYTES, + ('server received %d bytes, expected %d'):format(got, TOTAL_BYTES)) + + close_best_effort(conn) + close_best_effort(out) + close_best_effort(listener) + end) + + checked_spawn(scope, wg, errors, function () + local client, cerr = socket_mod.connect_unix(path) + assert(client, 'connect_unix failed: ' .. tostring(cerr)) + + local sent = 0 + while sent < TOTAL_BYTES do + local n = math.min(#chunk, TOTAL_BYTES - sent) + if n == #chunk then + write_all(client, chunk) + else + write_all(client, chunk:sub(1, n)) + end + sent = sent + n + end + + close_best_effort(client) + end) + + perform(wg:wait_op()) + + if #errors > 0 then + error(('upload iteration %d failed: %s'):format(iter, tostring(errors[1]))) + end +end + +local function main(scope) + math.randomseed(os.time()) + collect_full() + + local base_cancel = waiter_slots(scope._cancel_os) + local base_fault = waiter_slots(scope._fault_os) + + for i = 1, REPEAT do + run_upload_once(scope, i) + collect_full() + + local cancel_slots = waiter_slots(scope._cancel_os) + local fault_slots = waiter_slots(scope._fault_os) + assert(cancel_slots == base_cancel, + ('scope cancel waiter slots grew after upload %d: base=%d now=%d'): + format(i, base_cancel, cancel_slots)) + assert(fault_slots == base_fault, + ('scope fault waiter slots grew after upload %d: base=%d now=%d'): + format(i, base_fault, fault_slots)) + end +end + +fibers.run(main) + +print(('test_io-upload_stress.lua: %d x %d MiB upload-shaped checks passed'): + format(REPEAT, MB)) diff --git a/tests/test_io_backend_family_child.lua b/tests/test_io_backend_family_child.lua new file mode 100644 index 0000000..3e40b4c --- /dev/null +++ b/tests/test_io_backend_family_child.lua @@ -0,0 +1,41 @@ +-- tests/test_io_backend_family_child.lua +-- +-- Runs the existing I/O tests under one forced backend family. This script is +-- launched by test_io-backend_families.lua in a fresh Lua process so selector +-- state cannot leak between families. + +package.path = '../src/?.lua;' .. package.path +package.path = './?.lua;' .. package.path +package.path = package.path .. ';/usr/lib/lua/?.lua;/usr/lib/lua/?/init.lua' + +local family +for i = 1, #arg do + if arg[i] == '--family' then + family = arg[i + 1] + end +end + +assert(family, 'usage: lua test_io_backend_family_child.lua --family ') + +local gate = require 'io_backend_family' +gate.force(family) +gate.assert_selected(family) + +print(('testing: fibers.io backend family: %s'):format(family)) + +-- These are the existing tests that are materially affected by fd_backend, +-- poller and exec_backend selection. They are intentionally re-run once per +-- family in separate processes. +local tests = { + 'test_io-file.lua', + 'test_io-socket.lua', + 'test_io-upload_stress.lua', + 'test_io-exec_backend.lua', + 'test_io-exec.lua', +} + +for _, file in ipairs(tests) do + dofile(file) +end + +print(('backend family %s: all selected I/O tests passed'):format(family)) diff --git a/tests/test_oneshot.lua b/tests/test_oneshot.lua index cf0cba1..af8efa9 100644 --- a/tests/test_oneshot.lua +++ b/tests/test_oneshot.lua @@ -129,6 +129,25 @@ local function test_add_waiter_returns_canceller_and_cancel_prevents_run() assert_equal(count_live_waiters(os), 0, 'no live waiters should remain after signal') end + +local function test_cancelled_waiters_are_unlinked() + -- Regression for long-lived scopes: losing choice arms cancel many one-shot + -- waiters. It is not enough to clear rec.fn; the records themselves must + -- be unlinked so the waiter array does not grow for the lifetime of the + -- scope's cancellation/fault one-shots. + local os = oneshot.new() + local cancels = {} + for i = 1, 1000 do + cancels[i] = os:add_waiter(function () end) + end + assert_equal(#os.waiters, 1000, 'expected waiters to be registered') + for i = 1, #cancels do + cancels[i]() + end + assert_equal(#os.waiters, 0, 'cancelled waiters must be physically removed') + assert_equal(count_live_waiters(os), 0, 'cancelled waiters must not remain live') +end + local function test_add_waiter_after_signal_returns_noop_canceller() local os = oneshot.new() os:signal() @@ -201,6 +220,7 @@ local function main() { '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 }, + { 'cancelled waiters are unlinked', test_cancelled_waiters_are_unlinked }, { '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 },