From 34af7c1ac95d54dcefc3f11eccc3f010eb40660d Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sun, 21 Dec 2025 03:05:47 +0000 Subject: [PATCH 1/8] join-tree guarantees, cancellation as control flow --- .vscode/settings.json | 2 +- examples/03-scope-defers.lua | 13 +- ...tured-concurrency-and-fail-fast-scopes.lua | 15 +- examples/05-cancel-subprocess.lua | 57 +- examples/06-scope-command-shared-pipe.lua | 119 +- ...07-subprocess-with-structured-shutdown.lua | 39 +- src/fibers.lua | 105 +- src/fibers/io/exec.lua | 208 ++-- src/fibers/scope.lua | 1030 ++++++++++------- tests/test_scope.lua | 886 +++++++------- 10 files changed, 1332 insertions(+), 1142 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 9bcc741..278ae14 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,7 +2,7 @@ "editor.tabSize": 4, "editor.indentSize": "tabSize", "editor.insertSpaces": true, - "editor.formatOnSave": true, + "editor.formatOnSave": false, "editor.formatOnSaveMode": "modifications", "editor.codeActionsOnSave": { "source.fixAll.eslint": "never" diff --git a/examples/03-scope-defers.lua b/examples/03-scope-defers.lua index ec68943..af75392 100644 --- a/examples/03-scope-defers.lua +++ b/examples/03-scope-defers.lua @@ -24,13 +24,12 @@ fibers.run(function () error('worker body failed') end - local status, err, def_errs = fibers.run_scope(worker) + local status, primary, rep = fibers.run_scope(worker) - 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))) + print('status:', status) + print('primary:', primary) + print('extra failures:', #rep.extra_errors) + for i, e in ipairs(rep.extra_errors) 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 6d44194..7a861eb 100644 --- a/examples/04-structured-concurrency-and-fail-fast-scopes.lua +++ b/examples/04-structured-concurrency-and-fail-fast-scopes.lua @@ -26,23 +26,16 @@ end local function main() print('Main: starting child scope') - local status, err = run_scope(function (child_scope) - -- All fibers spawned here are supervised by child_scope. + local status, value_or_primary, _ = run_scope(function (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) + print('Child initial status:', child_scope:status()) - -- 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) + -- No explicit join here; just return. end) - print('Main: child scope finished with:', status, err) + print('Main: child scope finished with:', status, value_or_primary) if status ~= 'ok' then print('Main: treating non-ok child status as error') diff --git a/examples/05-cancel-subprocess.lua b/examples/05-cancel-subprocess.lua index 870acf8..36dd040 100644 --- a/examples/05-cancel-subprocess.lua +++ b/examples/05-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 fibers +-- 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 fiber. +-- Notes for the current scope semantics: +-- * fibers.run_scope(fn, ...) returns: +-- - 'ok', packed_results_table, report +-- - 'failed'|'cancelled', primary, report +-- * Command cleanup runs as a scope finaliser and is non-interruptible. package.path = '../src/?.lua;' .. package.path @@ -21,7 +21,6 @@ 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 ---------------------------------------------------------------------- @@ -31,10 +30,9 @@ local sleep_op = sleep.sleep_op 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 () + -- Run the subprocess and its helper fibres inside a child scope. + -- Use run_scope so we can interpret status and primary at a clear boundary. + local st, value_or_primary, _ = fibers.run_scope(function (s) print('[subscope] starting child process') ------------------------------------------------------------------ @@ -45,13 +43,13 @@ local function main() local cmd = exec.command { 'sh', '-c', script, - stdin = 'null', -- no input - stdout = 'pipe', -- capture output - stderr = 'inherit', -- pass through + stdin = 'null', -- no input + stdout = 'pipe', -- capture output + stderr = 'inherit', -- pass through } ------------------------------------------------------------------ - -- 2. Reader fiber: drain stdout until EOF or error + -- 2. Reader fibre: drain stdout until EOF or error ------------------------------------------------------------------ spawn(function () @@ -78,20 +76,6 @@ local function main() ------------------------------------------------------------------ -- 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(), @@ -99,8 +83,6 @@ local function main() )) 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 @@ -110,21 +92,20 @@ local function main() -- 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. + -- This cancels the reader fibre and any other work in the scope. + -- The Command’s scope finaliser will then perform a best-effort, + -- non-interruptible shutdown and close any owned streams. -- print('[subscope] timeout reached; cancelling subprocess scope') - current_scope():cancel('timeout') + s:cancel('timeout') end) -------------------------------------------------------------------- -- 5. Supervision boundary: interpret the outcome -------------------------------------------------------------------- - print('[root] subprocess scope completed; status:', status, 'reason:', reason) + local reason = (st == 'ok') and nil or value_or_primary + print('[root] subprocess scope completed; status:', st, 'reason:', reason) end run(main) diff --git a/examples/06-scope-command-shared-pipe.lua b/examples/06-scope-command-shared-pipe.lua index 3682bc5..56e86bf 100644 --- a/examples/06-scope-command-shared-pipe.lua +++ b/examples/06-scope-command-shared-pipe.lua @@ -1,10 +1,9 @@ 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 with_scope_op = fibers.with_scope_op local named_choice = fibers.named_choice @@ -16,39 +15,41 @@ local function main(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. + -- Finaliser runs only after the scope has drained spawned fibres and joined children. parent_scope:finally(function () - print('[parent] finaliser: closing shared streams') - assert(r_stream:close()); assert(w_stream:close()) + print('[parent] finaliser: closing streams (runs after reader fibre has finished)') + assert(r_stream:close()) + assert(w_stream:close()) end) ---------------------------------------------------------------------- - -- Reader fiber in the parent scope + -- Reader fibre in the parent scope (no explicit join/wait needed) ---------------------------------------------------------------------- - 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 + do + local ok, err = fibers.spawn(function () + print('[reader] started') + while true do + local line, e = perform(r_stream:read_line_op()) + if not line then + print('[reader] EOF/error:', e) + break + end + print('[reader] got:', line) end - - print('[parent-reader] got:', line) - end - - -- Signal that the reader is finished. - reader_done:signal() - end) + print('[reader] exiting') + end) + assert(ok, err) + end ---------------------------------------------------------------------- -- Child scope as an Op: command writes ticks to the shared stream + -- + -- with_scope_op yields: + -- scope_st, value_or_primary, report + -- where on scope_st == 'ok', value_or_primary is a packed table of + -- cmd:run_op() results: { [1]=cmd_st, [2]=code, [3]=signal, [4]=err, n=4 } ---------------------------------------------------------------------- - local child_scope_op = with_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]] @@ -58,36 +59,64 @@ local function main(parent_scope) stdout = w_stream, -- shared stream from parent scope } - print('[child] starting ticking command') - -- Return an Op that completes when the process exits + print('[child] starting command') return cmd:run_op() end) ---------------------------------------------------------------------- -- Race the child scope against a timeout ---------------------------------------------------------------------- + local timeout_op = sleep.sleep_op(3):wrap(function () + return 'timeout' + end) + local ev = named_choice { - child_scope_done = child_scope_op, -- long-running command - timeout = sleep.sleep_op(3), -- after ~3 seconds + child_scope_done = child_scope_op, + timeout = timeout_op, } - local which, status, code_or_sig, err = perform(ev) - print('[parent] choice result:', which, status, code_or_sig, err) + local which, a, b, c = perform(ev) + + if which == 'timeout' then + print('[parent] choice: timeout (child scope was aborted and joined by with_scope_op)') + else + local scope_st, value_or_primary, _ = a, b, c + + if scope_st == 'ok' then + local vals = value_or_primary + local cmd_st = vals[1] + local code = vals[2] + local signal = vals[3] + local cmd_err = vals[4] + + print('[parent] choice: child_scope_done', + 'cmd_st=', cmd_st, + 'code=', code, + 'signal=', signal, + 'err=', cmd_err + ) + -- report is available if you want to inspect child outcomes + -- print('[parent] child report id:', report.id) + else + print('[parent] child scope ended:', + 'scope_st=', scope_st, + 'primary=', value_or_primary + ) + end + end ---------------------------------------------------------------------- - -- 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. + -- Important point: + -- We do not wait for the reader fibre explicitly. + -- + -- We close the writer to provoke EOF, then return from main. + -- The surrounding scope join (performed by fibers.run) will wait + -- until the reader fibre exits, then run finalisers, then return. ---------------------------------------------------------------------- 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') + print('[parent] returning from main (reader may still be draining)') end fibers.run(main) + +print('[outside] fibers.run returned (all scoped work, including reader fibre, has completed)') diff --git a/examples/07-subprocess-with-structured-shutdown.lua b/examples/07-subprocess-with-structured-shutdown.lua index a7304f1..bd093bd 100644 --- a/examples/07-subprocess-with-structured-shutdown.lua +++ b/examples/07-subprocess-with-structured-shutdown.lua @@ -4,14 +4,14 @@ 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 unpack = rawget(table, 'unpack') or _G.unpack + 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. + -- cancellation is neatly contained and reported at the boundary. + local st, value_or_primary, _ = run_scope(function () local cmd = exec.command( 'sh', '-c', "echo 'hello from child process'; " .. @@ -19,23 +19,34 @@ local function main() "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()) + -- Use the current scope's status-first API to avoid raising on cancellation. + local s = fibers.current_scope() + local ost, output, proc_status, code, signal, perr = s:try(cmd:output_op()) + + if ost ~= 'ok' then + -- ost is 'failed' or 'cancelled'; output/proc_status/... are not meaningful here. + return ost, output + end 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 + -- Return any values you want the boundary to carry on success. + return proc_status, (code or signal), perr end) - print('[root] child exec scope finished with:', status, code_or_sig, err) + if st == 'ok' and value_or_primary then + -- On ok, the second return is a packed results table. + local proc_status, code_or_sig, perr = unpack(value_or_primary, 1, value_or_primary.n) + print('[root] child exec scope finished with:', st, proc_status, code_or_sig, perr) + else + -- On failed/cancelled, the second return is the primary error/reason. + print('[root] child exec scope finished with:', st, value_or_primary) + end + + -- report is available for diagnostics/telemetry if you want it: + -- print('child scope report id:', report.id) end run(main) diff --git a/src/fibers.lua b/src/fibers.lua index 0946bfc..7a5f653 100644 --- a/src/fibers.lua +++ b/src/fibers.lua @@ -1,12 +1,4 @@ -- 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), --- - primitives (sleep, channel, etc.). --- ---@module 'fibers' local Op = require 'fibers.op' @@ -19,8 +11,15 @@ local pack = rawget(table, 'pack') or function (...) return { n = select('#', ...), ... } end +local function raise_string(err) + if type(err) == 'string' or type(err) == 'number' then + error(err, 0) + end + error(tostring(err), 0) +end + ---------------------------------------------------------------------- --- Core entry points +-- Core entry point ---------------------------------------------------------------------- --- Run a main function under the scheduler's root scope. @@ -31,78 +30,68 @@ end --- returns ...results... from main_fn directly. --- --- On failure or cancellation: ---- raises the primary error / cancellation reason. +--- raises a string/number (never a table). --- ----@param main_fn fun(s: Scope, ...): any +---@param main_fn fun(s: any, ...): any ---@param ... any ----@return any ... -- only results from main_fn on success +---@return any ... local function run(main_fn, ...) assert(not Runtime.current_fiber(), - 'fibers.run must not be called from inside a fiber') + 'fibers.run must not be called from inside a fibre') 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 + local box = { + status = nil, -- 'ok'|'cancelled'|'failed' + value = nil, -- primary/reason (for non-ok) + results = nil, -- packed results table (for ok) + -- report = nil, -- optional: ScopeReport } 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 + -- Scope.run returns: st, value_or_primary, report + local st, v, _ = Scope.run(main_fn, unpack(args, 1, args.n)) + box.status = st + + if st == 'ok' then + -- v is the packed results table (may be empty but non-nil by convention) + box.results = v + -- box.report = _rep + else + box.value = v + -- box.report = _rep 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) + if box.status == 'ok' then + local res = box.results + if res and res.n and res.n > 0 then + return unpack(res, 1, res.n) + end + return end - if results then - return unpack(results, 1, results.n) - end - -- No results from main_fn: return nothing. + raise_string(box.value or box.status or 'fibers.run: missing status') end ---------------------------------------------------------------------- -- Spawn ---------------------------------------------------------------------- ---- Spawn a fiber under the current scope. ---- +--- Spawn a fibre under the current scope. --- fn is called as fn(...). ---@param fn fun(...): any ---@param ... any +---@return boolean ok, any|nil err local function spawn(fn, ...) local s = Scope.current() local args = { ... } - -- Wrapper that discards the scope parameter injected by Scope:spawn. local function shim(_, ...) return fn(...) end @@ -110,11 +99,26 @@ local function spawn(fn, ...) return s:spawn(shim, unpack(args)) end +---------------------------------------------------------------------- +-- Optional helper: non-raising perform under current scope +---------------------------------------------------------------------- + +--- Perform an op under the current scope, returning status-first. +--- Must be called from inside a fibre. +---@param ev any +---@return string status +---@return any ... +local function try_perform(ev) + local s = Scope.current() + return s:try(ev) +end + return { spawn = spawn, run = run, - perform = Performer.perform, + perform = Performer.perform, + try_perform = try_perform, now = Runtime.now, @@ -125,7 +129,6 @@ return { never = Op.never, bracket = Op.bracket, - -- Higher-level choice helpers race = Op.race, first_ready = Op.first_ready, named_choice = Op.named_choice, @@ -135,5 +138,5 @@ return { run_scope = Scope.run, with_scope_op = Scope.with_op, set_unscoped_error_handler = Scope.set_unscoped_error_handler, - current_scope = Scope.current + current_scope = Scope.current, } diff --git a/src/fibers/io/exec.lua b/src/fibers/io/exec.lua index d041c0c..1e01440 100644 --- a/src/fibers/io/exec.lua +++ b/src/fibers/io/exec.lua @@ -1,20 +1,25 @@ -- fibers/io/exec.lua - Structured process execution bound to scopes ----@module 'fibers.exec' +---@module 'fibers.io.exec' local Runtime = require 'fibers.runtime' -local Scope = require 'fibers.scope' +local ScopeMod = 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 unpack = rawget(table, 'unpack') or _G.unpack +local pack = rawget(table, 'pack') or function (...) + return { n = select('#', ...), ... } +end + 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. +--- ExecSpec: argv[1] is the programme to exec; argv[2..n] are its arguments. ---@class ExecSpec ---@field [integer] string # argv elements (1..n) ---@field cwd string|nil @@ -101,20 +106,32 @@ local function assert_not_started(self) 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 finalisers 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 raw. +--- +--- Important: use Scope:try (status-first) rather than Scope:perform to avoid raising +--- cancellation sentinels from inside op commit/wrap code paths. ---@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) + local s = ScopeMod.current() + + -- Scope:status() returns (st, v). We only care about the first. + local st = s and s.status and s:status() or nil + if s and s.try and st == 'running' then + local r = pack(s:try(ev)) + local rst = r[1] + if rst == 'ok' then + return unpack(r, 2, r.n) + end + -- If cancelled/failed, return a conventional triple where the last + -- value carries an error string. This avoids throwing from here. + local msg = r[2] + if msg == nil then + msg = (rst == 'cancelled') and 'scope cancelled' or 'scope failed' end + return nil, nil, tostring(msg) end + return op.perform_raw(ev) end @@ -184,8 +201,7 @@ function Command:_ensure_started() 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 the backend created pipe streams for us, record them and mark them as owned. if proc_handle.stdin then self._stdin.stream = proc_handle.stdin self._stdin.owned = true @@ -206,63 +222,42 @@ 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 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 @@ -273,10 +268,6 @@ 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 @@ -290,18 +281,13 @@ function Command:status() 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 end ---- Return a shallow copy of the argv array. ----@return string[] function Command:argv() local out = {} for i, v in ipairs(self._argv) do @@ -314,10 +300,6 @@ 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 @@ -334,12 +316,10 @@ function Command:kill(sig) 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 @@ -355,9 +335,6 @@ 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 @@ -373,9 +350,6 @@ 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 @@ -391,9 +365,6 @@ 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 @@ -416,14 +387,6 @@ 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() @@ -441,15 +404,6 @@ 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() @@ -499,11 +453,8 @@ function Command:shutdown_op(grace) 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). + -- Wait for completion, using the current scope when running (status-first), + -- otherwise falling back to raw waiting. 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 @@ -512,15 +463,6 @@ 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. @@ -546,15 +488,6 @@ 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') @@ -565,16 +498,74 @@ function Command:combined_output_op() return self:output_op() end +---------------------------------------------------------------------- +-- Finaliser-only shutdown (non-interruptible) +---------------------------------------------------------------------- + +--- Best-effort shutdown used during scope finalisation. +--- This path must not be interruptible by scope cancellation. +---@param grace number|nil +function Command:_shutdown_uninterruptible(grace) + if not (self._started and not self._done) then + return + end + + local ok, proc = self:_ensure_started() + if not (ok and proc and proc.backend) then + return + end + + local g = grace or self._shutdown_grace or DEFAULT_SHUTDOWN_GRACE + + -- Polite termination. + if proc.backend.terminate then + proc.backend:terminate() + elseif proc.backend.send_signal then + proc.backend:send_signal() + end + + -- 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) + ) + ) + + if not is_exit then + -- Escalate. + if proc.backend.kill then + proc.backend:kill() + elseif proc.backend.send_signal then + proc.backend:send_signal() + end + + -- Ensure the process is waited for (uninterruptible). + local code2, signal2, err2 = op.perform_raw(proc.backend:wait_op()) + self:_record_exit(code2, signal2, err2) + + -- Preserve any earlier status data if present. + -- (status/code/signal/e are unused here by design.) + return + end + + -- If it exited during the grace period, run_op has already recorded status. + -- Nothing more to do here. + return +end + ---------------------------------------------------------------------- -- Scope 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 finaliser machinery and recorded as a scope failure. - op.perform_raw(self:shutdown_op(self._shutdown_grace)) + -- Non-interruptible best-effort shutdown. + self:_shutdown_uninterruptible(self._shutdown_grace) end for _, name in ipairs { 'stdin', 'stdout', 'stderr' } do @@ -601,12 +592,11 @@ 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() + assert(Runtime.current_fiber(), 'exec.command must be called from inside a fibre') + local scope = ScopeMod.current() local argv = {} local i = 1 @@ -652,12 +642,6 @@ 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 diff --git a/src/fibers/scope.lua b/src/fibers/scope.lua index 35d39e2..4dbb361 100644 --- a/src/fibers/scope.lua +++ b/src/fibers/scope.lua @@ -1,565 +1,807 @@ ---- --- Structured concurrency scopes. +-- fibers/scope.lua +-- +-- Stable core structured concurrency scopes that complement the Op layer. +-- +-- Guarantees: +-- * structural lifetime: attached children are joined +-- * admission gate: close() stops new spawn/child; join starts by closing admission +-- * downward cancellation: cancel() cascades to attached children +-- * fail-fast within a scope: first fault marks failed and cancels the scope +-- * join/finalisation is non-interruptible: runs in a join worker using op.perform_raw +-- * scope-aware ops: +-- - try(ev) -> 'ok'|'failed'|'cancelled', ... +-- - perform(ev) raises on failed/cancelled (using a cancellation sentinel) +-- * boundaries: +-- - join_op() -> status, primary, report +-- - run(fn, ...) -> status, value_or_primary, report (value is packed results table on ok) +-- - with_op(build_op) -> Op yielding status, value_or_primary, report +-- +-- Deliberate non-feature: +-- * no implicit upward propagation of child failure into parent failure. +-- Child outcomes are reported, not escalated. -- --- 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 oneshot = require 'fibers.oneshot' +local op = require 'fibers.op' local safe = require 'coxpcall' +-- Debug flag: when true, capture full tracebacks via xpcall handlers. +-- When false, use yield-safe pcall and keep errors compact. +local DEBUG = false + +local function set_debug(v) + DEBUG = not not v +end + 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" ----@alias ScopeFinaliser fun(aborted: boolean, status: ScopeStatus, primary_err: any) +---------------------------------------------------------------------- +-- Cancellation sentinel (robust, non-colliding) +---------------------------------------------------------------------- + +local CANCEL_MT = {} +CANCEL_MT.__name = 'fibers.cancelled' + +---@class Cancelled +---@field reason any + +---@param reason any +---@return Cancelled +local function cancelled(reason) + return setmetatable({ reason = reason }, CANCEL_MT) +end + +---@param err any +---@return boolean +local function is_cancelled(err) + return type(err) == 'table' and getmetatable(err) == CANCEL_MT +end + +---@param err any +---@return any|nil +local function cancel_reason(err) + return is_cancelled(err) and err.reason or nil +end + +local function raise_any(err) + -- Raise the value verbatim so cancellation sentinels (tables/userdata) + -- remain distinguishable. tb_handler() will stringify non-cancellation + -- errors for reporting. + error(err, 0) +end + +---@param fn fun(): any +---@param handler fun(e:any): any +---@return boolean ok, any err +local function protected_call(fn, handler) + if DEBUG then + -- Full diagnostics path + return safe.xpcall(fn, handler) + else + -- Compact path (yield-safe) + return safe.pcall(fn) + end +end + +-- Preserve cancellation sentinel; otherwise return traceback string. +local function tb_handler(e) + if is_cancelled(e) then return e end + return debug.traceback(tostring(e), 2) +end + +-- Join must not be interruptible by cancellation: render cancellation as a traceback. +local function join_tb_handler(e) + if is_cancelled(e) then + return debug.traceback('join raised cancellation: ' .. tostring(cancel_reason(e)), 2) + end + return debug.traceback(tostring(e), 2) +end + +-- Finalisers preserve cancellation sentinel so we can treat it explicitly. +local function finaliser_tb_handler(e) + if is_cancelled(e) then return e end + return debug.traceback(tostring(e), 2) +end + +---------------------------------------------------------------------- +-- Types / state +---------------------------------------------------------------------- + +---@class ScopeChildOutcome +---@field id integer +---@field status '"ok"'|'"failed"'|'"cancelled"' +---@field primary any +---@field report ScopeReport + +---@class ScopeReport +---@field id integer +---@field extra_errors any[] +---@field children ScopeChildOutcome[] + +---@class ScopeJoinOutcome +---@field st '"ok"'|'"failed"'|'"cancelled"' +---@field primary any +---@field report ScopeReport ---- Supervision scope for structured concurrency. ---@class Scope +---@field _id integer ---@field _parent Scope|nil ----@field _children table # weak-key set of child scopes ----@field _status ScopeStatus ----@field _error any ----@field _extra_errors any[] ----@field failure_mode string # e.g. "fail_fast" +---@field _children table +---@field _order Scope[] ---@field _wg Waitgroup ----@field _finalisers ScopeFinaliser[] # LIFO finalisers; called as f(aborted, status, primary_err) ----@field _cancel_cond Cond ----@field _join_cond Cond ----@field _join_finalising boolean # true while some joiner is running finalisation ----@field _join_finalised boolean # true once finalisation has completed (join_cond signalled) ----@field _result table|nil # used by run() +---@field _closed boolean +---@field _close_reason any|nil +---@field _close_os Oneshot +---@field _cancelled boolean +---@field _cancel_reason any|nil +---@field _cancel_os Oneshot +---@field _primary_error any|nil +---@field _extra_errors any[] +---@field _fault_os Oneshot +---@field _finalisers function[] +---@field _join_started boolean +---@field _join_outcome ScopeJoinOutcome|nil +---@field _join_os Oneshot local Scope = {} Scope.__index = Scope --- Weak-keyed table mapping Fiber objects to their current Scope. ----@type table +-- Weak-key map: Fiber -> Scope for attribution of uncaught runtime fibre errors. local fiber_scopes = setmetatable({}, { __mode = 'k' }) --- Process-wide root scope and “current scope” when not in a fiber. ----@type Scope|nil -local root_scope ----@type Scope|nil -local global_scope +local root_scope, global_scope +local next_id = 0 + +local function current_fiber() + return runtime.current_fiber() +end + +---------------------------------------------------------------------- +-- Unscoped error handling +---------------------------------------------------------------------- --- 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 - io.stderr:write('Unscoped fiber error before root initialised: ' .. tostring(err) .. '\n') - end + io.stderr:write('Unscoped fibre error: ' .. tostring(err) .. '\n') 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) +---@param handler fun(fib:any, err:any) 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 +-- Current scope install/restore ---------------------------------------------------------------------- ---- Internal: current fiber object, or nil if not in a fiber. ----@return Fiber|nil -local function current_fiber() - return runtime.current_fiber() +local function install_current_scope(s) + local fib = current_fiber() + if fib then + local prev = fiber_scopes[fib] + fiber_scopes[fib] = s + return fib, prev + end + local prev = global_scope or root_scope or s + global_scope = s + return nil, prev +end + +local function restore_current_scope(fib, prev) + if fib then + fiber_scopes[fib] = prev + else + global_scope = prev + end +end + +---------------------------------------------------------------------- +-- Helpers +---------------------------------------------------------------------- + +local function copy_array(t) + local out = {} + for i = 1, #t do out[i] = t[i] end + return out end ---- Internal: create a new Scope with the given parent. ----@param parent Scope|nil ----@return Scope +local function snapshot_children_set(self) + local snap = {} + for ch in pairs(self._children) do snap[#snap + 1] = ch end + return snap +end + +local function oneshot_status_op(is_ready, os, get_st_v) + return op.new_primitive( + nil, + function () + if is_ready() then + local st, v = get_st_v() + return true, st, v + end + return false + end, + function (suspension, wrap_fn) + local cancel = os:add_waiter(function () + if suspension:waiting() then + local st, v = get_st_v() + suspension:complete(wrap_fn, st, v) + end + end) + suspension:add_cleanup(cancel) + end + ) +end + +local function terminal_status(self) + -- Failure takes precedence over cancellation. + if self._primary_error ~= nil then return 'failed', self._primary_error end + if self._cancelled then return 'cancelled', self._cancel_reason end + return 'ok', nil +end + +local function make_report(self, child_outcomes) + return { + id = self._id, + extra_errors = copy_array(self._extra_errors), + children = child_outcomes or {}, + } +end + +local function should_reject_admission(self) + return self._closed + or self._cancelled + or self._primary_error ~= nil + or self._join_started + or self._join_outcome ~= nil +end + +---------------------------------------------------------------------- +-- Observational status (non-blocking snapshot) +---------------------------------------------------------------------- + +--- Return a snapshot of this scope's current status. +--- +--- This is intentionally observational: it does not synchronise or wait. +--- For waiting, use join_op()/cancel_op()/fault_op()/not_ok_op(). +--- +--- Returns: +--- 'running', nil +--- 'failed', primary_error +--- 'cancelled', reason +--- 'ok', nil (only once join has completed successfully) +---@return string st +---@return any v +function Scope:status() + -- If join has completed, return the terminal state captured there. + local out = self._join_outcome + if out then return out.st, out.primary end + + -- Live view (pre-join). + if self._primary_error ~= nil then return 'failed', self._primary_error end + if self._cancelled then return 'cancelled', self._cancel_reason end + return 'running', nil +end + +--- The admission gate is deliberately not part of status(), because +--- "closed" is not a terminal outcome; it is simply "not admitting more work". +--- +--- Returns: +--- 'open', nil +--- 'closed', reason|nil +---@return string st +---@return any reason +function Scope:admission() + if self._closed then return 'closed', self._close_reason end + return 'open', nil +end + +local function admission_error(self) + -- Keep this intentionally simple and non-taxonomic. + if self._join_outcome ~= nil or self._join_started then return 'scope is joining' end + if self._primary_error ~= nil then return 'scope has failed' end + if self._cancelled then return 'scope is cancelled' end + if self._closed then return 'scope is closed' end + return 'scope is not admitting work' +end + +---------------------------------------------------------------------- +-- Construction / root / current +---------------------------------------------------------------------- + local function new_scope(parent) + next_id = next_id + 1 + local s = setmetatable({ + _id = next_id, _parent = parent, - _children = setmetatable({}, { __mode = 'k' }), + _children = {}, + _order = {}, + _wg = waitgroup.new(), - _status = 'running', - _error = nil, - _extra_errors = {}, - failure_mode = 'fail_fast', + _closed = false, + _close_os = oneshot.new(), + + _cancelled = false, + _cancel_os = oneshot.new(), - _wg = waitgroup.new(), - _finalisers = {}, + _extra_errors = {}, + _fault_os = oneshot.new(), - _cancel_cond = cond.new(), - _join_cond = cond.new(), - _join_finalising = false, - _join_finalised = false, + _finalisers = {}, + _join_started = false, + _join_os = oneshot.new(), }, Scope) if parent then parent._children[s] = true + parent._order[#parent._order + 1] = s + + -- Downward cancellation propagates immediately to new children. + if parent._cancelled then s:cancel(parent._cancel_reason) end end 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 root_scope then return root_scope end + root_scope = new_scope(nil) + global_scope = root_scope + + -- Error pump: route uncaught runtime errors to the owning scope when possible. + runtime.spawn_raw(function () + while true do + local fib, err = runtime.wait_fiber_error() + -- Ignore cancellation sentinels. + if not is_cancelled(err) then + local s = fiber_scopes[fib] if s then - s:_record_failure(err) + s:_record_fault(err, { tag = 'unhandled_fibre_error' }) else unscoped_error_handler(fib, err) end end - end) - end + end + end) + return root_scope end ---- Return the current Scope. ---- 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 - return fiber_scopes[fib] or root() - end + if fib then return fiber_scopes[fib] or root() end return global_scope or root() end ---------------------------------------------------------------------- --- Scope methods: lifecycle and failure +-- Child management (attachment) ---------------------------------------------------------------------- ---- 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) +function Scope:_remove_child(child) + if child._parent ~= self then return end + self._children[child] = nil + + local ord = self._order + for i = #ord, 1, -1 do + if ord[i] == child then + table.remove(ord, i) + break + end 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) + child._parent = nil end ---- 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 - finalisers[#finalisers + 1] = handler +function Scope:_detach_from_parent() + local p = self._parent + if p then p:_remove_child(self) end end ----@param reason any|nil -function Scope:_propagate_cancel(reason) - local r = reason or self._error or 'scope cancelled' +---@return Scope|nil child, any|nil err +function Scope:child() + if should_reject_admission(self) then return nil, admission_error(self) end - self._cancel_cond:signal() + return new_scope(self), nil +end - local children = self._children - for child in pairs(children) do - if child then - child:cancel(r) - end +---------------------------------------------------------------------- +-- Admission gate (close) +---------------------------------------------------------------------- + +---@param reason any|nil +function Scope:close(reason) + if self._join_outcome ~= nil then return end + + if not self._closed then + self._closed = true + self._close_reason = (reason ~= nil) and reason or self._close_reason + self._close_os:signal() + elseif self._close_reason == nil and reason ~= nil then + self._close_reason = reason end end ---- Cancel this scope and its children with an optional reason. ---- Idempotent; subsequent calls after terminal success are ignored. +---@return Op -- yields: 'closed', reason +function Scope:close_op() + return oneshot_status_op( + function () return self._closed end, + self._close_os, + function () return 'closed', self._close_reason end + ) +end + +---------------------------------------------------------------------- +-- Cancellation / faults +---------------------------------------------------------------------- + ---@param reason any|nil function Scope:cancel(reason) - if self._status == 'ok' then return end + if self._join_outcome ~= nil then return end - local r = reason or self._error or 'scope cancelled' + -- Cancellation implies admission is closed (useful for accept loops and similar). + self:close(reason) - if self._status == 'running' then - self._status = 'cancelled' - if self._error == nil then - self._error = r - end - self:_propagate_cancel(r) + if not self._cancelled then + self._cancelled = true + self._cancel_reason = (reason ~= nil) and reason or (self._cancel_reason or 'scope cancelled') + self._cancel_os:signal() + elseif self._cancel_reason == nil and reason ~= nil then + self._cancel_reason = reason + end + + -- Cancel attached children (snapshot avoids mutation hazards). + local snap = snapshot_children_set(self) + for i = 1, #snap do snap[i]:cancel(self._cancel_reason) end +end + +-- Record a fault in this scope. Cancellation escaping from bodies is control flow. +---@param err any +---@param _? table +function Scope:_record_fault(err, _) + if is_cancelled(err) then + self:cancel(cancel_reason(err)) + return + end + + local e = err + if type(e) ~= 'string' and type(e) ~= 'number' then + e = tostring(e) end + + if self._primary_error == nil then + self._primary_error = e + self._fault_os:signal() + -- Fail-fast: cancel the scope to stop sibling work. + self:cancel(e) + else + self._extra_errors[#self._extra_errors + 1] = e + end +end + +---@return Op -- yields: 'cancelled', reason +function Scope:cancel_op() + return oneshot_status_op( + function () return self._cancelled end, + self._cancel_os, + function () return 'cancelled', self._cancel_reason end + ) +end + +---@return Op -- yields: 'failed', primary +function Scope:fault_op() + return oneshot_status_op( + function () return self._primary_error ~= nil end, + self._fault_os, + function () return 'failed', self._primary_error end + ) +end + +---@return Op -- yields: 'failed', primary | 'cancelled', reason +function Scope:not_ok_op() + -- Re-check on wake so failure wins even if cancellation also became ready. + return op.choice(self:fault_op(), self:cancel_op()):wrap(function () + if self._primary_error ~= nil then return 'failed', self._primary_error end + return 'cancelled', self._cancel_reason + end) end ---- 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 +---------------------------------------------------------------------- +-- Finalisers +---------------------------------------------------------------------- + +---@param f fun(aborted:boolean, status:string, primary:any|nil) +function Scope:finally(f) + assert(type(f) == 'function', 'scope:finally expects a function') + self._finalisers[#self._finalisers + 1] = f +end + +---------------------------------------------------------------------- +-- Spawning (attached obligations) +---------------------------------------------------------------------- + +---@param fn fun(s:Scope, ...): any ---@param ... any +---@return boolean ok, any|nil err function Scope:spawn(fn, ...) - assert(self._status == 'running', 'cannot spawn on a non-running scope') + if should_reject_admission(self) then return false, admission_error(self) end + local args = pack(...) self._wg:add(1) runtime.spawn_raw(function () - local fib = current_fiber() + 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 = protected_call(function () return fn(self, unpack(args, 1, args.n)) end, tb_handler) - 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 not ok then self:_record_fault(err, { tag = 'fibre_failed' }) end + if fib then fiber_scopes[fib] = prev 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 -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 -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 -end - ---- Return a shallow copy of non-primary finaliser failures recorded on this scope. ----@return any[] -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 + return true, nil end ---------------------------------------------------------------------- --- Join and done ops +-- Join (non-interruptible finalisation) ---------------------------------------------------------------------- ---- Internal: run join finalisation exactly once, then signal _join_cond. ---- This runs in the calling fiber (a joiner) with this scope installed as current(). -function Scope:_finalise_join_once() - if self._join_finalised then - return - end +function Scope:_finalise_join_body() + -- Joining closes admission (but does not imply cancellation). + self:close('joining') - -- Install this scope as current for the duration of finalisation. - local fib = current_fiber() - local prev = fib and fiber_scopes[fib] - if fib then - fiber_scopes[fib] = self - end + -- Snapshot children in attachment order. + local children = copy_array(self._order) + local child_outcomes = {} - -- Drain children first (idempotent; wait_op is immediate once drained). + -- Drain spawned fibres. op.perform_raw(self._wg:wait_op()) - -- If still running after children complete, mark success. - if self._status == 'running' then - self._status = 'ok' - self._error = nil + -- Join children in attachment order. + for i = 1, #children do + local ch = children[i] + if ch and ch._parent == self then + local st, primary, rep = op.perform_raw(ch:join_op()) + child_outcomes[#child_outcomes + 1] = { + id = ch._id, + status = st, + primary = primary, + report = rep, + } + self:_remove_child(ch) + end end - local status = self._status - local primary_err = self._error - local aborted = (status ~= 'ok') + -- Run finalisers (LIFO). Any finaliser error becomes a fault. + local st, primary = terminal_status(self) + local aborted = (st ~= 'ok') + + local fs = self._finalisers + for i = #fs, 1, -1 do + local f = fs[i] + fs[i] = nil - -- Run finalisers LIFO; record failures as before. - local finalisers = self._finalisers - for i = #finalisers, 1, -1 do - local f = finalisers[i] - finalisers[i] = nil + local ok, err = protected_call(function () + return f(aborted, st, (st == 'failed') and primary or nil) + end, finaliser_tb_handler) - 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 + if is_cancelled(err) then + self:_record_fault('finaliser raised cancellation: ' .. tostring(cancel_reason(err)), + { tag = 'finaliser_cancelled' }) else - local failures = self._extra_errors - failures[#failures + 1] = err + self:_record_fault(err, { tag = 'finaliser_failed' }) end + st, primary = terminal_status(self) + aborted = (st ~= 'ok') end end - if fib then - fiber_scopes[fib] = prev - end - - self._join_finalised = true - self._join_cond:signal() + return child_outcomes end ---- Op that fires once the scope has reached a terminal status. ---- Returns (status, error) when performed. ----@return Op -function Scope:join_op() - return op.guard(function () - -- Fast path: already finalised. - if self._join_finalised then - return op.always(self._status, self._error) - end +function Scope:_start_join_worker() + if self._join_started then return end + self._join_started = true - -- Wait for children to drain, then finalise (or wait for another joiner). - return self._wg:wait_op():wrap(function () - -- Another joiner may have completed finalisation while we were waiting. - if self._join_finalised then - return self._status, self._error - end + runtime.spawn_raw(function () + local fib, prev = install_current_scope(self) - -- If a joiner is already running finalisation, wait for its signal. - if self._join_finalising then - op.perform_raw(self._join_cond:wait_op()) - return self._status, self._error - end + local child_outcomes + local ok, err = protected_call(function () + child_outcomes = self:_finalise_join_body() + end, join_tb_handler) - -- Become the finalising joiner. - self._join_finalising = true - -- Best-effort: ensure flags are consistent even if a bug escapes safe.pcall. - local ok, err = safe.pcall(function () - self:_finalise_join_once() - end) - if not ok then - -- Treat an unexpected failure in join finalisation as a scope failure. - if self._status == 'ok' or self._status == 'running' then - self._status = 'failed' - self._error = self._error or err - else - self._extra_errors[#self._extra_errors + 1] = err - end - -- Ensure joiners do not block forever. - self._join_finalised = true - self._join_cond:signal() - end - self._join_finalising = false + restore_current_scope(fib, prev) - return self._status, self._error - end) + if not ok then + self:_record_fault(err, { tag = 'join_failed' }) + end + + local st, primary = terminal_status(self) + local rep = make_report(self, child_outcomes or {}) + + self._join_outcome = { st = st, primary = primary, report = rep } + self._join_os:signal() + + -- Avoid retaining completed children in long-lived parents. + self:_detach_from_parent() 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) +---@return Op -- yields: status, primary, report +function Scope:join_op() + return op.new_primitive( + nil, + function () + local out = self._join_outcome + if out then + return true, out.st, out.primary, out.report + end + return false + end, + function (suspension, wrap_fn) + local cancel = self._join_os:add_waiter(function () + if suspension:waiting() then + local out = self._join_outcome + if out then + suspension:complete(wrap_fn, out.st, out.primary, out.report) + else + local st, primary = terminal_status(self) + suspension:complete(wrap_fn, st, primary, make_report(self, {})) + end + end + end) + suspension:add_cleanup(cancel) + self:_start_join_worker() + end + ) end ---------------------------------------------------------------------- --- Failure and cancellation wrapping for Ops +-- Scope-aware op performance (status-first) ---------------------------------------------------------------------- ---- Internal: build a cancellation op for this scope. ---- 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) - local ev = self._cancel_cond:wait_op() - return ev:wrap(function () - return false, self._error or 'scope cancelled', nil - end) +local function assert_op_value(ev) + if type(ev) ~= 'table' or getmetatable(ev) ~= op.Op then + error(('scope: expected op, got %s (%s)'):format(type(ev), tostring(ev)), 3) + end end ---- 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 +---@return Op -- yields: 'ok', ... | 'failed', primary | 'cancelled', reason function Scope:run_op(ev) + assert_op_value(ev) + return op.guard(function () - local this_cancel_op = cancel_op(self) - return op.choice(ev, this_cancel_op) + if self._primary_error ~= nil then return op.always('failed', self._primary_error) end + if self._cancelled then return op.always('cancelled', self._cancel_reason) end + + local body = ev:wrap(function (...) + if self._primary_error ~= nil then return 'failed', self._primary_error end + if self._cancelled then return 'cancelled', self._cancel_reason end + return 'ok', ... + end) + + return op.choice(body, self:not_ok_op()) end) end ---- Perform an op under this scope, obeying its cancellation rules. ---- 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)') - - 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))) - - 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 '"ok"'|'"failed"'|'"cancelled"', ... +function Scope:try(ev) + assert(runtime.current_fiber(), 'scope:try must be called from inside a fibre') + return op.perform_raw(self:run_op(ev)) 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 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 - - return unpack(results, 2, results.n) + local r = pack(self:try(ev)) + local st = r[1] + if st == 'ok' then return unpack(r, 2, r.n) end + if st == 'cancelled' then raise_any(cancelled(r[2])) end + raise_any(r[2] or 'scope failed') end ---------------------------------------------------------------------- --- Scope as an op: with_op +-- Boundaries ---------------------------------------------------------------------- ---- 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 +-- scope.run(body_fn, ...) -> (status, value_or_primary, report) +-- on ok: 'ok', packed_results, report +-- on not ok: st, primary, report +local function run(body_fn, ...) + assert(type(body_fn) == 'function', 'scope.run expects a function body') + assert(runtime.current_fiber(), 'scope.run must be called from inside a fibre') + + local parent = current() + local child, err = parent:child() + + -- Parent is not admitting; treat as cancelled boundary. + if not child then return 'cancelled', err, make_report(parent, {}) end + + local args = pack(...) + local fib, prev = install_current_scope(child) + + local ok, e + local results + + ok, e = protected_call(function () + results = pack(body_fn(child, unpack(args, 1, args.n))) + end, tb_handler) + + restore_current_scope(fib, prev) + + if not ok then child:_record_fault(e, { tag = 'run_body_failed' }) end + + local st, primary, rep = op.perform_raw(child:join_op()) + if st == 'ok' then return 'ok', (results or pack()), rep end + + return st, primary, rep +end + +-- scope.with_op(build_op) -> Op producing (status, value_or_primary, report) local function with_op(build_op) + assert(type(build_op) == 'function', 'scope.with_op expects a function') + return op.guard(function () local parent = current() - local child = new_scope(parent) + local child, err = parent:child() + if not child then return op.always('cancelled', err, make_report(parent, {})) end 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 + local fib, prev = install_current_scope(child) + return { fib = fib, prev = prev } 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 + restore_current_scope(token.fib, token.prev) - if aborted and child._status == 'running' then - child._status = 'cancelled' - child._error = child._error or 'scope aborted' - child:cancel(child._error) + if aborted then + -- Losing a choice is an external abort: cancel and join deterministically. + child:cancel('aborted') + safe.pcall(function () op.perform_raw(child:join_op()) end) end - - op.perform_raw(child:join_op()) end local function use() - return build_op(child) - end + local ok, body = protected_call(function () return build_op(child) end, tb_handler) - return op.bracket(acquire, release, use) - end) -end - ----------------------------------------------------------------------- --- scope.run: run a child scope in its own fiber ----------------------------------------------------------------------- - ---- 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") ---- 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[] 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(...) + if not ok then + child:_record_fault(body, { tag = 'with_build_failed' }) + return op.always('failed', child._primary_error or body) + end - child._result = nil + if type(body) ~= 'table' or getmetatable(body) ~= op.Op then + local msg = ('scope.with_op: build_op must return an Op (got %s)'):format(type(body)) + child:_record_fault(msg, { tag = 'with_build_not_op' }) + return op.always('failed', msg) + end - child:spawn(function (s) - local res = pack(body_fn(s, unpack(args, 1, args.n))) - s._result = res - end) + return child:run_op(body) + end - local status, err = op.perform_raw(child:join_op()) + return op.bracket(acquire, release, use):wrap(function (body_st, ...) + local body_vals = pack(...) - -- Shallow copy of non-primary finaliser failures; will be {} when there are none. - local extra = child:extra_errors() + local join_st, join_primary, rep = op.perform_raw(child:join_op()) - 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 + if join_st ~= 'ok' then return join_st, join_primary, rep end + if body_st == 'ok' then return 'ok', body_vals, rep end + return body_st, body_vals[1], rep + end) + end) end ---------------------------------------------------------------------- @@ -569,9 +811,15 @@ end return { root = root, current = current, + Scope = Scope, + run = run, with_op = with_op, - Scope = Scope, + + cancelled = cancelled, + is_cancelled = is_cancelled, + cancel_reason = cancel_reason, set_unscoped_error_handler = set_unscoped_error_handler, + set_debug = set_debug } diff --git a/tests/test_scope.lua b/tests/test_scope.lua index 3c2b288..311df04 100644 --- a/tests/test_scope.lua +++ b/tests/test_scope.lua @@ -1,645 +1,587 @@ ---- Tests the Scope implementation. -print('test: fibers.scope') +--- Tests the Scope implementation (updated for new fibers.scope). +print('test: fibers.scope (new)') -- look one level up package.path = '../src/?.lua;' .. package.path local runtime = require 'fibers.runtime' -local scope = require 'fibers.scope' +local scope_mod = require 'fibers.scope' local op = require 'fibers.op' local performer = require 'fibers.performer' local cond_mod = require 'fibers.cond' ------------------------------------------------------------------------------- --- 1. Structural tests +-- Helpers ------------------------------------------------------------------------------- -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') +local unpack = rawget(table, 'unpack') or _G.unpack - -- 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) +local function unpack_packed(p) + assert(type(p) == 'table', 'expected packed table') + local n = p.n or #p + return unpack(p, 1, n) +end - assert(st == 'ok' and err == nil, 'outer scope.run should complete with status ok') +local function assert_contains(hay, needle, msg) + assert(type(hay) == 'string', 'assert_contains expects string haystack') + assert(hay:find(needle, 1, true), msg or ('expected to find ' .. tostring(needle))) +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') +------------------------------------------------------------------------------- +-- 0. Outside-fibre current() snapshot +------------------------------------------------------------------------------- - -- 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') +do + local r = scope_mod.root() + assert(scope_mod.current() == r, 'outside fibres, current() should be root/global scope') end -local function test_inside_fibers() - local root = scope.root() +------------------------------------------------------------------------------- +-- 1. Current scope installation and restoration (run / nested run) +------------------------------------------------------------------------------- - local child_in_fiber - local grandchild_in_fiber +local function test_current_scope_run_restores() + local root = scope_mod.root() + assert(scope_mod.current() == root, 'test harness fibre should see current() == root') - -- Use a cond to wait for the spawned fiber to finish. - local done = cond_mod.new() + local outer_ref, inner_ref - -- 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') + local st, val, rep = scope_mod.run(function (s) + outer_ref = s + assert(scope_mod.current() == s, 'inside scope.run, current() should be the child scope') - -- 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') + local st2, val2, rep2 = scope_mod.run(function (s2) + inner_ref = s2 + assert(scope_mod.current() == s2, 'inside nested scope.run, current() should be nested child') + return 1, 2, 3 + end) - -- 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', 'nested scope.run should succeed') + local a, b, c = unpack_packed(val2) + assert(a == 1 and b == 2 and c == 3, 'nested scope.run should return packed body results') + assert(type(rep2) == 'table' and rep2.id ~= nil, 'nested scope.run should return a report') - assert(st2 == 'ok' and err2 == nil, - 'nested scope.run in fiber should complete with status ok') + assert(scope_mod.current() == s, 'after nested run, current() should restore to outer child scope') - -- After nested run, current() should be back to child - assert(scope.current() == child, 'after nested run in fiber, current() should be child again') - end) + return 'outer-result' + end) - assert(st == 'ok' and err == nil, - 'scope.run in fiber should complete with status ok') + assert(st == 'ok', 'outer scope.run should succeed') + assert(type(rep) == 'table' and rep.id ~= nil, 'outer scope.run should return a report') + local r1 = unpack_packed(val) + assert(r1 == 'outer-result', 'outer scope.run should return packed body results') - -- 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(scope_mod.current() == root, 'after scope.run returns, current() should restore to root in this fibre') - done:signal() - end) + assert(outer_ref ~= nil and inner_ref ~= nil, 'outer/inner scopes should have been captured') + assert(outer_ref ~= inner_ref, 'outer and inner scopes must differ') - -- 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 + -- join_op should be idempotent and immediate after run has joined the scope + do + local jst, jprimary, jrep = op.perform_raw(outer_ref:join_op()) + assert(jst == 'ok' and jprimary == nil, 'join_op on already-joined ok scope should be ok') + assert(jrep and jrep.id == rep.id, 'join_op report id should match') end - assert(found_child, 'root:children() should contain child_in_fiber') end ------------------------------------------------------------------------------- --- 1b. basic scope.with_op behaviour +-- 2. Structural lifetime: attached children are joined by parent join ------------------------------------------------------------------------------- -local function test_with_op_basic() - local parent = scope.current() - local child_scope +local function test_structural_lifetime_attached_children_joined() + local child1_ref, child2_ref + local child1_done, child2_done = false, false - 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') + local st, val_or_primary, rep = scope_mod.run(function (s) + local c1, err1 = s:child() + assert(c1, 'expected child1, got err: ' .. tostring(err1)) + child1_ref = c1 - -- simple op that returns two values - return op.always(true):wrap(function () - return 99, 'ok' + local c2, err2 = s:child() + assert(c2, 'expected child2, got err: ' .. tostring(err2)) + child2_ref = c2 + + local ok1, se1 = c1:spawn(function (_) + runtime.yield() + child1_done = true end) + assert(ok1 and se1 == nil, 'child1:spawn should be admitted') + + local ok2, se2 = c2:spawn(function (_) + runtime.yield() + child2_done = true + end) + assert(ok2 and se2 == nil, 'child2:spawn should be admitted') + + return 'parent-ok' end) - local a, b = performer.perform(ev) - assert(a == 99 and b == 'ok', 'with_op should propagate child op results') + assert(st == 'ok', 'parent scope should be ok') + assert(unpack_packed(val_or_primary) == 'parent-ok', 'parent run should return packed results') + assert(type(rep) == 'table' and type(rep.children) == 'table', 'report should include children array') + + assert(child1_done and child2_done, 'parent join should have waited for attached children work') - -- 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(#rep.children == 2, 'expected two child outcomes in report') + assert(rep.children[1].id == child1_ref._id, 'first child outcome should correspond to first attachment') + assert(rep.children[2].id == child2_ref._id, 'second child outcome should correspond to second attachment') + assert(rep.children[1].status == 'ok', 'child1 should end ok') + assert(rep.children[2].status == 'ok', 'child2 should end ok') - 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') + -- Joined child scopes should not admit new work + do + local ch, err = child1_ref:child() + assert(ch == nil and err ~= nil, 'joined child scope should not admit new child scopes') + end 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 +------------------------------------------------------------------------------- +-- 3. Admission gate: close() and cancel() stop new spawn/child +------------------------------------------------------------------------------- - local st, serr = scope.run(function (s) - outer_scope = s +local function test_admission_close_and_cancel() + local st, val_or_primary, rep = scope_mod.run(function (s) + local ast, _ = s:admission() + assert(ast == 'open', 'new scope admission should be open') - 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') + s:close() + s:close('closed-later') - error('with_op builder failure') - end) + local ast2, areason2 = s:admission() + assert(ast2 == 'closed' and areason2 == 'closed-later', 'close() should close admission and record reason') - -- The error above is caught by the Scope:spawn wrapper for this body fiber. - performer.perform(ev) + local ok, err = s:spawn(function () end) + assert(ok == false and err ~= nil, 'spawn should be rejected when scope is closed') + assert_contains(tostring(err), 'scope is closed', 'close admission error should mention closed') - -- Not reached. - end) + local child, cerr = s:child() + assert(child == nil and cerr ~= nil, 'child() should be rejected when scope is closed') + assert_contains(tostring(cerr), 'scope is closed', 'child admission error should mention closed') - -- 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') + local cst, creason = op.perform_raw(s:close_op()) + assert(cst == 'closed' and creason == 'closed-later', 'close_op should yield closed + reason') - assert(outer_scope ~= nil, 'outer_scope should have been set') - assert(child_scope ~= nil, 'with_op failure test should have created child scope') + return 'ok' + end) - 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 + assert(st == 'ok', 'scope.run should succeed') + assert(unpack_packed(val_or_primary) == 'ok', 'body result should be returned') + assert(rep and rep.id ~= nil, 'report should be present') --- 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 st2, primary2 = scope_mod.run(function (s) + s:cancel('bye') + end) + assert(st2 == 'cancelled' and primary2 == 'bye', 'explicit cancel should yield cancelled + reason') +end - local st, serr, _, winner = scope.run(function (s) - outer_scope = s +------------------------------------------------------------------------------- +-- 4. scope.run boundary when parent is not admitting work +------------------------------------------------------------------------------- - 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') +local function test_run_when_parent_closed_is_cancelled_boundary() + local st, val_or_primary = scope_mod.run(function (s) + s:close('no more children') - -- This arm never becomes ready; it will lose the choice. - return op.never() + local st2, v2, rep2 = scope_mod.run(function (_) + return 1 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") + assert(st2 == 'cancelled', 'nested run should be cancelled when parent is closed') + assert_contains(tostring(v2), 'scope is closed', 'nested run should surface admission error') + assert(rep2 and rep2.id == s._id, 'nested run report should be based on parent scope') + + return 'parent-ok' + end) - -- After the choice, current() should be restored. - assert(scope.current() == s, - 'after with_op choice, current() should be restored to outer scope') + assert(st == 'ok', 'outer run should remain ok') + assert(unpack_packed(val_or_primary) == 'parent-ok', 'outer body should complete') +end - return res +------------------------------------------------------------------------------- +-- 5. Cancellation sentinel and cancellation-as-control-flow +------------------------------------------------------------------------------- + +local function test_cancellation_sentinel_and_non_failure() + -- (A) cancellation escaping a fibre should not mark failure + local st, primary = scope_mod.run(function (s) + local c = cond_mod.new() + + local ok, err = s:spawn(function (_) + s:perform(c:wait_op()) + end) + assert(ok and err == nil, 'spawn should be admitted') + + runtime.yield() + s:cancel('bye') 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(st == 'cancelled' and primary == 'bye', + 'cancellation escaping a fibre should yield cancelled scope, not failed') - assert(outer_scope ~= nil, 'outer_scope should have been set') - assert(child_scope ~= nil, 'with_op choice test should have created child scope') + -- (B) perform should raise a distinguishable cancellation sentinel + local st2, primary2 = scope_mod.run(function (s) + local c = cond_mod.new() - 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 + s:spawn(function (_) + runtime.yield() + s:cancel('cancel-reason') + 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 ok, err = pcall(function () + s:perform(c:wait_op()) + end) - local st, serr = scope.run(function (s) - outer_scope = s + assert(ok == false, 'perform should raise on cancellation') + assert(scope_mod.is_cancelled(err), 'raised value should be a cancellation sentinel') + assert(scope_mod.cancel_reason(err) == 'cancel-reason', 'cancellation sentinel should carry reason') + end) - 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') + assert(st2 == 'cancelled' and primary2 == 'cancel-reason', 'scope should be cancelled with the correct reason') +end - -- A condition used only to keep one child fiber blocked. - local c = cond_mod.new() +------------------------------------------------------------------------------- +-- 6. Fail-fast on first fault: siblings observe failure (not cancellation) +------------------------------------------------------------------------------- - -- Failing child fiber under the with_op scope. - child:spawn(function (_) - error('with_op child fiber failure') - end) +local function test_fail_fast_and_siblings_observe_failure() + local observed_st, observed_primary - -- 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) + local st, primary, rep = scope_mod.run(function (s) + local c = cond_mod.new() - -- The main op for with_op completes successfully. - return op.always('ok') + -- Sibling that will be interrupted by fail-fast. + s:spawn(function (_) + observed_st, observed_primary = s:try(c:wait_op()) end) - local res = performer.perform(ev) - assert(res == 'ok', 'with_op main op should still return its result') + -- Failing sibling. + s:spawn(function (_) + error('boom') + end) - -- At this point, with_op's release will have waited for the child scope - -- to close, including both spawned child fibers and finalisers. + -- Give the siblings a chance to start. + runtime.yield() end) - assert(st == 'ok' and serr == nil, - 'outer scope.run should remain ok after with_op child-fiber failure') + assert(st == 'failed', 'scope should fail on first fault') + assert_contains(tostring(primary), 'boom', 'primary fault should mention the failing error') - assert(outer_scope ~= nil, 'outer_scope should have been set') - assert(child_scope ~= nil, 'with_op child-fiber test should have created child scope') + assert(observed_st == 'failed', 'blocked sibling should observe failed (not cancelled) when a fault occurs') + assert_contains(tostring(observed_primary), 'boom', + 'blocked sibling failure should reflect the primary fault') - 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') + -- No extra errors should be recorded (the blocked sibling returned normally). + assert(rep and type(rep.extra_errors) == 'table', 'report should include extra_errors') + assert(#rep.extra_errors == 0, 'extra_errors should be empty when siblings exit without additional faults') end ------------------------------------------------------------------------------- --- 2. Status transitions for scope.run (success, failure, cancellation) +-- 7. cancel_op / fault_op / not_ok_op behaviour and precedence ------------------------------------------------------------------------------- -local function test_run_success_and_failure() - local root = scope.root() +local function test_not_ok_ops() + local st1, primary1 = scope_mod.run(function (s) + s:spawn(function (_) + runtime.yield() + s:cancel('cancelled-here') + end) - -- 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' + local ost, oval = op.perform_raw(s:cancel_op()) + assert(ost == 'cancelled' and oval == 'cancelled-here', 'cancel_op should yield cancelled + reason') end) + assert(st1 == 'cancelled' and primary1 == 'cancelled-here', 'scope should be cancelled') - 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') + local st2, primary2 = scope_mod.run(function (s) + s:spawn(function (_) + runtime.yield() + error('fault-here') + end) - -- Failure case: body error becomes scope failure; scope.run does not throw. - local st_fail, err_fail = scope.run(function () - error('body failure') + local ost, oval = op.perform_raw(s:fault_op()) + assert(ost == 'failed', 'fault_op should yield failed') + assert_contains(tostring(oval), 'fault-here', 'fault_op primary should mention the fault') end) + assert(st2 == 'failed', 'scope should be failed on fault') + assert_contains(tostring(primary2), 'fault-here', 'scope primary should mention the fault') - 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 st3, primary3 = scope_mod.run(function (s) + s:spawn(function (_) + runtime.yield() + error('precedence-fault') + 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') + local ost, oval = op.perform_raw(s:not_ok_op()) + assert(ost == 'failed', 'not_ok_op should prefer failed if a fault occurs') + assert_contains(tostring(oval), 'precedence-fault', 'not_ok_op should carry the primary fault') 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') + assert(st3 == 'failed', 'scope should be failed in precedence test') + assert_contains(tostring(primary3), 'precedence-fault', 'scope primary should mention precedence-fault') end ------------------------------------------------------------------------------- --- 3. Defers: LIFO ordering and execution on failure +-- 8. Finalisers: LIFO order; cancellation sentinel in finaliser becomes fault ------------------------------------------------------------------------------- -local function test_finalisers_lifo_and_failure() +local function test_finalisers_lifo_and_cancel_in_finaliser() local order = {} - local scope_ref - local st, serr = scope.run(function (s) - scope_ref = s + local st, val_or_primary = scope_mod.run(function (s) s:finally(function () table.insert(order, 'first') end) s:finally(function () table.insert(order, 'second') end) - error('boom in body') + return 'ok' 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') + assert(st == 'ok', 'scope should be ok when body and finalisers succeed') + assert(unpack_packed(val_or_primary) == 'ok', 'body result should be returned on ok') + assert(#order == 2 and order[1] == 'second' and order[2] == 'first', 'finalisers should run LIFO') - 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') + local st2, primary2 = scope_mod.run(function (s) + s:finally(function () + error(scope_mod.cancelled('finaliser-cancel')) + end) + return 'ok' + end) - 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') + assert(st2 == 'failed', 'cancellation sentinel from finaliser should fail the scope') + assert_contains(tostring(primary2), 'finaliser raised cancellation', 'primary should mention finaliser cancellation') + assert_contains(tostring(primary2), 'finaliser-cancel', 'primary should include the cancellation reason') 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 +------------------------------------------------------------------------------- +-- 9. Reports: children outcomes and extra_errors +------------------------------------------------------------------------------- + +local function test_reports_children_and_extra_errors() + local st, primary, rep = scope_mod.run(function (s) + local ch, err = s:child() + assert(ch, 'expected child scope, got: ' .. tostring(err)) - local st, serr, _, body_res = scope.run(function (s) - scope_ref = s - s:finally(function () - error('finaliser failure') + ch:spawn(function (_) + error('child-fault') end) - return 'body-result' + + -- LIFO: finaliser-2 runs first and becomes primary, finaliser-1 becomes extra. + s:finally(function () error('finaliser-1') end) + s:finally(function () error('finaliser-2') end) + + return 'body-ok' 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') + assert(st == 'failed', 'finaliser failure should fail the scope') + assert(rep and rep.id ~= nil and type(rep.children) == 'table', 'report should be present') + assert(#rep.children == 1, 'report should include one child outcome') - 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_contains(tostring(rep.children[1].primary), 'child-fault', + 'child outcome primary should mention child fault') - assert(body_res == 'body-result', - 'scope.run should still return body results even if finalisers fail') + assert_contains(tostring(primary), 'finaliser-2', 'primary should come from the first failing finaliser (LIFO)') + + assert(type(rep.extra_errors) == 'table', 'report.extra_errors should be a table') + assert(#rep.extra_errors >= 1, 'expected at least one extra error') + local joined = table.concat(rep.extra_errors, '\n') + assert_contains(joined, 'finaliser-1', 'extra_errors should include the later finaliser failure') end ------------------------------------------------------------------------------- --- 4. Scope:sync via performer.perform: failure and cancellation paths +-- 10. Join closes admission: once join starts, spawn/child are rejected ------------------------------------------------------------------------------- -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 function test_join_closes_admission_and_rejects_new_work() + local st, val_or_primary = scope_mod.run(function (s) + local child, err = s:child() + assert(child, 'expected child scope, got: ' .. tostring(err)) - 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) + local blocker = cond_mod.new() + local join_started = cond_mod.new() - 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') + -- Keep the child scope busy so join has something to wait for. + child:spawn(function (_) + child:perform(blocker:wait_op()) + end) - 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 + -- Start joining the child from a sibling fibre in the parent scope. + s:spawn(function (_) + join_started:signal() + op.perform_raw(child:join_op()) + 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) + -- Wait until join has started (this is enough: join worker closes admission immediately). + performer.perform(join_started:wait_op()) + runtime.yield() + + local ok1, e1 = child:spawn(function () end) + assert(ok1 == false and e1 ~= nil, 'spawn should be rejected once join has started') + assert_contains(tostring(e1), 'scope is joining', 'spawn admission error should mention joining') + + local c2, e2 = child:child() + assert(c2 == nil and e2 ~= nil, 'child() should be rejected once join has started') + assert_contains(tostring(e2), 'scope is joining', 'child admission error should mention joining') - assert(st == 'cancelled', 'scope should be cancelled') - assert(serr == 'cancel before sync', 'cancellation reason should be preserved') + -- Allow join to complete. + blocker:signal() - 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') + return 'ok' + end) - 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') + assert(st == 'ok', 'join/admission test should complete ok') + assert(unpack_packed(val_or_primary) == 'ok', 'join/admission test should return ok') 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 +------------------------------------------------------------------------------- +-- 11. scope.with_op behaviour (status/value/report), failure confinement, abort +------------------------------------------------------------------------------- - local st, serr, _, ok_op, reason_op = scope.run(function (s) - race_scope = s - local cond = cond_mod.new() +local function test_with_op_basic() + local parent = scope_mod.current() + local child_ref - -- Canceller fiber: let the main fiber block first, then cancel. - s:spawn(function (_) - runtime.yield() - s:cancel('race cancel') - end) + local ev = scope_mod.with_op(function (child) + child_ref = child + assert(scope_mod.current() == child, 'inside with_op build_op, current() should be child scope') - local ok2, reason2 = s:sync(cond:wait_op()) - return ok2, reason2 + return op.always(true):wrap(function () + return 99, 'ok' + end) end) - assert(st == 'cancelled', 'scope.run should report cancelled in race test') - assert(serr == 'race cancel', - "race cancellation reason should be 'race cancel'") + local st, vals, rep = performer.perform(ev) + assert(st == 'ok', 'with_op should return ok on success') + assert(type(vals) == 'table', 'with_op ok result should carry packed values table') + local a, b = unpack_packed(vals) + assert(a == 99 and b == 'ok', 'with_op should propagate body op values via packed table') + assert(rep and rep.id == child_ref._id, 'with_op report id should be child scope id') - 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') + assert(scope_mod.current() == parent, 'after with_op, current() should restore to parent') + local cst, cprimary = child_ref:status() + assert(cst == 'ok' and cprimary == nil, 'with_op child scope should be ok after success') end -------------------------------------------------------------------------------- --- 5. join_op and not_ok_op() (on failed/cancelled scopes) -------------------------------------------------------------------------------- +local function test_with_op_builder_failure_confined() + local st, val_or_primary = scope_mod.run(function (_) + local child_ref -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') + local ev = scope_mod.with_op(function (child) + child_ref = child + error('with_op builder failure') + end) + + local wst, wprimary, wrep = performer.perform(ev) + assert(wst == 'failed', 'with_op should return failed when build_op errors') + assert_contains(tostring(wprimary), 'with_op builder failure', 'with_op primary should mention builder failure') + assert(wrep and wrep.id == child_ref._id, 'with_op report id should be child id') + + return 'outer-ok' 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') + assert(st == 'ok', 'outer scope should remain ok when with_op build fails') + assert(unpack_packed(val_or_primary) == 'outer-ok', 'outer scope should return body results') +end - 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 +local function test_with_op_abort_on_choice() + local child_ref - 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 + local st, val_or_primary = scope_mod.run(function (_) + local ev_with = scope_mod.with_op(function (child) + child_ref = child + return op.never() + 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') + 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") + return res end) - assert(st_cancel == 'cancelled', 'cancelled scope.run should report cancelled') - assert(err_cancel == 'stop again', - 'cancelled scope.run should report the cancellation reason') + assert(st == 'ok', 'outer scope should remain ok when with_op arm loses a choice') + assert(unpack_packed(val_or_primary) == 'right', 'outer scope should return choice result') - 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 + assert(child_ref ~= nil, 'with_op should have created a child scope') + local cst, cprimary = child_ref:status() + assert(cst == 'cancelled', 'with_op child should be cancelled when aborted by choice loss') + assert(cprimary == 'aborted', "with_op aborted child primary should be 'aborted'") +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 +local function test_with_op_child_fibre_failure() + local st, val_or_primary = scope_mod.run(function (_) + local ev = scope_mod.with_op(function (child) + child:spawn(function (_) + error('with_op child fibre failure') + end) + return op.always('ok') + end) + + local wst, wprimary = performer.perform(ev) + assert(wst == 'failed', 'with_op should return failed when a child fibre fails') + assert_contains(tostring(wprimary), 'with_op child fibre failure', + 'with_op primary should mention the child fibre failure') + + return 'outer-ok' + end) + + assert(st == 'ok', 'outer scope should remain ok after with_op child fibre failure') + assert(unpack_packed(val_or_primary) == 'outer-ok', 'outer scope should return body results') end ------------------------------------------------------------------------------- --- 6. Fail-fast from child fibers (via performer.perform) +-- Test suite entry ------------------------------------------------------------------------------- -local function test_fail_fast_from_child_fiber() - local test_scope +local function run_all_tests() + test_current_scope_run_restores() + test_structural_lifetime_attached_children_joined() - local st, serr = scope.run(function (s) - test_scope = s + test_admission_close_and_cancel() + test_run_when_parent_closed_is_cancelled_boundary() - -- Use a condition to ensure the child fiber runs before we exit the body. - local cond = cond_mod.new() + test_cancellation_sentinel_and_non_failure() + test_fail_fast_and_siblings_observe_failure() - -- 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) + test_not_ok_ops() + test_finalisers_lifo_and_cancel_in_finaliser() + test_reports_children_and_extra_errors() - 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') + test_join_closes_admission_and_rejects_new_work() - 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') + test_with_op_basic() + test_with_op_builder_failure_confined() + test_with_op_abort_on_choice() + test_with_op_child_fibre_failure() end ------------------------------------------------------------------------------- --- Main +-- Main (avoid hangs; propagate failure to luajit exit code) ------------------------------------------------------------------------------- local function main() 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() - - 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() + local outcome = { ok = true, err = nil } - test_finalisers_lifo_and_failure() - test_extra_failure_marks_scope_failed() + local root = scope_mod.root() - test_sync_wraps_op_failure() - test_sync_respects_cancellation() - test_sync_cancellation_race() + -- Run the suite in a fibre whose current scope is the root. + -- Capture any failure ourselves so it does not become an uncaught fibre error, + -- and so we can always stop the scheduler. + root:spawn(function (_) + local ok, err = xpcall(function () + run_all_tests() + end, function (e) + return debug.traceback(tostring(e), 2) + end) - test_join_and_not_ok_op() - test_fail_fast_from_child_fiber() + outcome.ok = ok + outcome.err = err - io.stdout:write('OK\n') runtime.stop() end) runtime.main() + + if not outcome.ok then + error(outcome.err or 'scope tests failed') + end + + io.stdout:write('OK\n') end main() From ec2a9b9754d33d10ec1d2efa68c1fc3a34564037 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sun, 21 Dec 2025 03:13:13 +0000 Subject: [PATCH 2/8] don't return values at boundaries in a table --- src/fibers.lua | 32 +++++++----- src/fibers/scope.lua | 83 ++++++++++++++++++----------- tests/test_io-file.lua | 18 +++---- tests/test_scope.lua | 116 +++++++++++++++++++++-------------------- 4 files changed, 141 insertions(+), 108 deletions(-) diff --git a/src/fibers.lua b/src/fibers.lua index 7a5f653..65c31af 100644 --- a/src/fibers.lua +++ b/src/fibers.lua @@ -44,23 +44,31 @@ local function run(main_fn, ...) local box = { status = nil, -- 'ok'|'cancelled'|'failed' - value = nil, -- primary/reason (for non-ok) - results = nil, -- packed results table (for ok) + primary = nil, -- primary/reason (for non-ok) + results = nil, -- packed results (for ok) -- report = nil, -- optional: ScopeReport } root:spawn(function () - -- Scope.run returns: st, value_or_primary, report - local st, v, _ = Scope.run(main_fn, unpack(args, 1, args.n)) + -- Scope.run returns: + -- on ok: 'ok', rep, ...results... + -- on not ok: st, rep, primary + local r = pack(Scope.run(main_fn, unpack(args, 1, args.n))) + + local st = r[1] + -- local rep = r[2] box.status = st + -- box.report = rep if st == 'ok' then - -- v is the packed results table (may be empty but non-nil by convention) - box.results = v - -- box.report = _rep + -- Preserve multi-return values for handoff back to the caller. + if r.n > 2 then + box.results = pack(unpack(r, 3, r.n)) + else + box.results = pack() + end else - box.value = v - -- box.report = _rep + box.primary = r[3] end Runtime.stop() @@ -76,7 +84,7 @@ local function run(main_fn, ...) return end - raise_string(box.value or box.status or 'fibers.run: missing status') + raise_string(box.primary or box.status or 'fibers.run: missing status') end ---------------------------------------------------------------------- @@ -135,8 +143,8 @@ return { boolean_choice = Op.boolean_choice, -- Scope utilities re-exported - run_scope = Scope.run, - with_scope_op = Scope.with_op, + run_scope = Scope.run, -- now returns: st, rep, ... + with_scope_op = Scope.with_op, -- now yields: st, rep, ... set_unscoped_error_handler = Scope.set_unscoped_error_handler, current_scope = Scope.current, } diff --git a/src/fibers/scope.lua b/src/fibers/scope.lua index 4dbb361..c349af2 100644 --- a/src/fibers/scope.lua +++ b/src/fibers/scope.lua @@ -11,10 +11,14 @@ -- * scope-aware ops: -- - try(ev) -> 'ok'|'failed'|'cancelled', ... -- - perform(ev) raises on failed/cancelled (using a cancellation sentinel) --- * boundaries: --- - join_op() -> status, primary, report --- - run(fn, ...) -> status, value_or_primary, report (value is packed results table on ok) --- - with_op(build_op) -> Op yielding status, value_or_primary, report +-- * boundaries (status-first, report-second): +-- - join_op() -> status, report, primary|nil +-- - run(fn, ...) -> status, report, ... (on not-ok: ... is primary) +-- - with_op(build_op) -> Op yielding status, report, ... (on not-ok: ... is primary) +-- +-- Notes: +-- * Returning variable arity across boundaries follows Lua conventions. +-- As with any multi-return in Lua, trailing nil results are not preserved. -- -- Deliberate non-feature: -- * no implicit upward propagation of child failure into parent failure. @@ -325,17 +329,21 @@ local function new_scope(parent) _order = {}, _wg = waitgroup.new(), - _closed = false, - _close_os = oneshot.new(), + _closed = false, + _close_reason = nil, + _close_os = oneshot.new(), - _cancelled = false, - _cancel_os = oneshot.new(), + _cancelled = false, + _cancel_reason = nil, + _cancel_os = oneshot.new(), - _extra_errors = {}, - _fault_os = oneshot.new(), + _primary_error = nil, + _extra_errors = {}, + _fault_os = oneshot.new(), _finalisers = {}, _join_started = false, + _join_outcome = nil, _join_os = oneshot.new(), }, Scope) @@ -408,7 +416,6 @@ end ---@return Scope|nil child, any|nil err function Scope:child() if should_reject_admission(self) then return nil, admission_error(self) end - return new_scope(self), nil end @@ -571,7 +578,8 @@ function Scope:_finalise_join_body() for i = 1, #children do local ch = children[i] if ch and ch._parent == self then - local st, primary, rep = op.perform_raw(ch:join_op()) + -- join_op() yields: st, report, primary + local st, rep, primary = op.perform_raw(ch:join_op()) child_outcomes[#child_outcomes + 1] = { id = ch._id, status = st, @@ -639,14 +647,14 @@ function Scope:_start_join_worker() end) end ----@return Op -- yields: status, primary, report +---@return Op -- yields: status, report, primary|nil function Scope:join_op() return op.new_primitive( nil, function () local out = self._join_outcome if out then - return true, out.st, out.primary, out.report + return true, out.st, out.report, out.primary end return false end, @@ -655,10 +663,10 @@ function Scope:join_op() if suspension:waiting() then local out = self._join_outcome if out then - suspension:complete(wrap_fn, out.st, out.primary, out.report) + suspension:complete(wrap_fn, out.st, out.report, out.primary) else local st, primary = terminal_status(self) - suspension:complete(wrap_fn, st, primary, make_report(self, {})) + suspension:complete(wrap_fn, st, make_report(self, {}), primary) end end end) @@ -718,9 +726,9 @@ end -- Boundaries ---------------------------------------------------------------------- --- scope.run(body_fn, ...) -> (status, value_or_primary, report) --- on ok: 'ok', packed_results, report --- on not ok: st, primary, report +-- scope.run(body_fn, ...) -> (status, report, ...) +-- on ok: 'ok', rep, ...body results... +-- on not ok: st, rep, primary local function run(body_fn, ...) assert(type(body_fn) == 'function', 'scope.run expects a function body') assert(runtime.current_fiber(), 'scope.run must be called from inside a fibre') @@ -729,7 +737,9 @@ local function run(body_fn, ...) local child, err = parent:child() -- Parent is not admitting; treat as cancelled boundary. - if not child then return 'cancelled', err, make_report(parent, {}) end + if not child then + return 'cancelled', make_report(parent, {}), err + end local args = pack(...) local fib, prev = install_current_scope(child) @@ -745,20 +755,26 @@ local function run(body_fn, ...) if not ok then child:_record_fault(e, { tag = 'run_body_failed' }) end - local st, primary, rep = op.perform_raw(child:join_op()) - if st == 'ok' then return 'ok', (results or pack()), rep end + -- join_op() yields: st, rep, primary + local st, rep, primary = op.perform_raw(child:join_op()) + if st == 'ok' then + local r = results or pack() + return 'ok', rep, unpack(r, 1, r.n) + end - return st, primary, rep + return st, rep, primary end --- scope.with_op(build_op) -> Op producing (status, value_or_primary, report) +-- scope.with_op(build_op) -> Op producing (status, report, ...) local function with_op(build_op) assert(type(build_op) == 'function', 'scope.with_op expects a function') return op.guard(function () local parent = current() local child, err = parent:child() - if not child then return op.always('cancelled', err, make_report(parent, {})) end + if not child then + return op.always('cancelled', make_report(parent, {}), err) + end local function acquire() local fib, prev = install_current_scope(child) @@ -795,11 +811,18 @@ local function with_op(build_op) return op.bracket(acquire, release, use):wrap(function (body_st, ...) local body_vals = pack(...) - local join_st, join_primary, rep = op.perform_raw(child:join_op()) + -- join_op() yields: st, rep, primary + local join_st, rep, join_primary = op.perform_raw(child:join_op()) + + if join_st ~= 'ok' then + return join_st, rep, join_primary + end + + if body_st == 'ok' then + return 'ok', rep, unpack(body_vals, 1, body_vals.n) + end - if join_st ~= 'ok' then return join_st, join_primary, rep end - if body_st == 'ok' then return 'ok', body_vals, rep end - return body_st, body_vals[1], rep + return body_st, rep, body_vals[1] end) end) end @@ -821,5 +844,5 @@ return { cancel_reason = cancel_reason, set_unscoped_error_handler = set_unscoped_error_handler, - set_debug = set_debug + set_debug = set_debug, } diff --git a/tests/test_io-file.lua b/tests/test_io-file.lua index eb80908..51b9804 100644 --- a/tests/test_io-file.lua +++ b/tests/test_io-file.lua @@ -156,7 +156,7 @@ 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 status, rep, primary = scope_mod.run(function (child) local r, w = file_mod.pipe() assert(r and w, 'pipe() did not return read and write streams') @@ -173,9 +173,6 @@ local function test_cancellation_cancels_blocked_read() 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', @@ -183,8 +180,6 @@ local function test_cancellation_cancels_blocked_read() 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)) @@ -192,14 +187,17 @@ local function test_cancellation_cancels_blocked_read() end) if status == 'failed' then - error(err) + -- rep is available for debugging if you want it; primary is the error. + error(primary) 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)) + assert(primary == 'test cancellation', + 'unexpected child scope cancellation reason: ' .. tostring(primary)) + + -- If you want to sanity-check the report exists: + assert(type(rep) == 'table', 'expected a scope report table') end ---------------------------------------------------------------------- diff --git a/tests/test_scope.lua b/tests/test_scope.lua index 311df04..d9e9a9a 100644 --- a/tests/test_scope.lua +++ b/tests/test_scope.lua @@ -1,5 +1,5 @@ ---- Tests the Scope implementation (updated for new fibers.scope). -print('test: fibers.scope (new)') +--- Tests the Scope implementation (updated for new return shapes). +print('test: fibers.scope (new return shape: st, rep, ...)') -- look one level up package.path = '../src/?.lua;' .. package.path @@ -14,14 +14,6 @@ local cond_mod = require 'fibers.cond' -- Helpers ------------------------------------------------------------------------------- -local unpack = rawget(table, 'unpack') or _G.unpack - -local function unpack_packed(p) - assert(type(p) == 'table', 'expected packed table') - local n = p.n or #p - return unpack(p, 1, n) -end - local function assert_contains(hay, needle, msg) assert(type(hay) == 'string', 'assert_contains expects string haystack') assert(hay:find(needle, 1, true), msg or ('expected to find ' .. tostring(needle))) @@ -46,19 +38,18 @@ local function test_current_scope_run_restores() local outer_ref, inner_ref - local st, val, rep = scope_mod.run(function (s) + local st, rep, outer_val = scope_mod.run(function (s) outer_ref = s assert(scope_mod.current() == s, 'inside scope.run, current() should be the child scope') - local st2, val2, rep2 = scope_mod.run(function (s2) + local st2, rep2, a, b, c = scope_mod.run(function (s2) inner_ref = s2 assert(scope_mod.current() == s2, 'inside nested scope.run, current() should be nested child') return 1, 2, 3 end) assert(st2 == 'ok', 'nested scope.run should succeed') - local a, b, c = unpack_packed(val2) - assert(a == 1 and b == 2 and c == 3, 'nested scope.run should return packed body results') + assert(a == 1 and b == 2 and c == 3, 'nested scope.run should return body results directly') assert(type(rep2) == 'table' and rep2.id ~= nil, 'nested scope.run should return a report') assert(scope_mod.current() == s, 'after nested run, current() should restore to outer child scope') @@ -68,8 +59,7 @@ local function test_current_scope_run_restores() assert(st == 'ok', 'outer scope.run should succeed') assert(type(rep) == 'table' and rep.id ~= nil, 'outer scope.run should return a report') - local r1 = unpack_packed(val) - assert(r1 == 'outer-result', 'outer scope.run should return packed body results') + assert(outer_val == 'outer-result', 'outer scope.run should return body results directly') assert(scope_mod.current() == root, 'after scope.run returns, current() should restore to root in this fibre') @@ -78,7 +68,7 @@ local function test_current_scope_run_restores() -- join_op should be idempotent and immediate after run has joined the scope do - local jst, jprimary, jrep = op.perform_raw(outer_ref:join_op()) + local jst, jrep, jprimary = op.perform_raw(outer_ref:join_op()) assert(jst == 'ok' and jprimary == nil, 'join_op on already-joined ok scope should be ok') assert(jrep and jrep.id == rep.id, 'join_op report id should match') end @@ -92,7 +82,7 @@ local function test_structural_lifetime_attached_children_joined() local child1_ref, child2_ref local child1_done, child2_done = false, false - local st, val_or_primary, rep = scope_mod.run(function (s) + local st, rep, parent_val = scope_mod.run(function (s) local c1, err1 = s:child() assert(c1, 'expected child1, got err: ' .. tostring(err1)) child1_ref = c1 @@ -117,7 +107,7 @@ local function test_structural_lifetime_attached_children_joined() end) assert(st == 'ok', 'parent scope should be ok') - assert(unpack_packed(val_or_primary) == 'parent-ok', 'parent run should return packed results') + assert(parent_val == 'parent-ok', 'parent run should return body results directly') assert(type(rep) == 'table' and type(rep.children) == 'table', 'report should include children array') assert(child1_done and child2_done, 'parent join should have waited for attached children work') @@ -140,7 +130,7 @@ end ------------------------------------------------------------------------------- local function test_admission_close_and_cancel() - local st, val_or_primary, rep = scope_mod.run(function (s) + local st, rep, body_val = scope_mod.run(function (s) local ast, _ = s:admission() assert(ast == 'open', 'new scope admission should be open') @@ -165,13 +155,16 @@ local function test_admission_close_and_cancel() end) assert(st == 'ok', 'scope.run should succeed') - assert(unpack_packed(val_or_primary) == 'ok', 'body result should be returned') + assert(body_val == 'ok', 'body result should be returned') assert(rep and rep.id ~= nil, 'report should be present') - local st2, primary2 = scope_mod.run(function (s) - s:cancel('bye') - end) - assert(st2 == 'cancelled' and primary2 == 'bye', 'explicit cancel should yield cancelled + reason') + do + local st2, rep2, primary2 = scope_mod.run(function (s) + s:cancel('bye') + end) + assert(st2 == 'cancelled' and primary2 == 'bye', 'explicit cancel should yield cancelled + reason') + assert(rep2 and rep2.id ~= nil, 'cancelled scope should still produce a report') + end end ------------------------------------------------------------------------------- @@ -179,22 +172,23 @@ end ------------------------------------------------------------------------------- local function test_run_when_parent_closed_is_cancelled_boundary() - local st, val_or_primary = scope_mod.run(function (s) + local st, rep, outer_val = scope_mod.run(function (s) s:close('no more children') - local st2, v2, rep2 = scope_mod.run(function (_) + local st2, rep2, primary2 = scope_mod.run(function (_) return 1 end) assert(st2 == 'cancelled', 'nested run should be cancelled when parent is closed') - assert_contains(tostring(v2), 'scope is closed', 'nested run should surface admission error') + assert_contains(tostring(primary2), 'scope is closed', 'nested run should surface admission error') assert(rep2 and rep2.id == s._id, 'nested run report should be based on parent scope') return 'parent-ok' end) assert(st == 'ok', 'outer run should remain ok') - assert(unpack_packed(val_or_primary) == 'parent-ok', 'outer body should complete') + assert(outer_val == 'parent-ok', 'outer body should complete') + assert(rep and rep.id ~= nil, 'outer run should return report') end ------------------------------------------------------------------------------- @@ -203,7 +197,7 @@ end local function test_cancellation_sentinel_and_non_failure() -- (A) cancellation escaping a fibre should not mark failure - local st, primary = scope_mod.run(function (s) + local st, rep, primary = scope_mod.run(function (s) local c = cond_mod.new() local ok, err = s:spawn(function (_) @@ -217,9 +211,10 @@ local function test_cancellation_sentinel_and_non_failure() assert(st == 'cancelled' and primary == 'bye', 'cancellation escaping a fibre should yield cancelled scope, not failed') + assert(rep and rep.id ~= nil, 'cancelled scope should return report') -- (B) perform should raise a distinguishable cancellation sentinel - local st2, primary2 = scope_mod.run(function (s) + local st2, rep2, primary2 = scope_mod.run(function (s) local c = cond_mod.new() s:spawn(function (_) @@ -237,6 +232,7 @@ local function test_cancellation_sentinel_and_non_failure() end) assert(st2 == 'cancelled' and primary2 == 'cancel-reason', 'scope should be cancelled with the correct reason') + assert(rep2 and rep2.id ~= nil, 'cancelled scope should return report') end ------------------------------------------------------------------------------- @@ -246,7 +242,7 @@ end local function test_fail_fast_and_siblings_observe_failure() local observed_st, observed_primary - local st, primary, rep = scope_mod.run(function (s) + local st, rep, primary = scope_mod.run(function (s) local c = cond_mod.new() -- Sibling that will be interrupted by fail-fast. @@ -280,7 +276,7 @@ end ------------------------------------------------------------------------------- local function test_not_ok_ops() - local st1, primary1 = scope_mod.run(function (s) + local st1, rep1, primary1 = scope_mod.run(function (s) s:spawn(function (_) runtime.yield() s:cancel('cancelled-here') @@ -290,8 +286,9 @@ local function test_not_ok_ops() assert(ost == 'cancelled' and oval == 'cancelled-here', 'cancel_op should yield cancelled + reason') end) assert(st1 == 'cancelled' and primary1 == 'cancelled-here', 'scope should be cancelled') + assert(rep1 and rep1.id ~= nil, 'cancelled scope should return report') - local st2, primary2 = scope_mod.run(function (s) + local st2, rep2, primary2 = scope_mod.run(function (s) s:spawn(function (_) runtime.yield() error('fault-here') @@ -303,8 +300,9 @@ local function test_not_ok_ops() end) assert(st2 == 'failed', 'scope should be failed on fault') assert_contains(tostring(primary2), 'fault-here', 'scope primary should mention the fault') + assert(rep2 and rep2.id ~= nil, 'failed scope should return report') - local st3, primary3 = scope_mod.run(function (s) + local st3, rep3, primary3 = scope_mod.run(function (s) s:spawn(function (_) runtime.yield() error('precedence-fault') @@ -316,6 +314,7 @@ local function test_not_ok_ops() end) assert(st3 == 'failed', 'scope should be failed in precedence test') assert_contains(tostring(primary3), 'precedence-fault', 'scope primary should mention precedence-fault') + assert(rep3 and rep3.id ~= nil, 'failed scope should return report') end ------------------------------------------------------------------------------- @@ -325,17 +324,18 @@ end local function test_finalisers_lifo_and_cancel_in_finaliser() local order = {} - local st, val_or_primary = scope_mod.run(function (s) + local st, rep, body_val = scope_mod.run(function (s) s:finally(function () table.insert(order, 'first') end) s:finally(function () table.insert(order, 'second') end) return 'ok' end) assert(st == 'ok', 'scope should be ok when body and finalisers succeed') - assert(unpack_packed(val_or_primary) == 'ok', 'body result should be returned on ok') + assert(body_val == 'ok', 'body result should be returned on ok') + assert(rep and rep.id ~= nil, 'ok scope should return report') assert(#order == 2 and order[1] == 'second' and order[2] == 'first', 'finalisers should run LIFO') - local st2, primary2 = scope_mod.run(function (s) + local st2, rep2, primary2 = scope_mod.run(function (s) s:finally(function () error(scope_mod.cancelled('finaliser-cancel')) end) @@ -343,6 +343,7 @@ local function test_finalisers_lifo_and_cancel_in_finaliser() end) assert(st2 == 'failed', 'cancellation sentinel from finaliser should fail the scope') + assert(rep2 and rep2.id ~= nil, 'failed scope should return report') assert_contains(tostring(primary2), 'finaliser raised cancellation', 'primary should mention finaliser cancellation') assert_contains(tostring(primary2), 'finaliser-cancel', 'primary should include the cancellation reason') end @@ -352,7 +353,7 @@ end ------------------------------------------------------------------------------- local function test_reports_children_and_extra_errors() - local st, primary, rep = scope_mod.run(function (s) + local st, rep, primary = scope_mod.run(function (s) local ch, err = s:child() assert(ch, 'expected child scope, got: ' .. tostring(err)) @@ -387,11 +388,11 @@ end ------------------------------------------------------------------------------- local function test_join_closes_admission_and_rejects_new_work() - local st, val_or_primary = scope_mod.run(function (s) + local st, rep, body_val = scope_mod.run(function (s) local child, err = s:child() assert(child, 'expected child scope, got: ' .. tostring(err)) - local blocker = cond_mod.new() + local blocker = cond_mod.new() local join_started = cond_mod.new() -- Keep the child scope busy so join has something to wait for. @@ -424,11 +425,12 @@ local function test_join_closes_admission_and_rejects_new_work() end) assert(st == 'ok', 'join/admission test should complete ok') - assert(unpack_packed(val_or_primary) == 'ok', 'join/admission test should return ok') + assert(rep and rep.id ~= nil, 'ok scope should return report') + assert(body_val == 'ok', 'join/admission test should return ok') end ------------------------------------------------------------------------------- --- 11. scope.with_op behaviour (status/value/report), failure confinement, abort +-- 11. scope.with_op behaviour (status/report/...), failure confinement, abort ------------------------------------------------------------------------------- local function test_with_op_basic() @@ -444,11 +446,9 @@ local function test_with_op_basic() end) end) - local st, vals, rep = performer.perform(ev) + local st, rep, a, b = performer.perform(ev) assert(st == 'ok', 'with_op should return ok on success') - assert(type(vals) == 'table', 'with_op ok result should carry packed values table') - local a, b = unpack_packed(vals) - assert(a == 99 and b == 'ok', 'with_op should propagate body op values via packed table') + assert(a == 99 and b == 'ok', 'with_op should propagate body op values') assert(rep and rep.id == child_ref._id, 'with_op report id should be child scope id') assert(scope_mod.current() == parent, 'after with_op, current() should restore to parent') @@ -457,7 +457,7 @@ local function test_with_op_basic() end local function test_with_op_builder_failure_confined() - local st, val_or_primary = scope_mod.run(function (_) + local st, rep, outer_val = scope_mod.run(function (_) local child_ref local ev = scope_mod.with_op(function (child) @@ -465,22 +465,23 @@ local function test_with_op_builder_failure_confined() error('with_op builder failure') end) - local wst, wprimary, wrep = performer.perform(ev) + local wst, wrep, wprimary = performer.perform(ev) assert(wst == 'failed', 'with_op should return failed when build_op errors') assert_contains(tostring(wprimary), 'with_op builder failure', 'with_op primary should mention builder failure') - assert(wrep and wrep.id == child_ref._id, 'with_op report id should be child id') + assert(wrep and child_ref and wrep.id == child_ref._id, 'with_op report id should be child id') return 'outer-ok' end) assert(st == 'ok', 'outer scope should remain ok when with_op build fails') - assert(unpack_packed(val_or_primary) == 'outer-ok', 'outer scope should return body results') + assert(rep and rep.id ~= nil, 'outer scope should return report') + assert(outer_val == 'outer-ok', 'outer scope should return body results') end local function test_with_op_abort_on_choice() local child_ref - local st, val_or_primary = scope_mod.run(function (_) + local st, rep, outer_val = scope_mod.run(function (_) local ev_with = scope_mod.with_op(function (child) child_ref = child return op.never() @@ -493,7 +494,8 @@ local function test_with_op_abort_on_choice() end) assert(st == 'ok', 'outer scope should remain ok when with_op arm loses a choice') - assert(unpack_packed(val_or_primary) == 'right', 'outer scope should return choice result') + assert(rep and rep.id ~= nil, 'outer scope should return report') + assert(outer_val == 'right', 'outer scope should return choice result') assert(child_ref ~= nil, 'with_op should have created a child scope') local cst, cprimary = child_ref:status() @@ -502,7 +504,7 @@ local function test_with_op_abort_on_choice() end local function test_with_op_child_fibre_failure() - local st, val_or_primary = scope_mod.run(function (_) + local st, rep, outer_val = scope_mod.run(function (_) local ev = scope_mod.with_op(function (child) child:spawn(function (_) error('with_op child fibre failure') @@ -510,16 +512,18 @@ local function test_with_op_child_fibre_failure() return op.always('ok') end) - local wst, wprimary = performer.perform(ev) + local wst, wrep, wprimary = performer.perform(ev) assert(wst == 'failed', 'with_op should return failed when a child fibre fails') assert_contains(tostring(wprimary), 'with_op child fibre failure', 'with_op primary should mention the child fibre failure') + assert(wrep and wrep.id ~= nil, 'with_op should return a report even on failure') return 'outer-ok' end) assert(st == 'ok', 'outer scope should remain ok after with_op child fibre failure') - assert(unpack_packed(val_or_primary) == 'outer-ok', 'outer scope should return body results') + assert(rep and rep.id ~= nil, 'outer scope should return report') + assert(outer_val == 'outer-ok', 'outer scope should return body results') end ------------------------------------------------------------------------------- From fa62480a2d5875bb7238ed34cd6682d83c0aeeb3 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sun, 21 Dec 2025 03:26:27 +0000 Subject: [PATCH 3/8] ensure TB handlers are always used --- examples/03-scope-defers.lua | 2 +- src/fibers/scope.lua | 45 ++++++++++++++++-------------------- 2 files changed, 21 insertions(+), 26 deletions(-) diff --git a/examples/03-scope-defers.lua b/examples/03-scope-defers.lua index af75392..b7e0c0f 100644 --- a/examples/03-scope-defers.lua +++ b/examples/03-scope-defers.lua @@ -24,7 +24,7 @@ fibers.run(function () error('worker body failed') end - local status, primary, rep = fibers.run_scope(worker) + local status, rep, primary = fibers.run_scope(worker) print('status:', status) print('primary:', primary) diff --git a/src/fibers/scope.lua b/src/fibers/scope.lua index c349af2..57dad49 100644 --- a/src/fibers/scope.lua +++ b/src/fibers/scope.lua @@ -80,37 +80,32 @@ local function raise_any(err) error(err, 0) end ----@param fn fun(): any ----@param handler fun(e:any): any ----@return boolean ok, any err -local function protected_call(fn, handler) - if DEBUG then - -- Full diagnostics path - return safe.xpcall(fn, handler) - else - -- Compact path (yield-safe) - return safe.pcall(fn) - end -end - --- Preserve cancellation sentinel; otherwise return traceback string. +-- Preserve cancellation sentinel; otherwise return either traceback or compact string. local function tb_handler(e) if is_cancelled(e) then return e end - return debug.traceback(tostring(e), 2) + local msg = tostring(e) + if DEBUG then return debug.traceback(msg, 2) end + return msg end --- Join must not be interruptible by cancellation: render cancellation as a traceback. +-- Join must not be interruptible by cancellation: convert cancellation to non-cancellation fault. local function join_tb_handler(e) if is_cancelled(e) then - return debug.traceback('join raised cancellation: ' .. tostring(cancel_reason(e)), 2) + local msg = 'join raised cancellation: ' .. tostring(cancel_reason(e)) + if DEBUG then return debug.traceback(msg, 2) end + return msg end - return debug.traceback(tostring(e), 2) + local msg = tostring(e) + if DEBUG then return debug.traceback(msg, 2) end + return msg end --- Finalisers preserve cancellation sentinel so we can treat it explicitly. +-- Finalisers preserve cancellation sentinel (so you can treat it explicitly). local function finaliser_tb_handler(e) if is_cancelled(e) then return e end - return debug.traceback(tostring(e), 2) + local msg = tostring(e) + if DEBUG then return debug.traceback(msg, 2) end + return msg end ---------------------------------------------------------------------- @@ -548,7 +543,7 @@ function Scope:spawn(fn, ...) local prev = fib and fiber_scopes[fib] or nil if fib then fiber_scopes[fib] = self end - local ok, err = protected_call(function () return fn(self, unpack(args, 1, args.n)) end, tb_handler) + local ok, err = safe.xpcall(function () return fn(self, unpack(args, 1, args.n)) end, tb_handler) if not ok then self:_record_fault(err, { tag = 'fibre_failed' }) end @@ -599,7 +594,7 @@ function Scope:_finalise_join_body() local f = fs[i] fs[i] = nil - local ok, err = protected_call(function () + local ok, err = safe.xpcall(function () return f(aborted, st, (st == 'failed') and primary or nil) end, finaliser_tb_handler) @@ -626,7 +621,7 @@ function Scope:_start_join_worker() local fib, prev = install_current_scope(self) local child_outcomes - local ok, err = protected_call(function () + local ok, err = safe.xpcall(function () child_outcomes = self:_finalise_join_body() end, join_tb_handler) @@ -747,7 +742,7 @@ local function run(body_fn, ...) local ok, e local results - ok, e = protected_call(function () + ok, e = safe.xpcall(function () results = pack(body_fn(child, unpack(args, 1, args.n))) end, tb_handler) @@ -792,7 +787,7 @@ local function with_op(build_op) end local function use() - local ok, body = protected_call(function () return build_op(child) end, tb_handler) + local ok, body = safe.xpcall(function () return build_op(child) end, tb_handler) if not ok then child:_record_fault(body, { tag = 'with_build_failed' }) From 59f9d831f8d2e9666cbe0fec6e029490ffc95b00 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sun, 21 Dec 2025 03:34:38 +0000 Subject: [PATCH 4/8] rename scope:run_op to scope:try_op --- src/fibers/scope.lua | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/fibers/scope.lua b/src/fibers/scope.lua index 57dad49..e56cba9 100644 --- a/src/fibers/scope.lua +++ b/src/fibers/scope.lua @@ -683,7 +683,7 @@ end ---@param ev Op ---@return Op -- yields: 'ok', ... | 'failed', primary | 'cancelled', reason -function Scope:run_op(ev) +function Scope:try_op(ev) assert_op_value(ev) return op.guard(function () @@ -704,7 +704,7 @@ end ---@return '"ok"'|'"failed"'|'"cancelled"', ... function Scope:try(ev) assert(runtime.current_fiber(), 'scope:try must be called from inside a fibre') - return op.perform_raw(self:run_op(ev)) + return op.perform_raw(self:try_op(ev)) end ---@param ev Op @@ -800,7 +800,7 @@ local function with_op(build_op) return op.always('failed', msg) end - return child:run_op(body) + return child:try_op(body) end return op.bracket(acquire, release, use):wrap(function (body_st, ...) @@ -838,6 +838,7 @@ return { is_cancelled = is_cancelled, cancel_reason = cancel_reason, + set_debug = set_debug, + set_unscoped_error_handler = set_unscoped_error_handler, - set_debug = set_debug, } From c405efa6732a62d30fda25e773258b2469499f00 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Sun, 21 Dec 2025 14:31:17 +0000 Subject: [PATCH 5/8] revise scope.lua adds run_op --- ...ope-defers.lua => 03-scope-finalisers.lua} | 0 examples/05-cancel-subprocess.lua | 8 +- examples/06-scope-command-shared-pipe.lua | 20 +- src/fibers.lua | 12 +- src/fibers/io/exec.lua | 2 +- src/fibers/scope.lua | 576 +++++++++--------- tests/test_scope.lua | 120 ++-- 7 files changed, 392 insertions(+), 346 deletions(-) rename examples/{03-scope-defers.lua => 03-scope-finalisers.lua} (100%) diff --git a/examples/03-scope-defers.lua b/examples/03-scope-finalisers.lua similarity index 100% rename from examples/03-scope-defers.lua rename to examples/03-scope-finalisers.lua diff --git a/examples/05-cancel-subprocess.lua b/examples/05-cancel-subprocess.lua index 36dd040..b52d689 100644 --- a/examples/05-cancel-subprocess.lua +++ b/examples/05-cancel-subprocess.lua @@ -3,7 +3,7 @@ -- * 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 -- -- Notes for the current scope semantics: -- * fibers.run_scope(fn, ...) returns: @@ -30,7 +30,7 @@ local sleep_op = sleep.sleep_op local function main() 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. -- Use run_scope so we can interpret status and primary at a clear boundary. local st, value_or_primary, _ = fibers.run_scope(function (s) print('[subscope] starting child process') @@ -49,7 +49,7 @@ local function main() } ------------------------------------------------------------------ - -- 2. Reader fibre: drain stdout until EOF or error + -- 2. Reader fiber: drain stdout until EOF or error ------------------------------------------------------------------ spawn(function () @@ -92,7 +92,7 @@ local function main() -- 4. Timeout: cancel the subscope ------------------------------------------------------------------ -- - -- This cancels the reader fibre and any other work in the scope. + -- This cancels the reader fiber and any other work in the scope. -- The Command’s scope finaliser will then perform a best-effort, -- non-interruptible shutdown and close any owned streams. -- diff --git a/examples/06-scope-command-shared-pipe.lua b/examples/06-scope-command-shared-pipe.lua index 56e86bf..89d2fb3 100644 --- a/examples/06-scope-command-shared-pipe.lua +++ b/examples/06-scope-command-shared-pipe.lua @@ -5,7 +5,7 @@ local sleep = require 'fibers.sleep' local exec = require 'fibers.io.exec' local file = require 'fibers.io.file' -local with_scope_op = fibers.with_scope_op +local run_scope_op = fibers.run_scope_op local named_choice = fibers.named_choice local perform = fibers.perform @@ -15,15 +15,15 @@ local function main(parent_scope) ---------------------------------------------------------------------- local r_stream, w_stream = file.pipe() - -- Finaliser runs only after the scope has drained spawned fibres and joined children. + -- Finaliser runs only after the scope has drained spawned fibers and joined children. parent_scope:finally(function () - print('[parent] finaliser: closing streams (runs after reader fibre has finished)') + print('[parent] finaliser: closing streams (runs after reader fiber has finished)') assert(r_stream:close()) assert(w_stream:close()) end) ---------------------------------------------------------------------- - -- Reader fibre in the parent scope (no explicit join/wait needed) + -- Reader fiber in the parent scope (no explicit join/wait needed) ---------------------------------------------------------------------- do local ok, err = fibers.spawn(function () @@ -44,12 +44,12 @@ local function main(parent_scope) ---------------------------------------------------------------------- -- Child scope as an Op: command writes ticks to the shared stream -- - -- with_scope_op yields: + -- run_scope_op yields: -- scope_st, value_or_primary, report -- where on scope_st == 'ok', value_or_primary is a packed table of -- cmd:run_op() results: { [1]=cmd_st, [2]=code, [3]=signal, [4]=err, n=4 } ---------------------------------------------------------------------- - local child_scope_op = with_scope_op(function (_) + local child_scope_op = run_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]] @@ -78,7 +78,7 @@ local function main(parent_scope) local which, a, b, c = perform(ev) if which == 'timeout' then - print('[parent] choice: timeout (child scope was aborted and joined by with_scope_op)') + print('[parent] choice: timeout (child scope was aborted and joined by run_scope_op)') else local scope_st, value_or_primary, _ = a, b, c @@ -107,11 +107,11 @@ local function main(parent_scope) ---------------------------------------------------------------------- -- Important point: - -- We do not wait for the reader fibre explicitly. + -- We do not wait for the reader fiber explicitly. -- -- We close the writer to provoke EOF, then return from main. -- The surrounding scope join (performed by fibers.run) will wait - -- until the reader fibre exits, then run finalisers, then return. + -- until the reader fiber exits, then run finalisers, then return. ---------------------------------------------------------------------- assert(w_stream:close()) print('[parent] returning from main (reader may still be draining)') @@ -119,4 +119,4 @@ end fibers.run(main) -print('[outside] fibers.run returned (all scoped work, including reader fibre, has completed)') +print('[outside] fibers.run returned (all scoped work, including reader fiber, has completed)') diff --git a/src/fibers.lua b/src/fibers.lua index 65c31af..acaea0f 100644 --- a/src/fibers.lua +++ b/src/fibers.lua @@ -37,7 +37,7 @@ end ---@return any ... local function run(main_fn, ...) assert(not Runtime.current_fiber(), - 'fibers.run must not be called from inside a fibre') + 'fibers.run must not be called from inside a fiber') local root = Scope.root() local args = pack(...) @@ -55,7 +55,7 @@ local function run(main_fn, ...) -- on not ok: st, rep, primary local r = pack(Scope.run(main_fn, unpack(args, 1, args.n))) - local st = r[1] + local st = r[1] -- local rep = r[2] box.status = st -- box.report = rep @@ -91,7 +91,7 @@ end -- Spawn ---------------------------------------------------------------------- ---- Spawn a fibre under the current scope. +--- Spawn a fiber under the current scope. --- fn is called as fn(...). ---@param fn fun(...): any ---@param ... any @@ -112,7 +112,7 @@ end ---------------------------------------------------------------------- --- Perform an op under the current scope, returning status-first. ---- Must be called from inside a fibre. +--- Must be called from inside a fiber. ---@param ev any ---@return string status ---@return any ... @@ -143,8 +143,8 @@ return { boolean_choice = Op.boolean_choice, -- Scope utilities re-exported - run_scope = Scope.run, -- now returns: st, rep, ... - with_scope_op = Scope.with_op, -- now yields: st, rep, ... + run_scope = Scope.run, -- now returns: st, rep, ... + run_scope_op = Scope.run_op, -- now yields: st, rep, ... 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 1e01440..098cd03 100644 --- a/src/fibers/io/exec.lua +++ b/src/fibers/io/exec.lua @@ -595,7 +595,7 @@ end ---@param spec ExecSpec ---@return Command local function command_from_spec(spec) - assert(Runtime.current_fiber(), 'exec.command must be called from inside a fibre') + assert(Runtime.current_fiber(), 'exec.command must be called from inside a fiber') local scope = ScopeMod.current() local argv = {} diff --git a/src/fibers/scope.lua b/src/fibers/scope.lua index e56cba9..5dc1f58 100644 --- a/src/fibers/scope.lua +++ b/src/fibers/scope.lua @@ -2,27 +2,37 @@ -- -- Stable core structured concurrency scopes that complement the Op layer. -- --- Guarantees: --- * structural lifetime: attached children are joined --- * admission gate: close() stops new spawn/child; join starts by closing admission --- * downward cancellation: cancel() cascades to attached children --- * fail-fast within a scope: first fault marks failed and cancels the scope --- * join/finalisation is non-interruptible: runs in a join worker using op.perform_raw --- * scope-aware ops: --- - try(ev) -> 'ok'|'failed'|'cancelled', ... --- - perform(ev) raises on failed/cancelled (using a cancellation sentinel) --- * boundaries (status-first, report-second): +-- This module provides supervision “scopes” for cooperative fibers. Scopes are +-- intended to be the unit of lifetime, cancellation and failure accounting, +-- with explicit boundaries for crossing between scopes. +-- +-- Guarantees +-- * Structural lifetime: attached children are joined by the parent join, +-- including child finalisers, in attachment order. +-- * Admission gate: close() stops new spawn()/child() on the scope. +-- Joining also closes admission (but does not imply cancellation). +-- * Downward cancellation: cancel() closes admission and cascades to attached +-- children. Cancellation is a normal termination mode, distinct from failure. +-- * Fail-fast within a scope: the first non-cancellation fault marks the scope +-- failed, records a primary error, and cancels the scope to stop siblings. +-- * Join/finalisation is non-interruptible: join runs in a join worker and uses +-- op.perform_raw, so it is not interrupted by scope cancellation. +-- * Scope-aware ops: +-- - try(ev) -> 'ok'|'failed'|'cancelled', ... +-- - perform(ev) -> returns results on ok; raises on failed/cancelled +-- (using a cancellation sentinel for cancelled). +-- * Boundaries (status-first, report-second): -- - join_op() -> status, report, primary|nil -- - run(fn, ...) -> status, report, ... (on not-ok: ... is primary) --- - with_op(build_op) -> Op yielding status, report, ... (on not-ok: ... is primary) +-- - run_op(fn, ...) -> Op yielding status, report, ... (on not-ok: ... is primary) -- --- Notes: +-- Notes -- * Returning variable arity across boundaries follows Lua conventions. --- As with any multi-return in Lua, trailing nil results are not preserved. +-- As with any multi-return, trailing nil results are not preserved. -- --- Deliberate non-feature: --- * no implicit upward propagation of child failure into parent failure. --- Child outcomes are reported, not escalated. +-- Deliberate non-feature +-- * No implicit upward propagation of child failure into parent failure. +-- Child outcomes are reported (via reports), not escalated. -- ---@module 'fibers.scope' @@ -32,13 +42,11 @@ local oneshot = require 'fibers.oneshot' local op = require 'fibers.op' local safe = require 'coxpcall' --- Debug flag: when true, capture full tracebacks via xpcall handlers. --- When false, use yield-safe pcall and keep errors compact. local DEBUG = false -local function set_debug(v) - DEBUG = not not v -end +--- Enable/disable debug traceback capture. +---@param v boolean +local function set_debug(v) DEBUG = not not v end local unpack = rawget(table, 'unpack') or _G.unpack local pack = rawget(table, 'pack') or function (...) @@ -73,40 +81,38 @@ local function cancel_reason(err) return is_cancelled(err) and err.reason or nil end +---@param err any local function raise_any(err) - -- Raise the value verbatim so cancellation sentinels (tables/userdata) - -- remain distinguishable. tb_handler() will stringify non-cancellation - -- errors for reporting. error(err, 0) end --- Preserve cancellation sentinel; otherwise return either traceback or compact string. -local function tb_handler(e) - if is_cancelled(e) then return e end - local msg = tostring(e) - if DEBUG then return debug.traceback(msg, 2) end - return msg -end +---------------------------------------------------------------------- +-- Error normalisation policy (xpcall handlers) +---------------------------------------------------------------------- --- Join must not be interruptible by cancellation: convert cancellation to non-cancellation fault. -local function join_tb_handler(e) - if is_cancelled(e) then - local msg = 'join raised cancellation: ' .. tostring(cancel_reason(e)) +---@param kind '"body"'|'"join"'|'"finaliser"' +---@return fun(e:any): any +local function make_xpcall_handler(kind) + return function (e) + if is_cancelled(e) then + if kind == 'join' then + local msg = 'join raised cancellation: ' .. tostring(cancel_reason(e)) + if DEBUG then return debug.traceback(msg, 2) end + return msg + end + -- body/finaliser: propagate cancellation sentinel for control flow + return e + end + + local msg = tostring(e) if DEBUG then return debug.traceback(msg, 2) end return msg end - local msg = tostring(e) - if DEBUG then return debug.traceback(msg, 2) end - return msg end --- Finalisers preserve cancellation sentinel (so you can treat it explicitly). -local function finaliser_tb_handler(e) - if is_cancelled(e) then return e end - local msg = tostring(e) - if DEBUG then return debug.traceback(msg, 2) end - return msg -end +local tb_handler = make_xpcall_handler('body') +local join_tb_handler = make_xpcall_handler('join') +local finaliser_handler = make_xpcall_handler('finaliser') ---------------------------------------------------------------------- -- Types / state @@ -114,7 +120,7 @@ end ---@class ScopeChildOutcome ---@field id integer ----@field status '"ok"'|'"failed"'|'"cancelled"' +---@field status "ok"|"failed"|"cancelled" ---@field primary any ---@field report ScopeReport @@ -124,10 +130,14 @@ end ---@field children ScopeChildOutcome[] ---@class ScopeJoinOutcome ----@field st '"ok"'|'"failed"'|'"cancelled"' +---@field st "ok"|"failed"|"cancelled" ---@field primary any ---@field report ScopeReport +---@class ScopeTerminal +---@field failed any|nil -- primary failure (string/number) if failed +---@field cancelled any|nil -- cancellation reason if cancelled + ---@class Scope ---@field _id integer ---@field _parent Scope|nil @@ -137,10 +147,8 @@ end ---@field _closed boolean ---@field _close_reason any|nil ---@field _close_os Oneshot ----@field _cancelled boolean ----@field _cancel_reason any|nil +---@field _terminal ScopeTerminal|nil ---@field _cancel_os Oneshot ----@field _primary_error any|nil ---@field _extra_errors any[] ---@field _fault_os Oneshot ---@field _finalisers function[] @@ -150,10 +158,13 @@ end local Scope = {} Scope.__index = Scope --- Weak-key map: Fiber -> Scope for attribution of uncaught runtime fibre errors. +-- Weak-key map: Fiber -> Scope for attribution of uncaught runtime fiber errors. local fiber_scopes = setmetatable({}, { __mode = 'k' }) -local root_scope, global_scope +-- Process-wide root scope. +local root_scope + +-- Monotonic scope id sequence (local to the process). local next_id = 0 local function current_fiber() @@ -165,7 +176,7 @@ end ---------------------------------------------------------------------- local function default_unscoped_error_handler(_, err) - io.stderr:write('Unscoped fibre error: ' .. tostring(err) .. '\n') + io.stderr:write('Unscoped fiber error: ' .. tostring(err) .. '\n') end local unscoped_error_handler = default_unscoped_error_handler @@ -177,26 +188,26 @@ local function set_unscoped_error_handler(handler) end ---------------------------------------------------------------------- --- Current scope install/restore +-- Current scope install/restore (fiber-local only) ---------------------------------------------------------------------- +---@param s Scope +---@return any fib_or_nil, Scope|nil prev local function install_current_scope(s) local fib = current_fiber() - if fib then - local prev = fiber_scopes[fib] - fiber_scopes[fib] = s - return fib, prev + if not fib then + return nil, nil end - local prev = global_scope or root_scope or s - global_scope = s - return nil, prev + local prev = fiber_scopes[fib] + fiber_scopes[fib] = s + return fib, prev end +---@param fib any|nil +---@param prev Scope|nil local function restore_current_scope(fib, prev) if fib then fiber_scopes[fib] = prev - else - global_scope = prev end end @@ -204,47 +215,62 @@ end -- Helpers ---------------------------------------------------------------------- +---@param t any[] +---@return any[] local function copy_array(t) local out = {} for i = 1, #t do out[i] = t[i] end return out end +---@param self Scope +---@return Scope[] local function snapshot_children_set(self) local snap = {} for ch in pairs(self._children) do snap[#snap + 1] = ch end return snap end -local function oneshot_status_op(is_ready, os, get_st_v) +--- Build a primitive op from an oneshot-like readiness predicate. +--- When ready, yields whatever get_values() returns (any arity). +---@param is_ready fun(): boolean +---@param os Oneshot +---@param get_values fun(): ... +---@param on_block? fun() +---@return Op +local function oneshot_value_op(is_ready, os, get_values, on_block) return op.new_primitive( nil, function () if is_ready() then - local st, v = get_st_v() - return true, st, v + return true, get_values() end return false end, function (suspension, wrap_fn) local cancel = os:add_waiter(function () if suspension:waiting() then - local st, v = get_st_v() - suspension:complete(wrap_fn, st, v) + suspension:complete(wrap_fn, get_values()) end end) suspension:add_cleanup(cancel) + if on_block then on_block() end end ) end +---@param self Scope +---@return "ok"|"failed"|"cancelled", any local function terminal_status(self) - -- Failure takes precedence over cancellation. - if self._primary_error ~= nil then return 'failed', self._primary_error end - if self._cancelled then return 'cancelled', self._cancel_reason end + local t = self._terminal + if t and t.failed ~= nil then return 'failed', t.failed end + if t and t.cancelled ~= nil then return 'cancelled', t.cancelled end return 'ok', nil end +---@param self Scope +---@param child_outcomes? ScopeChildOutcome[] +---@return ScopeReport local function make_report(self, child_outcomes) return { id = self._id, @@ -253,47 +279,36 @@ local function make_report(self, child_outcomes) } end -local function should_reject_admission(self) - return self._closed - or self._cancelled - or self._primary_error ~= nil - or self._join_started - or self._join_outcome ~= nil +--- Return a rejection reason if the scope is not admitting new work; otherwise nil. +---@param self Scope +---@return string|nil +local function reject_reason(self) + if self._join_outcome ~= nil or self._join_started then return 'scope is joining' end + + local t = self._terminal + if t and t.failed ~= nil then return 'scope has failed' end + if t and t.cancelled ~= nil then return 'scope is cancelled' end + + if self._closed then return 'scope is closed' end + return nil end ---------------------------------------------------------------------- -- Observational status (non-blocking snapshot) ---------------------------------------------------------------------- ---- Return a snapshot of this scope's current status. ---- ---- This is intentionally observational: it does not synchronise or wait. ---- For waiting, use join_op()/cancel_op()/fault_op()/not_ok_op(). ---- ---- Returns: ---- 'running', nil ---- 'failed', primary_error ---- 'cancelled', reason ---- 'ok', nil (only once join has completed successfully) ---@return string st ---@return any v function Scope:status() - -- If join has completed, return the terminal state captured there. local out = self._join_outcome if out then return out.st, out.primary end - -- Live view (pre-join). - if self._primary_error ~= nil then return 'failed', self._primary_error end - if self._cancelled then return 'cancelled', self._cancel_reason end + local t = self._terminal + if t and t.failed ~= nil then return 'failed', t.failed end + if t and t.cancelled ~= nil then return 'cancelled', t.cancelled end return 'running', nil end ---- The admission gate is deliberately not part of status(), because ---- "closed" is not a terminal outcome; it is simply "not admitting more work". ---- ---- Returns: ---- 'open', nil ---- 'closed', reason|nil ---@return string st ---@return any reason function Scope:admission() @@ -301,19 +316,12 @@ function Scope:admission() return 'open', nil end -local function admission_error(self) - -- Keep this intentionally simple and non-taxonomic. - if self._join_outcome ~= nil or self._join_started then return 'scope is joining' end - if self._primary_error ~= nil then return 'scope has failed' end - if self._cancelled then return 'scope is cancelled' end - if self._closed then return 'scope is closed' end - return 'scope is not admitting work' -end - ---------------------------------------------------------------------- -- Construction / root / current ---------------------------------------------------------------------- +---@param parent Scope|nil +---@return Scope local function new_scope(parent) next_id = next_id + 1 @@ -328,13 +336,11 @@ local function new_scope(parent) _close_reason = nil, _close_os = oneshot.new(), - _cancelled = false, - _cancel_reason = nil, - _cancel_os = oneshot.new(), + _terminal = nil, + _cancel_os = oneshot.new(), - _primary_error = nil, - _extra_errors = {}, - _fault_os = oneshot.new(), + _extra_errors = {}, + _fault_os = oneshot.new(), _finalisers = {}, _join_started = false, @@ -347,27 +353,29 @@ local function new_scope(parent) parent._order[#parent._order + 1] = s -- Downward cancellation propagates immediately to new children. - if parent._cancelled then s:cancel(parent._cancel_reason) end + local pt = parent._terminal + if pt and pt.cancelled ~= nil then + s:cancel(pt.cancelled) + end end return s end +---@return Scope local function root() if root_scope then return root_scope end - root_scope = new_scope(nil) - global_scope = root_scope + root_scope = new_scope(nil) -- Error pump: route uncaught runtime errors to the owning scope when possible. runtime.spawn_raw(function () while true do local fib, err = runtime.wait_fiber_error() - -- Ignore cancellation sentinels. if not is_cancelled(err) then local s = fiber_scopes[fib] if s then - s:_record_fault(err, { tag = 'unhandled_fibre_error' }) + s:_record_fault(err) else unscoped_error_handler(fib, err) end @@ -378,16 +386,22 @@ local function root() return root_scope end +--- Return the current scope. +--- Inside a fiber: the fiber's scope, defaulting to root. +--- Outside fibers: always the root scope. +---@return Scope local function current() local fib = current_fiber() if fib then return fiber_scopes[fib] or root() end - return global_scope or root() + return root() end ---------------------------------------------------------------------- -- Child management (attachment) ---------------------------------------------------------------------- +---@param self Scope +---@param child Scope function Scope:_remove_child(child) if child._parent ~= self then return end self._children[child] = nil @@ -410,7 +424,8 @@ end ---@return Scope|nil child, any|nil err function Scope:child() - if should_reject_admission(self) then return nil, admission_error(self) end + local why = reject_reason(self) + if why then return nil, why end return new_scope(self), nil end @@ -431,9 +446,9 @@ function Scope:close(reason) end end ----@return Op -- yields: 'closed', reason +---@return Op function Scope:close_op() - return oneshot_status_op( + return oneshot_value_op( function () return self._closed end, self._close_os, function () return 'closed', self._close_reason end @@ -448,70 +463,100 @@ end function Scope:cancel(reason) if self._join_outcome ~= nil then return end - -- Cancellation implies admission is closed (useful for accept loops and similar). + -- Cancellation implies admission is closed. self:close(reason) - if not self._cancelled then - self._cancelled = true - self._cancel_reason = (reason ~= nil) and reason or (self._cancel_reason or 'scope cancelled') + local t = self._terminal + if not t then + t = { failed = nil, cancelled = nil } + self._terminal = t + end + + if t.cancelled == nil then + t.cancelled = (reason ~= nil) and reason or 'scope cancelled' self._cancel_os:signal() - elseif self._cancel_reason == nil and reason ~= nil then - self._cancel_reason = reason end -- Cancel attached children (snapshot avoids mutation hazards). local snap = snapshot_children_set(self) - for i = 1, #snap do snap[i]:cancel(self._cancel_reason) end + for i = 1, #snap do snap[i]:cancel(t.cancelled) end end --- Record a fault in this scope. Cancellation escaping from bodies is control flow. ---@param err any ----@param _? table -function Scope:_record_fault(err, _) +function Scope:_record_fault(err) if is_cancelled(err) then self:cancel(cancel_reason(err)) return end + -- Normalise error to a reportable primitive (string/number). local e = err if type(e) ~= 'string' and type(e) ~= 'number' then e = tostring(e) end - if self._primary_error == nil then - self._primary_error = e - self._fault_os:signal() - -- Fail-fast: cancel the scope to stop sibling work. - self:cancel(e) - else + local t = self._terminal + if t and t.failed ~= nil then + -- Subsequent faults are recorded as extra errors. self._extra_errors[#self._extra_errors + 1] = e + return end + + if not t then + t = { failed = nil, cancelled = nil } + self._terminal = t + end + + -- First fault becomes primary; fail-fast by cancelling. + t.failed = e + self._fault_os:signal() + + -- Ensure cancellation is requested (without overriding an existing reason). + if t.cancelled == nil then + t.cancelled = e + self._cancel_os:signal() + end + + -- Downward cancellation stops siblings/children. + self:cancel(t.cancelled) end ----@return Op -- yields: 'cancelled', reason +---@return Op function Scope:cancel_op() - return oneshot_status_op( - function () return self._cancelled end, + return oneshot_value_op( + function () + local t = self._terminal + return t ~= nil and t.cancelled ~= nil + end, self._cancel_os, - function () return 'cancelled', self._cancel_reason end + function () + local t = self._terminal + return 'cancelled', t and t.cancelled or nil + end ) end ----@return Op -- yields: 'failed', primary +---@return Op function Scope:fault_op() - return oneshot_status_op( - function () return self._primary_error ~= nil end, + return oneshot_value_op( + function () + local t = self._terminal + return t ~= nil and t.failed ~= nil + end, self._fault_os, - function () return 'failed', self._primary_error end + function () + local t = self._terminal + return 'failed', t and t.failed or nil + end ) end ----@return Op -- yields: 'failed', primary | 'cancelled', reason +---@return Op function Scope:not_ok_op() - -- Re-check on wake so failure wins even if cancellation also became ready. return op.choice(self:fault_op(), self:cancel_op()):wrap(function () - if self._primary_error ~= nil then return 'failed', self._primary_error end - return 'cancelled', self._cancel_reason + local t = self._terminal + if t and t.failed ~= nil then return 'failed', t.failed end + return 'cancelled', t and t.cancelled or nil end) end @@ -533,21 +578,22 @@ end ---@param ... any ---@return boolean ok, any|nil err function Scope:spawn(fn, ...) - if should_reject_admission(self) then return false, admission_error(self) end + local why = reject_reason(self) + if why then return false, why end local args = pack(...) self._wg:add(1) 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 + local fib, prev = install_current_scope(self) - local ok, err = safe.xpcall(function () return fn(self, unpack(args, 1, args.n)) end, tb_handler) + local ok, err = safe.xpcall(function () + return fn(self, unpack(args, 1, args.n)) + end, tb_handler) - if not ok then self:_record_fault(err, { tag = 'fibre_failed' }) end + restore_current_scope(fib, prev) - if fib then fiber_scopes[fib] = prev end + if not ok then self:_record_fault(err) end self._wg:done() end) @@ -558,22 +604,19 @@ end -- Join (non-interruptible finalisation) ---------------------------------------------------------------------- +---@param self Scope +---@return ScopeChildOutcome[] function Scope:_finalise_join_body() - -- Joining closes admission (but does not imply cancellation). self:close('joining') - -- Snapshot children in attachment order. local children = copy_array(self._order) local child_outcomes = {} - -- Drain spawned fibres. op.perform_raw(self._wg:wait_op()) - -- Join children in attachment order. for i = 1, #children do local ch = children[i] if ch and ch._parent == self then - -- join_op() yields: st, report, primary local st, rep, primary = op.perform_raw(ch:join_op()) child_outcomes[#child_outcomes + 1] = { id = ch._id, @@ -585,7 +628,6 @@ function Scope:_finalise_join_body() end end - -- Run finalisers (LIFO). Any finaliser error becomes a fault. local st, primary = terminal_status(self) local aborted = (st ~= 'ok') @@ -596,14 +638,13 @@ function Scope:_finalise_join_body() local ok, err = safe.xpcall(function () return f(aborted, st, (st == 'failed') and primary or nil) - end, finaliser_tb_handler) + end, finaliser_handler) if not ok then if is_cancelled(err) then - self:_record_fault('finaliser raised cancellation: ' .. tostring(cancel_reason(err)), - { tag = 'finaliser_cancelled' }) + self:_record_fault('finaliser raised cancellation: ' .. tostring(cancel_reason(err))) else - self:_record_fault(err, { tag = 'finaliser_failed' }) + self:_record_fault(err) end st, primary = terminal_status(self) aborted = (st ~= 'ok') @@ -627,9 +668,7 @@ function Scope:_start_join_worker() restore_current_scope(fib, prev) - if not ok then - self:_record_fault(err, { tag = 'join_failed' }) - end + if not ok then self:_record_fault(err) end local st, primary = terminal_status(self) local rep = make_report(self, child_outcomes or {}) @@ -637,37 +676,23 @@ function Scope:_start_join_worker() self._join_outcome = { st = st, primary = primary, report = rep } self._join_os:signal() - -- Avoid retaining completed children in long-lived parents. self:_detach_from_parent() end) end ----@return Op -- yields: status, report, primary|nil +---@return Op function Scope:join_op() - return op.new_primitive( - nil, + return oneshot_value_op( + function () return self._join_outcome ~= nil end, + self._join_os, function () local out = self._join_outcome - if out then - return true, out.st, out.report, out.primary - end - return false + if out then return out.st, out.report, out.primary end + -- Defensive fallback. + local st, primary = terminal_status(self) + return st, make_report(self, {}), primary end, - function (suspension, wrap_fn) - local cancel = self._join_os:add_waiter(function () - if suspension:waiting() then - local out = self._join_outcome - if out then - suspension:complete(wrap_fn, out.st, out.report, out.primary) - else - local st, primary = terminal_status(self) - suspension:complete(wrap_fn, st, make_report(self, {}), primary) - end - end - end) - suspension:add_cleanup(cancel) - self:_start_join_worker() - end + function () self:_start_join_worker() end ) end @@ -675,6 +700,7 @@ end -- Scope-aware op performance (status-first) ---------------------------------------------------------------------- +---@param ev any local function assert_op_value(ev) if type(ev) ~= 'table' or getmetatable(ev) ~= op.Op then error(('scope: expected op, got %s (%s)'):format(type(ev), tostring(ev)), 3) @@ -682,17 +708,19 @@ local function assert_op_value(ev) end ---@param ev Op ----@return Op -- yields: 'ok', ... | 'failed', primary | 'cancelled', reason +---@return Op function Scope:try_op(ev) assert_op_value(ev) return op.guard(function () - if self._primary_error ~= nil then return op.always('failed', self._primary_error) end - if self._cancelled then return op.always('cancelled', self._cancel_reason) end + local t = self._terminal + if t and t.failed ~= nil then return op.always('failed', t.failed) end + if t and t.cancelled ~= nil then return op.always('cancelled', t.cancelled) end local body = ev:wrap(function (...) - if self._primary_error ~= nil then return 'failed', self._primary_error end - if self._cancelled then return 'cancelled', self._cancel_reason end + local t2 = self._terminal + if t2 and t2.failed ~= nil then return 'failed', t2.failed end + if t2 and t2.cancelled ~= nil then return 'cancelled', t2.cancelled end return 'ok', ... end) @@ -701,9 +729,9 @@ function Scope:try_op(ev) end ---@param ev Op ----@return '"ok"'|'"failed"'|'"cancelled"', ... +---@return "ok"|"failed"|"cancelled", ... function Scope:try(ev) - assert(runtime.current_fiber(), 'scope:try must be called from inside a fibre') + assert(runtime.current_fiber(), 'scope:try must be called from inside a fiber') return op.perform_raw(self:try_op(ev)) end @@ -721,107 +749,107 @@ end -- Boundaries ---------------------------------------------------------------------- --- scope.run(body_fn, ...) -> (status, report, ...) --- on ok: 'ok', rep, ...body results... --- on not ok: st, rep, primary -local function run(body_fn, ...) - assert(type(body_fn) == 'function', 'scope.run expects a function body') - assert(runtime.current_fiber(), 'scope.run must be called from inside a fibre') - - local parent = current() - local child, err = parent:child() - - -- Parent is not admitting; treat as cancelled boundary. - if not child then - return 'cancelled', make_report(parent, {}), err - end +---@param body_fn fun(s:Scope, ...): ... +---@param ... any +---@return Op +local function run_op(body_fn, ...) + assert(type(body_fn) == 'function', 'scope.run_op expects a function') local args = pack(...) - local fib, prev = install_current_scope(child) - local ok, e - local results - - ok, e = safe.xpcall(function () - results = pack(body_fn(child, unpack(args, 1, args.n))) - end, tb_handler) - - restore_current_scope(fib, prev) - - if not ok then child:_record_fault(e, { tag = 'run_body_failed' }) end + return op.guard(function () + local parent = current() - -- join_op() yields: st, rep, primary - local st, rep, primary = op.perform_raw(child:join_op()) - if st == 'ok' then - local r = results or pack() - return 'ok', rep, unpack(r, 1, r.n) - end + local child = nil + local child_err = nil + local results = nil - return st, rep, primary -end + local function start_once() + if child ~= nil or child_err ~= nil then return end --- scope.with_op(build_op) -> Op producing (status, report, ...) -local function with_op(build_op) - assert(type(build_op) == 'function', 'scope.with_op expects a function') + child, child_err = parent:child() + if not child then return end - return op.guard(function () - local parent = current() - local child, err = parent:child() - if not child then - return op.always('cancelled', make_report(parent, {}), err) - end + local ok_spawn, spawn_err = child:spawn(function (s) + local ok, err = safe.xpcall(function () + results = pack(body_fn(s, unpack(args, 1, args.n))) + end, tb_handler) - local function acquire() - local fib, prev = install_current_scope(child) - return { fib = fib, prev = prev } - end + if not ok then s:_record_fault(err) end - local function release(token, aborted) - restore_current_scope(token.fib, token.prev) + s:close('body complete') + s:_start_join_worker() + end) - if aborted then - -- Losing a choice is an external abort: cancel and join deterministically. - child:cancel('aborted') - safe.pcall(function () op.perform_raw(child:join_op()) end) + if not ok_spawn then + child:_record_fault(spawn_err) + child:close('body spawn failed') + child:_start_join_worker() end end - local function use() - local ok, body = safe.xpcall(function () return build_op(child) end, tb_handler) - - if not ok then - child:_record_fault(body, { tag = 'with_build_failed' }) - return op.always('failed', child._primary_error or body) + local function complete_from_join(suspension, wrap_fn) + local out = child and child._join_outcome or nil + if not out then + local st, primary = terminal_status(child) + suspension:complete(wrap_fn, st, make_report(child, {}), primary) + return end - if type(body) ~= 'table' or getmetatable(body) ~= op.Op then - local msg = ('scope.with_op: build_op must return an Op (got %s)'):format(type(body)) - child:_record_fault(msg, { tag = 'with_build_not_op' }) - return op.always('failed', msg) + if out.st == 'ok' then + local r = results or pack() + suspension:complete(wrap_fn, 'ok', out.report, unpack(r, 1, r.n)) + else + suspension:complete(wrap_fn, out.st, out.report, out.primary) end - - return child:try_op(body) end - return op.bracket(acquire, release, use):wrap(function (body_st, ...) - local body_vals = pack(...) + local function try_fn() + local why = reject_reason(parent) + if why then + return true, 'cancelled', make_report(parent, {}), why + end + return false + end - -- join_op() yields: st, rep, primary - local join_st, rep, join_primary = op.perform_raw(child:join_op()) + local function block_fn(suspension, wrap_fn) + start_once() - if join_st ~= 'ok' then - return join_st, rep, join_primary + if not child then + suspension:complete(wrap_fn, 'cancelled', make_report(parent, {}), child_err) + return end - if body_st == 'ok' then - return 'ok', rep, unpack(body_vals, 1, body_vals.n) + local cancel_join = child._join_os:add_waiter(function () + if suspension:waiting() then complete_from_join(suspension, wrap_fn) end + end) + suspension:add_cleanup(cancel_join) + + if child._join_outcome and suspension:waiting() then + complete_from_join(suspension, wrap_fn) end + end + + local ev = op.new_primitive(nil, try_fn, block_fn) - return body_st, rep, body_vals[1] + return ev:on_abort(function () + if not child then return end + child:cancel('aborted') + child:_start_join_worker() + safe.pcall(function () op.perform_raw(child:join_op()) end) end) end) end +---@param body_fn fun(s:Scope, ...): ... +---@param ... any +---@return '"ok"'|'"failed"'|'"cancelled"', ScopeReport, any ... +local function run(body_fn, ...) + assert(type(body_fn) == 'function', 'scope.run expects a function body') + assert(runtime.current_fiber(), 'scope.run must be called from inside a fiber') + return op.perform_raw(run_op(body_fn, ...)) +end + ---------------------------------------------------------------------- -- Public API ---------------------------------------------------------------------- @@ -831,8 +859,8 @@ return { current = current, Scope = Scope, - run = run, - with_op = with_op, + run = run, + run_op = run_op, cancelled = cancelled, is_cancelled = is_cancelled, diff --git a/tests/test_scope.lua b/tests/test_scope.lua index d9e9a9a..99a790f 100644 --- a/tests/test_scope.lua +++ b/tests/test_scope.lua @@ -20,12 +20,12 @@ local function assert_contains(hay, needle, msg) end ------------------------------------------------------------------------------- --- 0. Outside-fibre current() snapshot +-- 0. Outside-fiber current() snapshot ------------------------------------------------------------------------------- do local r = scope_mod.root() - assert(scope_mod.current() == r, 'outside fibres, current() should be root/global scope') + assert(scope_mod.current() == r, 'outside fibers, current() should be root/global scope') end ------------------------------------------------------------------------------- @@ -34,7 +34,7 @@ end local function test_current_scope_run_restores() local root = scope_mod.root() - assert(scope_mod.current() == root, 'test harness fibre should see current() == root') + assert(scope_mod.current() == root, 'test harness fiber should see current() == root') local outer_ref, inner_ref @@ -61,7 +61,7 @@ local function test_current_scope_run_restores() assert(type(rep) == 'table' and rep.id ~= nil, 'outer scope.run should return a report') assert(outer_val == 'outer-result', 'outer scope.run should return body results directly') - assert(scope_mod.current() == root, 'after scope.run returns, current() should restore to root in this fibre') + assert(scope_mod.current() == root, 'after scope.run returns, current() should restore to root in this fiber') assert(outer_ref ~= nil and inner_ref ~= nil, 'outer/inner scopes should have been captured') assert(outer_ref ~= inner_ref, 'outer and inner scopes must differ') @@ -196,7 +196,7 @@ end ------------------------------------------------------------------------------- local function test_cancellation_sentinel_and_non_failure() - -- (A) cancellation escaping a fibre should not mark failure + -- (A) cancellation escaping a fiber should not mark failure local st, rep, primary = scope_mod.run(function (s) local c = cond_mod.new() @@ -210,7 +210,7 @@ local function test_cancellation_sentinel_and_non_failure() end) assert(st == 'cancelled' and primary == 'bye', - 'cancellation escaping a fibre should yield cancelled scope, not failed') + 'cancellation escaping a fiber should yield cancelled scope, not failed') assert(rep and rep.id ~= nil, 'cancelled scope should return report') -- (B) perform should raise a distinguishable cancellation sentinel @@ -400,7 +400,7 @@ local function test_join_closes_admission_and_rejects_new_work() child:perform(blocker:wait_op()) end) - -- Start joining the child from a sibling fibre in the parent scope. + -- Start joining the child from a sibling fiber in the parent scope. s:spawn(function (_) join_started:signal() op.perform_raw(child:join_op()) @@ -430,98 +430,115 @@ local function test_join_closes_admission_and_rejects_new_work() end ------------------------------------------------------------------------------- --- 11. scope.with_op behaviour (status/report/...), failure confinement, abort +-- 11. scope.run_op behaviour (status/report/...), failure confinement, abort +-- +-- Updated for new semantics: +-- run_op(body_fn, ...) runs body_fn in a new fiber under a new child scope. +-- body_fn returns values directly (like run), not an Op. ------------------------------------------------------------------------------- -local function test_with_op_basic() +local function test_run_op_basic() local parent = scope_mod.current() local child_ref - local ev = scope_mod.with_op(function (child) + local ev = scope_mod.run_op(function (child) child_ref = child - assert(scope_mod.current() == child, 'inside with_op build_op, current() should be child scope') + assert(scope_mod.current() == child, 'inside run_op body, current() should be child scope') - return op.always(true):wrap(function () - return 99, 'ok' - end) + -- Exercise op performance within the child scope. + local a, b = child:perform(op.always(99, 'ok')) + return a, b end) local st, rep, a, b = performer.perform(ev) - assert(st == 'ok', 'with_op should return ok on success') - assert(a == 99 and b == 'ok', 'with_op should propagate body op values') - assert(rep and rep.id == child_ref._id, 'with_op report id should be child scope id') + assert(st == 'ok', 'run_op should return ok on success') + assert(a == 99 and b == 'ok', 'run_op should return body results on ok') + assert(rep and rep.id == child_ref._id, 'run_op report id should be child scope id') - assert(scope_mod.current() == parent, 'after with_op, current() should restore to parent') + assert(scope_mod.current() == parent, 'after run_op, current() should remain the parent in this fiber') local cst, cprimary = child_ref:status() - assert(cst == 'ok' and cprimary == nil, 'with_op child scope should be ok after success') + assert(cst == 'ok' and cprimary == nil, 'run_op child scope should be ok after success') end -local function test_with_op_builder_failure_confined() +local function test_run_op_body_failure_confined() local st, rep, outer_val = scope_mod.run(function (_) local child_ref - local ev = scope_mod.with_op(function (child) + local ev = scope_mod.run_op(function (child) child_ref = child - error('with_op builder failure') + error('run_op body failure') end) local wst, wrep, wprimary = performer.perform(ev) - assert(wst == 'failed', 'with_op should return failed when build_op errors') - assert_contains(tostring(wprimary), 'with_op builder failure', 'with_op primary should mention builder failure') - assert(wrep and child_ref and wrep.id == child_ref._id, 'with_op report id should be child id') + assert(wst == 'failed', 'run_op should return failed when body errors') + assert_contains(tostring(wprimary), 'run_op body failure', 'run_op primary should mention body failure') + assert(wrep and child_ref and wrep.id == child_ref._id, 'run_op report id should be child id') return 'outer-ok' end) - assert(st == 'ok', 'outer scope should remain ok when with_op build fails') + assert(st == 'ok', 'outer scope should remain ok when run_op fails') assert(rep and rep.id ~= nil, 'outer scope should return report') assert(outer_val == 'outer-ok', 'outer scope should return body results') end -local function test_with_op_abort_on_choice() +local function test_run_op_abort_on_choice() local child_ref - local st, rep, outer_val = scope_mod.run(function (_) - local ev_with = scope_mod.with_op(function (child) + local st, rep, outer_val = scope_mod.run(function (s) + local ready = cond_mod.new() + + -- Ensure the choice takes the blocking path so run_op actually starts. + s:spawn(function (_) + runtime.yield() + ready:signal() + end) + + local ev_with = scope_mod.run_op(function (child) child_ref = child - return op.never() + -- Block until cancelled by abort. + child:perform(op.never()) + -- If cancellation works, we should never reach here. + return 'unexpected' end) - local ev_choice = op.choice(ev_with, op.always('right')) + local ev_right = ready:wait_op():wrap(function () return 'right' end) + + local ev_choice = op.choice(ev_with, ev_right) local res = performer.perform(ev_choice) - assert(res == 'right', "choice should pick the always('right') arm") + assert(res == 'right', "choice should pick the 'right' arm once signalled") return res end) - assert(st == 'ok', 'outer scope should remain ok when with_op arm loses a choice') + assert(st == 'ok', 'outer scope should remain ok when run_op arm loses a choice') assert(rep and rep.id ~= nil, 'outer scope should return report') assert(outer_val == 'right', 'outer scope should return choice result') - assert(child_ref ~= nil, 'with_op should have created a child scope') + assert(child_ref ~= nil, 'run_op should have created a child scope on the blocking path') local cst, cprimary = child_ref:status() - assert(cst == 'cancelled', 'with_op child should be cancelled when aborted by choice loss') - assert(cprimary == 'aborted', "with_op aborted child primary should be 'aborted'") + assert(cst == 'cancelled', 'run_op child should be cancelled when aborted by choice loss') + assert(cprimary == 'aborted', "run_op aborted child primary should be 'aborted'") end -local function test_with_op_child_fibre_failure() +local function test_run_op_child_fiber_failure() local st, rep, outer_val = scope_mod.run(function (_) - local ev = scope_mod.with_op(function (child) + local ev = scope_mod.run_op(function (child) child:spawn(function (_) - error('with_op child fibre failure') + error('run_op child fiber failure') end) - return op.always('ok') + return 'ok' end) local wst, wrep, wprimary = performer.perform(ev) - assert(wst == 'failed', 'with_op should return failed when a child fibre fails') - assert_contains(tostring(wprimary), 'with_op child fibre failure', - 'with_op primary should mention the child fibre failure') - assert(wrep and wrep.id ~= nil, 'with_op should return a report even on failure') + assert(wst == 'failed', 'run_op should return failed when a child fiber fails') + assert_contains(tostring(wprimary), 'run_op child fiber failure', + 'run_op primary should mention the child fiber failure') + assert(wrep and wrep.id ~= nil, 'run_op should return a report even on failure') return 'outer-ok' end) - assert(st == 'ok', 'outer scope should remain ok after with_op child fibre failure') + assert(st == 'ok', 'outer scope should remain ok after run_op child fiber failure') assert(rep and rep.id ~= nil, 'outer scope should return report') assert(outer_val == 'outer-ok', 'outer scope should return body results') end @@ -546,10 +563,11 @@ local function run_all_tests() test_join_closes_admission_and_rejects_new_work() - test_with_op_basic() - test_with_op_builder_failure_confined() - test_with_op_abort_on_choice() - test_with_op_child_fibre_failure() + test_run_op_basic() + test_run_op_body_failure_confined() + test_run_op_abort_on_choice() + test_run_op_child_fiber_failure() + test_run_op_abort_on_choice() end ------------------------------------------------------------------------------- @@ -563,8 +581,8 @@ local function main() local root = scope_mod.root() - -- Run the suite in a fibre whose current scope is the root. - -- Capture any failure ourselves so it does not become an uncaught fibre error, + -- Run the suite in a fiber whose current scope is the root. + -- Capture any failure ourselves so it does not become an uncaught fiber error, -- and so we can always stop the scheduler. root:spawn(function (_) local ok, err = xpcall(function () From c0517ec5491873d5cb3b8b777353950b7ead309a Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Mon, 22 Dec 2025 03:26:14 +0000 Subject: [PATCH 6/8] neatening scope, editing examples + docs --- DESIGN-NOTES.md | 721 ++++++------------ EXECUTION.md | 308 ++++---- README.md | 252 +++--- STRUCTURED-CONCURRENCY.md | 451 +++++------ ...tured-concurrency-and-fail-fast-scopes.lua | 2 +- examples/05-cancel-subprocess.lua | 2 +- examples/06-scope-command-shared-pipe.lua | 71 +- ...07-subprocess-with-structured-shutdown.lua | 20 +- src/coxpcall.lua | 91 ++- src/fibers/scope.lua | 228 +++--- tests/test_scope.lua | 5 +- 11 files changed, 886 insertions(+), 1265 deletions(-) diff --git a/DESIGN-NOTES.md b/DESIGN-NOTES.md index 60dc1b7..862f9ef 100644 --- a/DESIGN-NOTES.md +++ b/DESIGN-NOTES.md @@ -11,15 +11,15 @@ It is aimed at readers who are already comfortable with concurrent programming. `fibers` provides: - a cooperative scheduler and lightweight fibers; -- an algebra of *operations* (“Ops”) for describing blocking, choice and cancellation; +- an algebra of *operations* (“Ops”) for describing blocking, choice and abort behaviour; - structured concurrency scopes with fail-fast supervision; -- a small set of high-level primitives (sleep, channels, I/O streams, processes). +- a small set of 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. +- treat lifetimes (scopes, fibers, I/O, child processes) as part of the same coordination model; +- 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. @@ -29,28 +29,28 @@ The same machinery is used for in-memory primitives (channels, timers), I/O, and The design draws on several existing models: -- **CSP**: +- **CSP** - synchronous channels and rendezvous; - - emphasis on message-passing and composition. + - emphasis on message passing and composition. -- **Concurrent ML (CML)**: +- **Concurrent ML (CML)** - first-class events and an algebra for combining them; - - choice over multiple events with cancellation via negative acknowledgements. + - choice over multiple events with abort behaviour (negative acknowledgements). -- **Structured concurrency** (e.g. Trio, Kotlin coroutines, OCaml Eio): +- **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): +- **Actor/supervision systems** (e.g. Erlang/OTP) - failures organised along a supervision tree; - - emphasis on “let it fail” and restarts at boundaries. + - “let it fail” with recovery 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. +- a scope tree for lifetime and failure accounting; +- CSP-style channels expressed as Ops; +- I/O and process execution expressed as further Ops. --- @@ -63,463 +63,249 @@ The scheduler (`fibers.runtime`) manages **fibers**: lightweight, cooperatively 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. +- yield control only by *performing* Ops (directly or via helpers such as sleep, channels, I/O); +- do not run in parallel 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. +The scheduler works with task sources (poller, timers, etc.) which re-schedule fibers when external conditions change. + +`fibers.runtime` also provides an error pump interface (`wait_fiber_error`) so uncaught fiber errors can be attributed and handled at a higher layer (scopes). ### 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. +- how to check readiness without blocking; +- how to register interest and suspend; +- how to resume the fiber when ready. 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.). +- `try()` is a non-blocking probe returning: + - `true, ...results...` when ready; or + - `false` when not ready (must block); +- `block(suspension, wrap)` registers the suspension with some external mechanism (timer wheel, poller, waitset, etc.). -On top of this, the library provides an algebra of combinators: +On top of primitives, the library provides 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`. +- `op.choice(a, b, ...)` – wait for the first ready leaf; +- `op.named_choice{ name = op, ... }`, `op.boolean_choice(a, b)`; +- `op.guard(f)` – build an Op lazily (at perform time); +- `op.with_nack(f)` and `:on_abort(f)` – abort behaviour for losing arms in a choice; +- `op.bracket(acquire, release, use)` – ensure release runs on both success and abort; +- `op.always(...)` / `op.never()` – immediate and never-ready events; +- helper forms: `race`, `first_ready`. 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). +There are two relevant ways to perform Ops in normal code: -- `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. +- `fibers.perform(op)` (via `fibers.performer.perform`) + - must be called from inside a fiber; + - uses the current scope when available (and therefore honours cancellation and fail-fast semantics); + - otherwise falls back to `op.perform_raw(op)`. -- `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). +- `op.perform_raw(op)` + - executes an Op directly in the current fiber without consulting scope state; + - used by scope internals (notably join/finalisation) and other carefully controlled paths. -Normal user-facing code is expected to use `fibers.perform` or `Scope:perform` rather than `op.perform_raw`. +Most user-facing code should use `fibers.perform` (or `fibers.try_perform` when status-first handling is required). ### 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 `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 finalisation handlers. - -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; - - 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: - -- `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 `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. 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 - -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. +Scopes (`fibers.scope`) are supervision domains and form a tree. -This pattern is used for: +A scope provides: -- channels; -- I/O streams; -- poller and exec backends; -- other primitives that need to integrate with the scheduler. +- **admission gating**: `close(reason)` stops new work being admitted (spawn/child); join also closes admission; +- **downward cancellation**: `cancel(reason)` closes admission and cancels attached children; +- **fail-fast semantics**: the first non-cancellation error becomes the primary failure, and triggers cancellation of the scope to stop siblings; +- **deterministic join**: join runs in a join worker and uses `op.perform_raw` so it is not interrupted by the scope’s own cancellation; +- **finalisers**: LIFO `scope:finally(fn)` handlers run during join. ---- - -## 4. I/O architecture - -The I/O stack is layered to separate platform-neutral abstractions from platform-specific backends. - -### 4.1 Streams and `StreamBackend` +Scopes track a status with the observable values: -`fibers.io.stream` defines a buffered `Stream` over an abstract `StreamBackend`. +- `"running"` (pre-join, no failure/cancellation recorded) +- `"failed"` (primary failure recorded) +- `"cancelled"` (cancellation reason recorded) +- `"ok"` (only after join completes successfully) -A `StreamBackend` is expected to provide: +A scope also carries a `report` snapshot produced at join: -- `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. +```lua +report = { + id = , + extra_errors = { ... }, -- secondary errors after the primary is established + children = { ... }, -- child outcomes (each includes nested report) +} +``` -### 4.2 Poller and readiness +#### Current scope attribution -`fibers.io.poller.core` defines a `Poller`: +Scope attribution is fiber-local: -- 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. +* inside a fiber: `Scope.current()` returns the fiber’s scope (defaulting to the process root); +* outside a fiber: `Scope.current()` returns the process root. -As a task source: +There is no separate “global current scope” distinct from root. This keeps attribution rules simple and avoids cross-context leakage. -- `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. +A weak-key map (fiber → scope) is used to attribute uncaught runtime fiber errors to the owning scope. -The `Poller` module (`fibers.io.poller`) chooses a backend at load time from: +#### Failure and cancellation policy -- `fibers.io.poller.epoll` – Linux epoll backend via FFI; -- `fibers.io.poller.select` – luaposix-based poll/select backend. +The scope uses a single primary record: -Both implement: +* on failure: the scope records `_failed_primary` and then calls `cancel(_failed_primary)`; +* cancellation reason becomes the value propagated downwards to child scopes; +* subsequent errors, once a primary has been established, are appended to `report.extra_errors` and do not replace the primary. -- `new_backend()`; -- `poll(state, timeout_ms, rd_waitset, wr_waitset)`; -- optional `on_wait_change`, `close_backend`, `is_supported`. +A cancellation sentinel (`fibers.cancelled`) is used for cancellation-as-control-flow across error channels. Escaping cancellation is treated as cancellation, not failure. -The scheduler registers the `Poller` singleton as a task source, and calls its `schedule_tasks` method when needed. +#### Join and finalisation -### 4.3 FD backends, files and sockets +Join is represented as an Op: -`fibers.io.fd_backend.core` defines a generic `FdBackend`: +* `Scope:join_op()` becomes ready once join has completed and the scope has reached a terminal state; +* it yields: -- 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()`. +```lua +st, report, primary_or_nil +``` -It also defines module-level helpers: +Finalisers run during join, after: -- 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()`; +1. admission is closed; +2. the scope’s internal waitgroup drains (all spawned fibers complete); +3. attached child scopes join in attachment order. -- 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)`. +Finalisers are called as: -Two concrete backends are provided and selected by `fibers.io.fd_backend`: +```lua +fn(aborted, st, primary_if_failed_or_nil) +``` -- `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. +where: -- `fd_backend.posix`: - - uses luaposix (`posix.unistd`, `posix.fcntl`, `posix.sys.socket`, etc.); - - provides equivalent functionality without FFI. +* `aborted` is `true` when `st ~= "ok"`; +* `st` is `"ok"|"failed"|"cancelled"`; +* the third argument is the primary value only when `st == "failed"`. -### 4.4 Top-level file and socket modules +If a finaliser raises: -`fibers.io.file` builds on `Stream` and `FdBackend`: +* if the scope would otherwise be ok, the first such error becomes the primary failure; +* if the scope is already failed or cancelled, the error is recorded as a secondary error. -- `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`). +#### Scope-aware operation performance -- `open(filename, mode?, perms?)`: - - uses backend `open_file`; - - wraps the resulting fd via `fdopen`. +Scopes integrate with Ops via a “race body vs not-ok” pattern. -- `pipe()`: - - uses backend `pipe()` and wraps the read and write ends as separate streams. +* `Scope:try_op(ev)` returns an Op that yields: -- `mktemp(prefix, perms?)`: - - uses backend `mktemp`. +```lua +"ok", ...results... +"failed", primary +"cancelled", reason +``` -- `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)`. +* `Scope:try(ev)` performs `try_op(ev)` (must be called inside a fiber). +* `Scope:perform(ev)` returns results on ok; on failure it raises the primary; on cancellation it raises a cancellation sentinel. -- `init_nonblocking(fd)`: - - delegates to backend `set_nonblock`. +The core rule is: successful results are only returned when the scope remains ok; if the scope has failed or been cancelled, the operation is treated as not-ok. -`fibers.io.socket` uses `FdBackend` sockets: +### 3.4 Scope boundaries as values -- `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`. +Scope boundaries are exposed in two forms: -- `listen_unix(path, opts?)`: - - binds and listens on an AF_UNIX path; - - optionally provides ephemeral semantics (unlink on close). +* `Scope.run(body_fn, ...)` -- `connect_unix(path, stype?, protocol?)`: - - creates a socket, connects, and wraps the connection as a full-duplex `Stream`. + * runs `body_fn(child_scope, ...)` in a fresh child scope; + * waits until that scope joins; + * returns status-first: -Underlying system calls are isolated in `FdBackend` implementations. The `file` and `socket` modules themselves see only: +```lua +st, report, ... -- on ok: ... are results +st, report, primary -- on not-ok +``` -- `FdBackend` functions such as `open_file`, `pipe`, `socket`, `bind`, `listen`, `accept`; -- the `Stream` abstraction. +* `Scope.run_op(body_fn, ...)` -They do not depend on any specific syscall API and do not call FFI or luaposix directly. + * returns an Op that performs the same boundary; + * suitable for use in `choice`/`race` (timeouts, cancellation triggers, etc.); + * on abort (losing a choice), the child scope is cancelled with reason `"aborted"` and then joined deterministically. -### 4.5 Process execution and ExecBackend +A notable design choice is that the boundary Op is intentionally not “fast-path eager”: its primitive `try` path does not attempt completion without blocking. This simplifies correctness and avoids subtle partial-state completion races; the boundary is driven by the child join. -`fibers.exec` provides structured process execution with scope-bound lifetime. +### 3.5 Waitsets and `waitable` -A `Command` encapsulates a process configuration and its lifecycle. It is constructed via: +`fibers.wait` provides infrastructure for building blocking primitives. -- `exec.command{ ...spec... }`; or -- `exec.command("prog", "arg1", "arg2", ...)` (positional argv). +#### Waitset -The spec includes: +`Waitset` is a keyed set of scheduler tasks: -- `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). +* `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`. -Internally, `fibers.exec` normalises these into `ExecStreamConfig` values and delegates to an `ExecBackend` selected by `fibers.io.exec_backend`: +It is used by poller backends (read/write readiness keyed by fd), and by process backends (waiters keyed by pid or pidfd). -- `ExecBackend.start(spec)` returns: - - backend state (including `pid`); - - streams (parent ends for any pipes created). +#### `waitable(register, step, wrap)` -`Command` methods include: +`waitable` builds an Op from: -- 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. +* a non-blocking `step()` returning: -Lifecycle Ops: + * `true, ...` when ready; or + * `false` when not ready; +* a `register(task, suspension, leaf_wrap)` that arranges for `task:run()` to be called when progress may have occurred. -- `run_op()`: - - ensures the process has been spawned; - - waits on `backend:wait_op()` and updates command status; - - returns `(status, code, signal, err)`. +It ensures that: -- `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)`. +* the operation participates correctly in `choice` and abort behaviour; +* outstanding registrations are cancelled on abort via `token:unlink()`. -- `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)`. +This pattern is used for timers, poller readiness, stream I/O, socket accept/connect, and process completion. -- `combined_output_op()`: - - rewires stderr to `"stdout"` (if not already a pipe/stream); - - delegates to `output_op()`. +--- -Scope integration: +## 4. I/O architecture -- `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 `finally` handler that: - - on scope exit: - - performs a best-effort `shutdown_op`; - - closes any owned stdio streams; - - closes the backend state. +The I/O stack is layered to separate platform-neutral abstractions from platform-specific backends. -Actual process management is delegated to `ExecBackend` implementations via `fibers.io.exec_backend.core.build_backend`. Two are provided: +### 4.1 Streams and `StreamBackend` -- `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. +`fibers.io.stream` defines a buffered `Stream` over an abstract `StreamBackend`. -- `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`. +A `StreamBackend` is expected to provide: -Both use the shared `exec_backend.stdio` module to: +* `read_string(max)` -> `data|nil, err|nil`; +* `write_string(data)` -> `bytes_written|nil, err|nil`; +* `on_readable(task)` / `on_writable(task)` -> `WaitToken`; +* `close()`, and optionally `seek`, `nonblock`, `block`, `fileno`, `filename`. -- map `ExecStreamConfig` values to child and parent file descriptors; -- close child-only fds in the parent; -- build parent-side `Stream`s via `file.fdopen`. +`Stream` exposes: -As with files and sockets, `fibers.exec` itself does not call syscalls directly. It depends on: +* core Ops: `read_string_op`, `write_string_op`, etc.; +* derived Ops: `read_line_op`, `read_exactly_op`, `read_all_op`; +* Lua-compatible `read_op`/`write_op` forms; +* synchronous wrappers calling `fibers.perform`, therefore respecting scope semantics. -- the `ExecBackend` abstraction; -- the `Stream` abstraction and scope/event machinery. +### 4.2 Poller and readiness -### 4.6 Portability and platform boundaries +A poller is a task source that converts kernel readiness into scheduled tasks, typically through keyed waitsets. -Current backends are Unix-like: +Backends (epoll, poll/select) are intended to be interchangeable behind a stable interface. -- 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. +### 4.3 Files and sockets -The intention is that additional platforms (for example, Windows) can be supported by: +`fibers.io.file` and `fibers.io.socket` are thin layers over: -- 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. +* an fd backend (`fibers.io.fd_backend`) that performs system calls and integrates with the poller; +* `Stream` as the user-facing buffered interface. -Application code using the top-level I/O and exec APIs should not need to change. +Socket accept/connect are expressed as Ops using `waitable`, so they can be composed in `choice` and respect scope cancellation. --- @@ -529,43 +315,31 @@ Application code using the top-level I/O and exec APIs should not need to change 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. +* an uncaught error in any fiber is attributed to that fiber’s current scope; +* the first such error becomes the scope’s primary failure and triggers cancellation of the scope; +* cancellation is propagated down the tree to attached children. -Users can still catch and handle expected errors locally. Unexpected errors bubble to scope boundaries by default. +This supports “let it fail” within a scope, and boundary-based reporting. ### 5.2 Cancellation as an event -Cancellation is expressed as part of the event algebra: +Cancellation is integrated into the event algebra: -- each scope has a cancellation condition; -- `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. +* a scope provides `fault_op`, `cancel_op`, and `not_ok_op`; +* `Scope:try_op(ev)` races the body against scope not-ok and re-checks after completion; +* `fibers.perform` therefore returns results only when the scope remains ok. -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. +The same approach is used in higher-level facilities, such as timeouts (race an operation against `sleep.sleep_op`) and aborting entire subtrees (abort behaviour on `run_op`). ### 5.3 Finalisers and resource cleanup -Each scope maintains LIFO finalisers via `scope:finally(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. +Scopes provide LIFO finalisers via `scope:finally(fn)`: -Errors in finalisers: +* run exactly once during join (after child fibers drain, and after joining attached children); +* are passed enough information to distinguish normal exit from failure/cancellation; +* errors in finalisers are captured and recorded as primary or secondary errors according to whether the scope was otherwise ok. -- convert `"ok"` into `"failed"` and record an error; or -- 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. +This is the primary mechanism for tying external resources to a unit of work. --- @@ -573,92 +347,69 @@ Errors in finalisers: ### 6.1 Futures and async/await -In a typical futures/async model: +In many future-based models: -- work is represented as futures attached to specific operations; -- lifetime and cancellation are usually per-future concerns; -- structured concurrency is often added on top. +* a future is the primary unit of concurrency and cancellation; +* structured concurrency is layered on. 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. +* the primary representation of “waiting” is the Op; +* cancellation is primarily a scope property, not an operation-local property; +* lifetimes are grouped into a scope tree, with boundaries as explicit results. ### 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. +The combination of fibers and channels is similar in spirit to Go. The main differences are: -However: +* selection is expressed via the event algebra (`choice`, `named_choice`) rather than a language `select`; +* scopes enforce lifetime and cancellation boundaries for groups of fibers; +* blocking operations are explicit values (Ops) which can be composed without helper fibers. -- 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/supervision systems -### 6.3 Actor systems +The scope tree resembles a supervision tree: -The scope tree resembles a supervision tree in actor systems: +* failures are attributed to a domain and prompt coordinated shutdown; +* cleanup is deterministic via join and finalisers. -- 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). +The model remains cooperative and single-scheduler, with channels/streams rather than actor mailboxes as the primary communication tools. --- ## 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, ...)`. +* 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.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)`. - -The process terminates when the main child scope of the root completes and the scheduler stops. +* use `fibers.spawn(fn, ...)` to create child fibers under the current scope; +* use `fibers.run_scope(fn, ...)` to create nested supervision domains and observe outcomes; +* use `fibers.run_scope_op(fn, ...)` when you need a scope boundary to participate in `choice`/`race`; +* perform Ops via `fibers.perform(ev)` (or `fibers.try_perform(ev)` when status-first handling is required). ### 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. +* `fibers.io.file` for file streams and pipes; +* `fibers.io.socket` for UNIX sockets; +* `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. +Avoid depending directly on platform-specific backends in application code. This keeps portability concerns confined to backend modules. ### 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; +Express coordination using the event algebra and scope boundaries: -- bind logical units of work (requests, sessions, jobs) to their own scopes: - - cancel the scope to cancel all associated work and resources; - - use `scope:finally` to tie external resources to that scope. +* race I/O against timeouts with `named_choice`; +* coordinate multiple producers/consumers through channels; +* bind requests/sessions/jobs to their own scope and cancel that scope to stop all related work and cleanup. --- @@ -670,45 +421,47 @@ The architecture is intended to be open to new backends and platforms. 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`; +* implement a backend module providing: -- add it to the candidate list in `fibers.io.poller`. + * `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 the poller selection module. ### 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`). +* implement an fd backend providing: -Existing `file`, `socket`, and `stream` modules should operate unchanged. + * non-blocking mode control; + * read/write primitives; + * readiness registration integrated with the poller; + * file helpers (open, pipe, tmpfile support) and, where relevant, socket helpers. + +Streams (`fibers.io.stream`) should not need modification. ### 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`. +* implement an exec backend providing: + + * spawn/start; + * non-blocking status polling; + * readiness registration as an Op via `waitable` patterns; + * termination/kill, and backend cleanup. -`fibers.exec` and `Command` then use this backend without modification. +The higher-level `fibers.exec` layer should remain stable. ### 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. +* the public APIs for I/O and exec are independent of any particular syscall interface; +* backends encapsulate dependencies on epoll/select, signals, fork/exec, pidfd, and so on. -This is intentional to support future cross-platform work without changing the programming model. +The intention is that new platform support is primarily a backend exercise, not a model redesign. --- @@ -716,11 +469,11 @@ This is intentional to support future cross-platform work without changing the p 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. +* adopt a CML-style event algebra (Ops) as the common representation for all blocking behaviour; +* treat scope lifetime boundaries as values (direct returns or Ops) so they compose with the same 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, delegating those concerns to pluggable backends; +* make `fibers.perform` scope-aware so application code consistently observes structured cancellation when run inside 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. +The result is a small, coherent foundation for building concurrent systems with explicit lifetime boundaries and predictable cleanup. diff --git a/EXECUTION.md b/EXECUTION.md index 7441b97..c196b7a 100644 --- a/EXECUTION.md +++ b/EXECUTION.md @@ -4,8 +4,11 @@ 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` – initialises the scheduler and root scope. -- `fibers.spawn(fn, ...)` – starts a new fiber in the *current* scope. +* `fibers.run(main_fn, ...)` – initialises the scheduler and root scope. +* `fibers.spawn(fn, ...)` – starts a new fiber in the current scope. +* `fibers.perform(op)` – performs an operation under the current scope (raising on scope failure/cancellation). +* `fibers.run_scope(body_fn, ...)` – runs a child scope and returns its outcome as values. +* `fibers.run_scope_op(body_fn, ...)` – represents a child-scope boundary as an `Op`. The process execution API itself is provided by `fibers.exec`. @@ -14,7 +17,7 @@ Typical imports: ```lua local fibers = require 'fibers' local exec = require 'fibers.exec' -```` +``` --- @@ -29,25 +32,20 @@ The execution subsystem provides: * inherit from the parent process * connect to `/dev/null` * pipe via `Stream` - * reuse another stream (including `stdout` for `stderr`) + * reuse another stream (including directing `stderr` to `stdout`) * 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. +The underlying implementation uses platform-specific backends (for example `pidfd` on Linux when available, or a `SIGCHLD`-based fallback). 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. +`exec.command` must be called from inside a fiber. In practice this means inside the function passed to `fibers.run`, inside a fiber started by `fibers.spawn`, or inside a child scope started by `fibers.run_scope`. Typical pattern: @@ -60,13 +58,12 @@ fibers.run(function(scope) 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()) + local out, st, code, sig, err = fibers.perform(cmd:output_op()) - if status == "ok" or (status == "exited" and code_or_sig == 0) then + if st == "exited" and code == 0 then print("ls output:\n" .. out) else - print("ls failed:", status, code_or_sig, err) + print("ls failed:", st, code, sig, err) end end) ``` @@ -74,14 +71,16 @@ 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. +* Use `fibers.perform(op)` to run `Op`s in a way that respects scope cancellation and fail-fast semantics. +* The command’s lifetime is bound to the current scope: when that scope joins, the process is shut down and its resources cleaned up. + +If you need non-raising scope-aware execution, use `fibers.try_perform(op)`, which returns a status tag first (see below). --- ## Constructing commands -The main entry point is: +The main entry point is `exec.command`. It supports both table and positional forms: ```lua local exec = require 'fibers.exec' @@ -101,16 +100,16 @@ local cmd = exec.command{ local cmd2 = exec.command("ls", "-l", "/") ``` -Both forms create a `Command` object. In table form the fields are: +In table form, the fields are typically: * `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` +* `env` – environment variables (`string -> string|nil`) +* `flags` – backend-specific flags (for example `setsid`) * `stdin`, `stdout`, `stderr` – stdio configuration (see below) -* `shutdown_grace` – timeout in seconds for graceful shutdown, default `1.0` +* `shutdown_grace` – grace period in seconds for shutdown, default implementation-defined -There is also a set of setter methods which can be used before the command is started: +There may also be setter methods which can be used before the command is started: ```lua cmd:set_cwd("/var/log") @@ -121,7 +120,7 @@ cmd:set_cwd("/var/log") :set_shutdown_grace(5.0) ``` -All setters return `self` and will raise if the command has already been started. +(All setters are expected to raise if the command has already started.) --- @@ -139,7 +138,7 @@ Allowed string modes: * `"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. +Passing a `Stream` instance uses the underlying file descriptor for that stream. In that case the `Command` does not own the stream and will not close it automatically. Example configurations: @@ -154,51 +153,51 @@ local cmd1 = exec.command{ stderr = "null", } --- Pipe stdout to a stream, inherit stderr +-- Direct stdout to an existing stream; inherit stderr local out_file = assert(file.open("out.log", "w")) local cmd2 = exec.command{ "my-tool", - stdout = out_file, -- user-supplied stream + stdout = out_file, -- user-supplied stream } --- Capture stdout and stderr together +-- Capture stdout and stderr together (merged) local cmd3 = exec.command{ "my-tool", stdout = "pipe", - stderr = "stdout", -- merged with stdout + stderr = "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. +If you set `stdout = "pipe"` or `stderr = "pipe"`, the backend will create `Stream`s for you, and the `Command` will typically own and close them during cleanup. --- ## Inspecting command state -The main introspection methods are: +The main introspection methods are typically: ```lua -local status, code_or_sig, err = cmd:status() -local pid = cmd:pid() -local argv_copy = cmd:argv() +local st, code_or_sig, err = cmd:status() +local pid = cmd:pid() +local argv_copy = cmd:argv() ``` -The status is one of: +Status values are typically: * `"pending"` – created but not yet started. -* `"running"` – process has been started and is still running. +* `"running"` – process started and still running. * `"exited"` – process exited normally. -* `"signalled"` – process terminated due to a signal. -* `"failed"` – failure to start or manage the process (for example `exec` error). +* `"signalled"` – process terminated by a signal. +* `"failed"` – failed to start or manage the process. -For `"exited"` and `"signalled"` the second result is: +For `"exited"` and `"signalled"`: * 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. +For `"failed"` the error is typically 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`, and so on), or when you explicitly request a piped stream. +Many implementations start the process lazily: the process may only be started when you first perform `run_op`, `shutdown_op`, `output_op`, or when you request piped streams. --- @@ -207,15 +206,15 @@ Note that the process is only started when you first use one of the operations b 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() +local stdin_stream, e1 = cmd:stdin_stream() +local stdout_stream, e2 = cmd:stdout_stream() +local stderr_stream, e3 = cmd:stderr_stream() ``` -Behaviour: +Typical 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 a user-supplied stream, you get that stream back. * 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`. @@ -235,10 +234,9 @@ 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) + fibers.spawn(function() while true do - local line, lerr = child_scope:perform(out_stream:read_line_op()) + local line, lerr = fibers.perform(out_stream:read_line_op()) if not line then break end @@ -246,10 +244,8 @@ fibers.run(function(scope) end end) - local status, code_or_sig, cerr = - scope:perform(cmd:run_op()) - - print("child finished:", status, code_or_sig, cerr) + local st, code, sig, cerr = fibers.perform(cmd:run_op()) + print("child finished:", st, code, sig, cerr) end) ``` @@ -260,22 +256,32 @@ end) To wait for a process to finish, use `run_op`: ```lua -local status, code, signal, err = - scope:perform(cmd:run_op()) +local st, code, sig, err = fibers.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: +* If the process has not yet been started, `run_op` starts it. +* If the process is already complete, `run_op` may resolve immediately. +* Typical results are: - * `status` – `"exited"`, `"signalled"` or `"failed"`. + * `st` – `"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. + * `sig` – signal number (for `"signalled"`) or `nil`. + * `err` – error string for `"failed"`, otherwise `nil`. + +Because `run_op` is an `Op`, it participates in `choice` and respects scope cancellation when performed via `fibers.perform`. + +### Scope-aware, non-raising form -Because `run_op` is an `Op`, it participates in `choice` and respects scope cancellation when used with `scope:perform`. +If you want to observe scope failure/cancellation as data (rather than as a raised error), use `fibers.try_perform`: + +```lua +local scope_st, a, b, c, d = fibers.try_perform(cmd:run_op()) +-- scope_st is: "ok" | "failed" | "cancelled" +-- on "ok": a,b,c,d are the run_op results +-- on not-ok: a is the scope’s primary error/reason +``` --- @@ -285,28 +291,18 @@ 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)) +local st, code, sig, err = fibers.perform(cmd:shutdown_op(5.0)) ``` -Behaviour: +Typical 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: +2. Send a polite termination request (backend-defined). +3. Wait for exit within the grace period. +4. If the process has not exited, send a more forceful kill. +5. Wait for final completion and return the final status. - * `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 (for example when you decide to stop a worker) and is also used automatically when the owning scope exits (see below). +`shutdown_op` is suitable in normal control flow (for example when stopping a worker) and is also used during scope cleanup. --- @@ -317,32 +313,27 @@ Behaviour: `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()) +local out, st, code, sig, err = fibers.perform(cmd:output_op()) ``` -Semantics: +Typical behaviour: -* If `stdout` would otherwise be inherited, `output_op` arranges for it to be a pipe instead. +* If `stdout` would otherwise be inherited, `output_op` arranges for it to be piped for capture. * 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: +* All data from the stdout stream is read (typically using stream operations). +* The operation then waits for the process to exit. +* It returns `out` plus the same status tuple as `run_op`. - * `out` – captured stdout as a string (possibly empty). - * `status`, `code`, `signal`, `err` – as for `run_op`. - -Standard usage pattern: +Example: ```lua local cmd = exec.command("sh", "-c", "echo hello; exit 0") -local out, status, code, signal, err = - scope:perform(cmd:output_op()) +local out, st, code, sig, err = fibers.perform(cmd:output_op()) -if status == "exited" and code == 0 then +if st == "exited" and code == 0 then print("child said:", out) else - print("child failed:", status, code or signal, err) + print("child failed:", st, code, sig, err) end ``` @@ -351,73 +342,43 @@ end If you want stdout and stderr merged, use `combined_output_op`: ```lua -local out, status, code, signal, err = - scope:perform(cmd:combined_output_op()) +local out, st, code, sig, err = fibers.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"`. +Typical behaviour: -The behaviour is otherwise the same as `output_op`, but with stderr directed to stdout before capture. +* `stderr` is directed to `stdout` for the lifetime of the operation (subject to existing configuration constraints). +* Capture and completion semantics match `output_op`. --- ## Sending signals directly -If you want to send a signal yourself, use `kill`: +If you want to send a signal yourself, a typical interface is: ```lua -local ok, err = cmd:kill() -- default signal (backend-chosen) -local ok2, err2 = cmd:kill("TERM") -- if the backend supports named signals +local ok, err = cmd:kill() -- default forceful kill (backend-defined) +-- or, if supported: +local ok2, err2 = cmd:kill("TERM") ``` -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"`. +Exact signal naming and behaviour are backend dependent. Prefer `shutdown_op` where you want portable behaviour. --- ## 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 `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 - * closes the backend handle. - -This has two important consequences: +A `Command` is owned by the scope in which it is created. In practical terms: -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. +* Create commands in the scope that should own their lifetime. +* Perform process ops via `fibers.perform` (or `scope:perform` if you are explicitly holding a scope). +* Do not keep `Command` instances beyond the lifetime of their owning scope. -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. +When a scope joins, its finalisers run in a non-interruptible join worker. This provides a reliable place for process cleanup: any processes and backend resources owned by the scope can be shut down deterministically even if the scope has already been cancelled. --- -## Summary of typical patterns +## Typical patterns ### Fire-and-wait with captured output @@ -427,59 +388,60 @@ 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()) + local out, st, code, sig, err = fibers.perform(cmd:output_op()) - if status == "exited" and code == 0 then + if st == "exited" and code == 0 then print(out) else - print("uname failed:", status, code or signal, err) + print("uname failed:", st, code, sig, err) end 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 -status, err, extra_errors, ...body_results -``` +### Run a long-lived process in a child scope and observe the boundary -In many cases only `status` and `err` are required. +Use `fibers.run_scope` when you want the scope outcome as values: ```lua -local fibers = require 'fibers' -local exec = require 'fibers.exec' -local scope_mod = require 'fibers.scope' +local fibers = require 'fibers' +local exec = require 'fibers.exec' fibers.run(function(scope) - local cmd = exec.command{ - "some-daemon", "--foreground", - stdout = "inherit", - stderr = "inherit", - } - - -- Run worker in a child scope for clearer lifetime boundaries. - 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()) + local scope_st, report, proc_st, code, sig, perr = + fibers.run_scope(function(child) + local cmd = exec.command{ + "some-daemon", "--foreground", + stdout = "inherit", + stderr = "inherit", + } + return fibers.perform(cmd:run_op()) end) - print("worker scope finished:", status, err) - - -- Optional: record any errors from finaliser cleanup in the worker scope. - for i, derr in ipairs(extra_errors) do - print("worker extra failure[" .. i .. "]:", derr) + if scope_st ~= "ok" then + -- The child scope failed or was cancelled; third value is the primary error/reason. + local primary = proc_st + print("worker scope not ok:", scope_st, tostring(primary)) + else + -- The child scope body returned normally; proc_st/code/sig/perr are from run_op. + print("process finished:", proc_st, code, sig, perr) end - -- child_status/code/sig/cerr are the results from cmd:run_op(), if needed. + if report and report.extra_errors and #report.extra_errors > 0 then + print("secondary errors during join:") + for i, e in ipairs(report.extra_errors) do + print(" [" .. i .. "]", e) + end + end end) ``` -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. +This pattern separates “scope outcome” from “process exit status”: the child scope can be `"ok"` even if the process exits non-zero, because that exit status is regular return data rather than a Lua error. --- -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. +## Summary + +* Create commands inside a scope (that is, inside a fiber). +* Use `fibers.perform(cmd:run_op())`, `fibers.perform(cmd:shutdown_op())`, and `fibers.perform(cmd:output_op())` as the normal interaction style. +* Use `fibers.run_scope` / `fibers.run_scope_op` when you want process work to be a structured subtree that can be joined, reported on, and composed with other events. +* Rely on scope finalisers for deterministic cleanup: processes and owned streams do not leak past the scope that created them. diff --git a/README.md b/README.md index 377f553..a2c18ed 100644 --- a/README.md +++ b/README.md @@ -3,44 +3,54 @@ `fibers` is a small library for running many concurrent tasks in one Lua process, with: - structured lifetimes (supervision scopes), -- cancellable operations (the “event algebra”), +- cancellable operations (“Ops”), - 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. +It is intended for Lua programs that need high levels of concurrency while remaining single-threaded and cooperative. -The main pattern is: +A typical entry point is: ```lua local fibers = require 'fibers' local function main(scope) - -- normal code here + -- application 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. +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** +### Fail-fast supervision scopes + +Every fiber runs in a scope. If a fiber fails, its scope records the first failure as the *primary* failure, cancels sibling work in that scope, and runs finalisers to unwind resources. + +Outcomes are reported at `fibers.run` and `fibers.run_scope` boundaries. In most cases application code can use `error`/`assert` and rely on the scope boundary for reporting. - 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”) -* **First-class operations (“Ops”)** +Anything that may block is represented as an `Op`: channel send/receive, sleeps, I/O readiness, stream reads/writes, subprocess completion, scope join, and so on. - 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. +Ops can be combined using: -* **Scopes as operations** +* `choice`, `named_choice`, `boolean_choice`, `race` +* `guard` +* `bracket` +* `:wrap` +* and (for advanced use) `with_nack` / abort behaviour. - 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. +### Scope boundaries as operations -* **Integrated I/O and subprocesses** +A scope boundary can itself be expressed as an `Op` via `fibers.run_scope_op`, so an entire subtree of work can be raced against other events (timeouts, cancellation triggers, I/O) using the same combinators used for channels and timers. - 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. +### 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. --- @@ -48,6 +58,12 @@ Inside `main` you work in terms of scopes, operations and ordinary Lua functions ### 1. Fail fast and inspect failures at the boundary +`fibers.run_scope` returns: + +* `status` (`"ok"|"failed"|"cancelled"`) +* a `report` snapshot +* either results (on `"ok"`) or the primary error/reason (on not-ok) + ```lua local fibers = require 'fibers' local sleep = require 'fibers.sleep' @@ -59,47 +75,39 @@ local function main() print("first: finished ok") end) - -- Run some work in a nested child scope and observe its outcome. - local status, err, extra_errors = fibers.run_scope(function(child) + -- Run work in a nested child scope and observe its outcome. + local status, report, value_or_primary = 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. + -- If the scope is already failed/cancelled, this becomes a secondary error. + -- If the scope was otherwise ok, this becomes the primary 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 #extra_errors > 0 then - print("child scope finaliser failures:") - for i, e in ipairs(extra_errors) do + print("child scope status:", status, tostring(value_or_primary)) + + if report and report.extra_errors and #report.extra_errors > 0 then + print("secondary errors:") + for i, e in ipairs(report.extra_errors) do print(" [" .. i .. "]", e) end end - - -- No need for extra blocking; run_scope already joins its child scope. end fibers.run(main) ``` -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. +Inside scopes you can allow errors to escape; the scope records the first failure, cancels siblings, runs finalisers, and reports the outcome at the boundary. -If the top-level `main` fails, `fibers.run(main)` re-raises the primary failure. +If the top-level `main` fails, `fibers.run(main)` raises the primary failure. --- @@ -123,7 +131,6 @@ local function main() local read_op = c:get_op() local timeout_op = sleep.sleep_op(1.0) - -- One Op representing “either read or timeout”. local ev = fibers.named_choice{ data = read_op, timeout = timeout_op, @@ -145,63 +152,61 @@ end fibers.run(main) ``` -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. +This is the intended timeout pattern: race an op against `sleep.sleep_op(...)` using `choice`/`named_choice`. --- ### 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. +Use `fibers.run_scope_op` to represent a structured subtree as an op. The op resolves when the child scope has joined (including finalisers and attached children). ```lua local fibers = require 'fibers' 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. + return fibers.run_scope_op(function(child) + -- Launch 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() + -- Return values from the boundary function if needed. + return "started" end) end - -- 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 - ) + -- Race the subtree boundary against a 1s timeout. + local ev = fibers.named_choice{ + subtree = subtree_op(), -- yields: st, rep, results/primary + timeout = sleep.sleep_op(1.0), -- yields: nothing + } - local subtree_won, status = fibers.perform(ev) + local which, st, rep, v = fibers.perform(ev) - if subtree_won then - print("subtree completed with status:", status) - else + if which == "timeout" then print("timed out; subtree scope has been cancelled") + return + end + + -- which == "subtree" + if st == "ok" then + print("subtree boundary ok:", tostring(v)) + else + print("subtree boundary not ok:", st, tostring(v)) + end + + if rep and rep.extra_errors and #rep.extra_errors > 0 then + print("subtree secondary errors:", #rep.extra_errors) 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. +If `subtree_op` loses in an outer `choice`, its child scope is cancelled with reason `"aborted"` and then joined deterministically. --- @@ -215,7 +220,7 @@ local function main() fibers.run_scope(function() local cmd = exec.command{ "ls", "-l", - stdout = "pipe", -- capture stdout + stdout = "pipe", } -- output_op returns: @@ -233,13 +238,7 @@ end fibers.run(main) ``` -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. +The `Command` is attached to the current scope. On scope exit it is shut down and its streams/handles are cleaned up. --- @@ -249,10 +248,10 @@ From user code, process operations are just more ops: they can be raced against 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.run(main)` starts the scheduler and runs `main` inside a scope under the process root. * `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. +You do not manually join fibers; scopes track obligations and join deterministically. ### Scopes @@ -261,78 +260,48 @@ A **scope** is a supervision domain with a tree structure and fail-fast semantic * 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: + * admission is closed, + * attached child scopes are cancelled, + * in-flight operations observe cancellation via `fibers.perform`, + * finalisers run in LIFO order during join. - * a status (`"running"`, `"ok"`, `"failed"`, `"cancelled"`), - * a primary error or cancellation reason, - * any additional finaliser-time failures. +Scope outcomes are reported via boundaries as: -Scopes can be: +```lua +status, report, ... -- on ok: ... are results +status, report, primary -- on not-ok: primary is error/reason +``` -* 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`. +The `report` contains: -Inside a scope, normal code typically does not wait for the scope itself; it registers finalisers and lets the parent scope observe status. +* `extra_errors`: secondary errors after the primary has been established; +* `children`: joined child outcomes with nested reports. ### 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. +An **operation** represents something that may block. -Ops are not tied to a particular fiber until they are performed. They can be: +Ops can be combined with `choice`/`race`/`named_choice` and related combinators. To perform an op: -* 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`. +* use `fibers.perform(op)` inside a fiber (raises on failure/cancellation), or +* use `fibers.try_perform(op)` when you want status-first results. ### 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: +The I/O layer wraps non-blocking file descriptors in buffered `Stream` objects and 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. +* `read_line_op`, `read_all_op`, `read_exactly_op` +* `write_string_op` -Since `Stream` operations are ops, they can be raced and cancelled like any other blocking activity. +Because these are ops, they can be raced and cancelled in the same way as channels and timers. ### Subprocesses -The **exec layer** runs subprocesses under scopes: +The exec layer runs subprocesses under scopes: -* 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. +* constructs commands and stdio wiring, +* exposes lifecycle as ops (`run_op`, `shutdown_op`, `output_op`, etc.), +* attaches clean-up to scope finalisers so processes are shut down on scope exit. --- @@ -340,18 +309,16 @@ Again, callers mostly see these as just another family of ops. 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. +* letting an error escape a fiber is a normal way to signal failure; +* the first failure becomes the scope’s primary failure and triggers cancellation of siblings; +* additional failures (including finaliser failures once the scope is already not-ok) are recorded as secondary errors in `report.extra_errors`. 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`. +* `fibers.run(main)` raises the primary failure/reason (or returns on success), +* `fibers.run_scope(fn)` returns `status, report, ...` as described above. -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. +Because failures are handled at boundaries, `pcall` is usually only needed where you intend local recovery. --- @@ -364,44 +331,45 @@ Because errors are funnelled through scopes in this way, `pcall` is rarely neede ### Backend support -`fibers` uses a pluggable backend for polling and subprocess handling. You need at least one of the following stacks available: +`fibers` uses a pluggable backend for polling and subprocess handling. You need at least one compatible stack available. * **FFI backend (preferred)** - * LuaJIT, or PUC Lua with [cffi-lua](https://github.com/q66/cffi-lua) + + * LuaJIT, or PUC Lua with 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. +OS-specific code is isolated in backends (`fibers.io.*_backend` and the poller), so adding support for other platforms is mostly 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. +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() +local function main(scope) -- application code here end 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. +From that point on, application code is structured in terms of scopes, operations, and ordinary 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! +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. Many good ideas you may find in *fibers* are quite likely to have appeared there first! diff --git a/STRUCTURED-CONCURRENCY.md b/STRUCTURED-CONCURRENCY.md index e106594..c30d64d 100644 --- a/STRUCTURED-CONCURRENCY.md +++ b/STRUCTURED-CONCURRENCY.md @@ -1,34 +1,30 @@ # 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. +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: +The focus is on: -- `fibers.run` -- `fibers.spawn` -- `fibers.run_scope` -- `fibers.scope_op` -- `fibers.current_scope` -- `fibers.perform` (for running operations under the current scope) +* `fibers.run` +* `fibers.spawn` +* `fibers.run_scope` +* `fibers.run_scope_op` +* `fibers.current_scope` +* `fibers.perform` and `fibers.try_perform` -Lower-level details of the scheduler and event algebra are covered elsewhere. +Lower-level details of the scheduler and the op algebra are covered elsewhere. --- ## 1. Overview -The library uses *structured concurrency*: +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 finalisers when a scope finishes. Finalisers are told whether the scope finished successfully or was aborted (failed or cancelled). +* Every fiber runs inside a *scope*. +* Scopes form a tree: a scope may have child scopes. +* The first non-cancellation fault in a scope becomes the **primary failure** for that scope and triggers **fail-fast cancellation** of that scope and its descendants. +* Scopes provide deterministic finalisation: attached child scopes are joined in attachment order, and finalisers run in LIFO order. -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. +A scope is a supervision context. It owns a set of fibers and resources, and it reaches a terminal state only once its obligations have drained and its finalisers have run. --- @@ -40,15 +36,15 @@ The top-level `fibers` module provides a convenient interface to this model. local fibers = require 'fibers' fibers.run(function(scope, ...) - -- scope is the top-level scope for this run + -- scope is a root-attached 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. +* Creates the scheduler and the process root scope. +* Runs `main_fn(scope, ...)` inside a fresh child scope beneath the root. +* Drives the scheduler until that child scope reaches a terminal state and joins. * 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. +* On failure or cancellation, raises the primary error / reason to the calling thread. `fibers.run` must be called from outside any fiber. @@ -57,265 +53,204 @@ end) ```lua fibers.run(function(scope) fibers.spawn(function() - -- This runs under the same current scope as 'scope' - local this_scope = fibers.current_scope() + local s = fibers.current_scope() -- ... end) end) ``` -* Spawns a new fiber *under the current scope*. -* 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. +* Spawns a new fiber under the **current scope**. +* Calls `fn(...)` in that fiber. +* Returns immediately; lifetime is managed via the scope (no join handle). ### 2.3 `fibers.run_scope(body_fn, ...)` +`fibers.run_scope` is a re-export of `Scope.run`. + ```lua -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") +local fibers = require 'fibers' + +fibers.run(function() + local st, rep, a, b = fibers.run_scope(function(child_scope, x) + fibers.spawn(function() + -- runs under child_scope + end) + return x, 42 + end, "value") + + if st == "ok" then + -- a == "value", b == 42 + else + -- on "failed"/"cancelled": a is the primary (error or reason) + end +end) ``` -`fibers.run_scope` is a re-export of `Scope.run`: +Behaviour: * Must be called from inside a fiber. -* Creates a new *child scope* of the current scope. +* 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: +* Joins the child scope deterministically and returns: ```lua - status :: "ok" | "failed" | "cancelled" - err :: primary error or cancellation reason (nil when status == "ok") - extra_failures :: array of additional errors from finalisers - ... :: results from body_fn (only when status == "ok") + status :: "ok" | "failed" | "cancelled" + report :: ScopeReport + ... :: results from body_fn (only when status == "ok") + OR primary value (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. 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)` +The `ScopeReport` has the shape: ```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) +report.id -- scope id +report.extra_errors -- array of secondary errors (see section 3) +report.children -- array of child outcomes (joined children) ``` -`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()` +Each `child` outcome contains: ```lua -local scope = fibers.current_scope() +child.id +child.status -- "ok"|"failed"|"cancelled" +child.primary +child.report -- nested ScopeReport ``` -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). +### 2.4 `fibers.run_scope_op(body_fn, ...)` -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. +`fibers.run_scope_op` is a re-export of `Scope.run_op`. -### 2.6 `fibers.perform(op)` +This returns an `Op` which, when performed, runs `body_fn` in a fresh child scope and resolves when that child scope joins. ```lua local fibers = require 'fibers' local sleep = require 'fibers.sleep' -fibers.run(function(scope) - fibers.perform(sleep.sleep_op(0.5)) +local function work_op() + return fibers.run_scope_op(function(s) + fibers.perform(sleep.sleep_op(1.0)) + return "done" + end) +end + +fibers.run(function() + local st, rep, v_or_primary = fibers.perform(work_op()) + -- st is "ok"/"failed"/"cancelled" 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. +Key points: ---- +* The child scope is cancelled and joined deterministically if the op is aborted as a losing arm in an outer `choice`. +* This is the supported way to make “run a structured sub-task” participate in the op algebra (timeouts, races, etc.). -## 3. Scope lifecycle and failure semantics +### 2.5 `fibers.current_scope()` -Each scope has a status: +```lua +local s = fibers.current_scope() +``` -* `"running"` – initial state. -* `"ok"` – scope completed successfully. -* `"failed"` – a fiber in the scope raised an error, or a finaliser failed. -* `"cancelled"` – scope was cancelled explicitly or by a parent’s failure. +* Inside a fiber: returns the scope associated with that fiber (defaults to the root if none is set). +* Outside a fiber: returns the process root scope. -Internally, a scope also tracks: +Most user code receives scopes as parameters (e.g. from `fibers.run` or `fibers.run_scope`). `fibers.current_scope()` is useful when you need access to the scope without threading it through arguments. -* a primary error or cancellation reason (`_error`), and -* any additional errors from finalisers in a list (`_extra_failures`). +### 2.6 `fibers.perform(op)` and `fibers.try_perform(op)` -### 3.1 How failures are recorded +```lua +local fibers = require 'fibers' +local sleep = require 'fibers.sleep' -If a fiber running in a scope raises a Lua error: +fibers.run(function() + fibers.perform(sleep.sleep_op(0.5)) +end) +``` -If a fiber running in a scope raises a Lua error while the scope is `"running"`: +* `fibers.perform(op)` performs an `Op` under the current scope: -* 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. + * returns results on success; + * raises on failure; and + * raises a cancellation sentinel on cancellation. +* `fibers.try_perform(op)` performs under the current scope but returns status-first: -Subsequent errors from other fibers in the same scope are treated as cancellation noise and are not accumulated. + ```lua + st, ... = fibers.try_perform(op) + -- st is "ok"|"failed"|"cancelled" + ``` -Errors raised by finalisers are handled separately and are described in section 4. +If you need to distinguish cancellation from failure when catching errors, use the helpers exposed by `fibers.scope` (`is_cancelled`, `cancel_reason`) unless you choose to re-export them from `fibers.lua`. -### 3.2 How cancellation works +--- -When a scope is cancelled: +## 3. Scope lifecycle and reporting -* 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. +A scope has two related notions of “status”: -Cancellation can arise from: +* **Observational status** from `scope:status()`: -* 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`. + ```lua + "running" + "failed", primary + "cancelled", reason + "ok" (only after join completes) + ``` -### 3.3 Observing scope status +* **Terminal status** (used by `join_op`, `run`, `run_op`): `"ok"|"failed"|"cancelled"` with failure taking precedence if both a failure and cancellation are recorded. -Scopes passed into your functions support: +### 3.1 Primary failure and secondary errors -```lua -local status, err = scope:status() -local extra_errors = scope:extra_failures() -local parent = scope:parent() -local children = scope:children() -``` +* The first non-cancellation fault becomes `_failed_primary` and triggers cancellation of the scope. +* Any subsequent faults (including failures in finalisers and late-arriving fiber errors) are recorded in `report.extra_errors`. -These methods are mainly useful for diagnostics or building higher-level abstractions; most user code interacts via `fibers.run_scope` and `fibers.perform`. +This is deliberately conservative: the primary error answers “what caused this scope to fail”, and the report provides additional diagnostics without changing the primary cause. --- ## 4. Resource management with finalisers -Each scope maintains a LIFO list of finalisers, registered with: +Finalisers are registered with: ```lua -scope:finally(function(aborted, status, err) +scope:finally(function(aborted, status, primary_or_nil) -- cleanup work end) -```` - -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). - -* 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: +Finalisers run during join, after: -```lua -local fibers = require 'fibers' -local file = require 'fibers.io.file' +1. spawned fibers in the scope have drained; +2. attached child scopes have been joined (in attachment order). -fibers.run(function(scope) - local f, err = file.open("output.log", "w") - if not f then error(err) end - - scope:finally(function(aborted, status, serr) - local ok, cerr = f:close() - if not ok then - -- 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) +Finaliser calling convention: - fibers.perform(f:write_op("hello\n")) -end) -``` +* `aborted` is `true` when terminal status is not `"ok"`; +* `status` is `"ok"|"failed"|"cancelled"`; +* `primary_or_nil` is provided only when `status == "failed"` (cancellation is not treated as failure for this argument). + +If a finaliser raises: -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. +* if the scope was otherwise `"ok"`, the finaliser error becomes the primary failure for the scope; +* otherwise the error is recorded in `extra_errors` and the primary remains unchanged. --- ## 5. Cancellation and operations -Operations (`Op`s) integrate with scopes through `fibers.perform` and, more directly, `scope:perform` / `scope:sync`: +Operations integrate with scopes through `scope:try_op` and the top-level performers: -* 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. +* If the scope is already failed or cancelled, `try_op` resolves immediately with `"failed"` or `"cancelled"`. +* Otherwise, the operation races against the scope’s “not ok” condition. +* After completion, the scope is checked again; if it transitioned while the operation completed, the result is treated as not ok. -The common interface is: +In practice: -```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. +* use `fibers.perform(op)` for direct-style code (raise-on-not-ok); +* use `fibers.try_perform(op)` or `scope:try(op)` when you want to handle failure/cancellation as data. --- -## 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. +## 6. Example: structured workers with explicit outcome ```lua local fibers = require 'fibers' @@ -325,9 +260,6 @@ local function run_workers(n) return fibers.run_scope(function(scope) for i = 1, n do 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") @@ -337,89 +269,58 @@ local function run_workers(n) end) end -fibers.run(function(scope) - local status, err, extra_failures = run_workers(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) +fibers.run(function() + local st, rep, v_or_primary = run_workers(5) + + if st == "ok" then + print("all workers completed") + elseif st == "failed" then + print("failed:", v_or_primary) + else + print("cancelled:", v_or_primary) + end + + if rep and rep.extra_errors and #rep.extra_errors > 0 then + print("secondary errors:", table.concat(rep.extra_errors, "; ")) 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: +## 7. Example: racing a structured task against a timeout ```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) - ) +local function task_op() + return fibers.run_scope_op(function(scope) + fibers.perform(sleep.sleep_op(2.0)) + return "done" + end) 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) +fibers.run(function() + local st, rep, v_or_primary = fibers.perform( + fibers.boolean_choice( + task_op(), + sleep.sleep_op(0.5):wrap(function() return "timeout" end) + ) + ) + -- Interpret results based on the op you chose to race. 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: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. +(How you tag results is up to you; the key point is that `run_scope_op` composes as an `Op`.) --- -## 9. Unscoped errors +## 8. 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. +Most user code runs inside scopes created through `fibers.run`, `fibers.spawn`, and `fibers.run_scope`. -The behaviour for *unscoped* fiber errors can be customised via: +The runtime can still encounter fibers that are not associated with a scope (for example, internal fibers or externally spawned fibers that do not install scope attribution). Uncaught errors from such fibers are passed to the unscoped error handler: ```lua fibers.set_unscoped_error_handler(function(fib, err) @@ -428,17 +329,17 @@ fibers.set_unscoped_error_handler(function(fib, err) 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`. +The default handler writes to stderr. --- -## 10. Summary +## 9. 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:finally` to attach resource cleanup to the scope lifetime. -* Use `fibers.perform` to run operations so that cancellation and failure follow the scope tree. +* Use `fibers.run` once at the top level. +* Use `fibers.spawn` to start concurrent fibers under the current scope. +* Use `fibers.run_scope` when you want a sub-task with its own scope and a status/report outcome. +* Use `fibers.run_scope_op` to race or compose a structured sub-task within the op algebra. +* Use `scope:finally` to attach resource cleanup to a scope’s lifetime. +* Use `fibers.perform` / `fibers.try_perform` to run ops so 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. +This keeps lifetimes bounded, failure and cancellation explicit, and cleanup deterministic. diff --git a/examples/04-structured-concurrency-and-fail-fast-scopes.lua b/examples/04-structured-concurrency-and-fail-fast-scopes.lua index 7a861eb..69cb20b 100644 --- a/examples/04-structured-concurrency-and-fail-fast-scopes.lua +++ b/examples/04-structured-concurrency-and-fail-fast-scopes.lua @@ -26,7 +26,7 @@ end local function main() print('Main: starting child scope') - local status, value_or_primary, _ = run_scope(function (child_scope) + local status, _, value_or_primary, _ = run_scope(function (child_scope) spawn(sometimes_fails, 'flaky', 0.3, 4) spawn(sibling, 'sibling', 0.2) diff --git a/examples/05-cancel-subprocess.lua b/examples/05-cancel-subprocess.lua index b52d689..19b1913 100644 --- a/examples/05-cancel-subprocess.lua +++ b/examples/05-cancel-subprocess.lua @@ -32,7 +32,7 @@ local function main() -- Run the subprocess and its helper fibers inside a child scope. -- Use run_scope so we can interpret status and primary at a clear boundary. - local st, value_or_primary, _ = fibers.run_scope(function (s) + local st, _, value_or_primary = fibers.run_scope(function (s) print('[subscope] starting child process') ------------------------------------------------------------------ diff --git a/examples/06-scope-command-shared-pipe.lua b/examples/06-scope-command-shared-pipe.lua index 89d2fb3..4c106d5 100644 --- a/examples/06-scope-command-shared-pipe.lua +++ b/examples/06-scope-command-shared-pipe.lua @@ -1,3 +1,15 @@ +-- 06-scope-command-shared-pipe.lua +-- +-- Demonstrates: +-- * parent scope owning a shared pipe +-- * a reader fiber in the parent scope draining that pipe +-- * a child scope boundary run as an Op (fibers.run_scope_op) +-- * racing the child scope against a timeout +-- +-- Key point for the revised scope.run_op semantics: +-- The boundary body must PERFORM any Op it wants to run. +-- Returning an Op value merely returns that Op as a value; it does not execute it. + package.path = '../src/?.lua;' .. package.path local fibers = require 'fibers' @@ -6,8 +18,8 @@ local exec = require 'fibers.io.exec' local file = require 'fibers.io.file' local run_scope_op = fibers.run_scope_op -local named_choice = fibers.named_choice -local perform = fibers.perform +local named_choice = fibers.named_choice +local perform = fibers.perform local function main(parent_scope) ---------------------------------------------------------------------- @@ -18,8 +30,9 @@ local function main(parent_scope) -- Finaliser runs only after the scope has drained spawned fibers and joined children. parent_scope:finally(function () print('[parent] finaliser: closing streams (runs after reader fiber has finished)') - assert(r_stream:close()) - assert(w_stream:close()) + -- Best-effort close; guard against double-close from the body. + pcall(function () assert(r_stream:close()) end) + pcall(function () assert(w_stream:close()) end) end) ---------------------------------------------------------------------- @@ -44,12 +57,14 @@ local function main(parent_scope) ---------------------------------------------------------------------- -- Child scope as an Op: command writes ticks to the shared stream -- - -- run_scope_op yields: - -- scope_st, value_or_primary, report - -- where on scope_st == 'ok', value_or_primary is a packed table of - -- cmd:run_op() results: { [1]=cmd_st, [2]=code, [3]=signal, [4]=err, n=4 } + -- IMPORTANT (revised semantics): + -- run_scope_op yields: + -- on ok: 'ok', report, ...body_results... + -- on not ok: st, report, primary + -- + -- Therefore the child body must PERFORM cmd:run_op() and return its results. ---------------------------------------------------------------------- - local child_scope_op = run_scope_op(function (_) + local child_scope_op = run_scope_op(function (_child_scope) 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]] @@ -60,47 +75,43 @@ local function main(parent_scope) } print('[child] starting command') - return cmd:run_op() + + -- Run the command under the child scope and return its results as values. + -- Assumed contract for cmd:run_op() when performed: + -- cmd_st, code, signal, err + local cmd_st, code, signal, cmd_err = fibers.perform(cmd:run_op()) + return cmd_st, code, signal, cmd_err end) ---------------------------------------------------------------------- -- Race the child scope against a timeout ---------------------------------------------------------------------- - local timeout_op = sleep.sleep_op(3):wrap(function () - return 'timeout' - end) - local ev = named_choice { child_scope_done = child_scope_op, - timeout = timeout_op, + timeout = sleep.sleep_op(3), } - local which, a, b, c = perform(ev) + -- named_choice returns: which, ...winner_results... + local which, st, _, v1, v2, v3, v4 = perform(ev) if which == 'timeout' then print('[parent] choice: timeout (child scope was aborted and joined by run_scope_op)') else - local scope_st, value_or_primary, _ = a, b, c - - if scope_st == 'ok' then - local vals = value_or_primary - local cmd_st = vals[1] - local code = vals[2] - local signal = vals[3] - local cmd_err = vals[4] - + if st == 'ok' then + local cmd_st, code, signal, cmd_err = v1, v2, v3, v4 print('[parent] choice: child_scope_done', 'cmd_st=', cmd_st, 'code=', code, 'signal=', signal, 'err=', cmd_err ) - -- report is available if you want to inspect child outcomes - -- print('[parent] child report id:', report.id) + -- Report is available if you want to inspect child outcomes. + -- print('[parent] child report id:', rep.id) else + local primary = v1 print('[parent] child scope ended:', - 'scope_st=', scope_st, - 'primary=', value_or_primary + 'scope_st=', st, + 'primary=', primary ) end end @@ -113,7 +124,7 @@ local function main(parent_scope) -- The surrounding scope join (performed by fibers.run) will wait -- until the reader fiber exits, then run finalisers, then return. ---------------------------------------------------------------------- - assert(w_stream:close()) + pcall(function () assert(w_stream:close()) end) print('[parent] returning from main (reader may still be draining)') end diff --git a/examples/07-subprocess-with-structured-shutdown.lua b/examples/07-subprocess-with-structured-shutdown.lua index bd093bd..5ae0ce6 100644 --- a/examples/07-subprocess-with-structured-shutdown.lua +++ b/examples/07-subprocess-with-structured-shutdown.lua @@ -6,12 +6,10 @@ local exec = require 'fibers.io.exec' local run = fibers.run local run_scope = fibers.run_scope -local unpack = rawget(table, 'unpack') or _G.unpack - local function main() -- Put the subprocess in its own child scope so that any failure or - -- cancellation is neatly contained and reported at the boundary. - local st, value_or_primary, _ = run_scope(function () + -- cancellation is contained and reported at the boundary. + local st, _, a, b, c = run_scope(function () local cmd = exec.command( 'sh', '-c', "echo 'hello from child process'; " .. @@ -19,13 +17,14 @@ local function main() "echo 'goodbye from child process'" ) - -- Use the current scope's status-first API to avoid raising on cancellation. + -- Use status-first to avoid raising on cancellation/failure. local s = fibers.current_scope() local ost, output, proc_status, code, signal, perr = s:try(cmd:output_op()) if ost ~= 'ok' then -- ost is 'failed' or 'cancelled'; output/proc_status/... are not meaningful here. - return ost, output + -- Return a primary value for the boundary. + return ost end print('[subprocess] status:', proc_status, 'code:', code, 'signal:', signal, 'err:', perr) @@ -36,13 +35,12 @@ local function main() return proc_status, (code or signal), perr end) - if st == 'ok' and value_or_primary then - -- On ok, the second return is a packed results table. - local proc_status, code_or_sig, perr = unpack(value_or_primary, 1, value_or_primary.n) + if st == 'ok' then + local proc_status, code_or_sig, perr = a, b, c print('[root] child exec scope finished with:', st, proc_status, code_or_sig, perr) else - -- On failed/cancelled, the second return is the primary error/reason. - print('[root] child exec scope finished with:', st, value_or_primary) + local primary = a + print('[root] child exec scope finished with:', st, primary) end -- report is available for diagnostics/telemetry if you want it: diff --git a/src/coxpcall.lua b/src/coxpcall.lua index fb426ae..2b09945 100644 --- a/src/coxpcall.lua +++ b/src/coxpcall.lua @@ -1,4 +1,13 @@ -- coxpcall.lua +-- +-- Coroutine-safe pcall/xpcall for Lua 5.1-style environments. +-- If the host already provides yield-safe pcall/xpcall (e.g. LuaJIT), this +-- module returns the native functions unchanged. +-- +-- This version aims to make Lua 5.1 tracebacks look closer to LuaJIT by: +-- * using debug.traceback(co, ...) for the failing coroutine, and +-- * splicing in “outer” call-site frames by walking the coroutine parent chain, +-- inserting a synthetic “[C]: in function 'xpcall'” boundary each hop. local M = {} @@ -16,7 +25,6 @@ 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 @@ -40,18 +48,91 @@ local function id(trace) return trace end +local function filter_outer_tb(tb) + if type(tb) ~= 'string' or tb == '' then + return nil + end + + local kept = {} + for line in tb:gmatch('[^\n]+') do + if line ~= 'stack traceback:' + and not line:match('^%s*$') + and not line:match('%(tail call%)') + and not line:find('coxpcall.lua', 1, true) + and not line:find("in function 'coroutine.resume'", 1, true) + and not line:find('handleReturnValue', 1, true) + and not line:find('performResume', 1, true) + then + kept[#kept + 1] = line + end + end + + if #kept == 0 then + return nil + end + return table.concat(kept, '\n') +end + +local function splice_chain(tb_inner, co, marker) + if type(tb_inner) ~= 'string' or tb_inner == '' then + return tb_inner + end + if not (debug and debug.traceback) then + return tb_inner + end + + marker = marker or "\t[C]: in function 'xpcall'" + + local out = tb_inner + local parent = coromap[co] + + while parent do + -- Level 3: drop the debug.traceback frame and the splice helper. + local tb_outer = debug.traceback(parent, '', 3) + tb_outer = filter_outer_tb(tb_outer) or '' + + if tb_outer and tb_outer ~= '' then + out = out .. '\n' .. marker .. '\n' .. tb_outer + end + + parent = coromap[parent] + end + + return out +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, ... + end + + local e = ... + + -- Compute the failing coroutine traceback and splice in outer call-site frames. + local tb + if debug and debug.traceback then + tb = debug.traceback(co, tostring(e)) + tb = splice_chain(tb, co) else - -- xpcall semantics: run the error handler on a traceback - return false, err(debug.traceback(co, (...)), ...) + tb = tostring(e) + end + + -- Preserve idiom: xpcall(f, debug.traceback) + if err == debug.traceback then + return false, tb + end + + -- Call handler with (error_object, traceback_string). A 1-arg handler + -- will ignore the second argument. + local ok_h, handled = oldpcall(err, e, tb) + if not ok_h then + -- If the handler itself faults, xpcall reports that fault. + return false, handled end + return false, handled end if coroutine.status(co) == 'suspended' then diff --git a/src/fibers/scope.lua b/src/fibers/scope.lua index 5dc1f58..618d130 100644 --- a/src/fibers/scope.lua +++ b/src/fibers/scope.lua @@ -90,22 +90,27 @@ end -- Error normalisation policy (xpcall handlers) ---------------------------------------------------------------------- ----@param kind '"body"'|'"join"'|'"finaliser"' ----@return fun(e:any): any +---@param kind 'body'|'join'|'finaliser' +---@return fun(e:any, tb:string|nil): any local function make_xpcall_handler(kind) - return function (e) + return function (e, tb) if is_cancelled(e) then if kind == 'join' then local msg = 'join raised cancellation: ' .. tostring(cancel_reason(e)) - if DEBUG then return debug.traceback(msg, 2) end + if DEBUG then + -- Prefer a supplied traceback; otherwise fall back to local stack. + return tb or debug.traceback(msg, 2) + end return msg end - -- body/finaliser: propagate cancellation sentinel for control flow return e end local msg = tostring(e) - if DEBUG then return debug.traceback(msg, 2) end + if DEBUG then + -- Prefer failing-coroutine traceback if supplied. + return tb or debug.traceback(msg, 2) + end return msg end end @@ -120,7 +125,7 @@ local finaliser_handler = make_xpcall_handler('finaliser') ---@class ScopeChildOutcome ---@field id integer ----@field status "ok"|"failed"|"cancelled" +---@field status 'ok'|'failed'|'cancelled' ---@field primary any ---@field report ScopeReport @@ -130,14 +135,10 @@ local finaliser_handler = make_xpcall_handler('finaliser') ---@field children ScopeChildOutcome[] ---@class ScopeJoinOutcome ----@field st "ok"|"failed"|"cancelled" +---@field st 'ok'|'failed'|'cancelled' ---@field primary any ---@field report ScopeReport ----@class ScopeTerminal ----@field failed any|nil -- primary failure (string/number) if failed ----@field cancelled any|nil -- cancellation reason if cancelled - ---@class Scope ---@field _id integer ---@field _parent Scope|nil @@ -147,7 +148,8 @@ local finaliser_handler = make_xpcall_handler('finaliser') ---@field _closed boolean ---@field _close_reason any|nil ---@field _close_os Oneshot ----@field _terminal ScopeTerminal|nil +---@field _failed_primary any|nil -- primary failure (string/number) if failed +---@field _cancel_reason any|nil -- cancellation reason if cancelled ---@field _cancel_os Oneshot ---@field _extra_errors any[] ---@field _fault_os Oneshot @@ -191,24 +193,22 @@ end -- Current scope install/restore (fiber-local only) ---------------------------------------------------------------------- ----@param s Scope ----@return any fib_or_nil, Scope|nil prev local function install_current_scope(s) - local fib = current_fiber() - if not fib then - return nil, nil - end + local fib = assert(current_fiber(), 'scope internal invariant violated: no current fiber') local prev = fiber_scopes[fib] fiber_scopes[fib] = s return fib, prev end ----@param fib any|nil ----@param prev Scope|nil local function restore_current_scope(fib, prev) - if fib then - fiber_scopes[fib] = prev - end + fiber_scopes[fib] = prev +end + +local function xpcall_in_scope(self, handler, f) + local fib, prev = install_current_scope(self) + local ok, res = safe.xpcall(f, handler) + restore_current_scope(fib, prev) + return ok, res end ---------------------------------------------------------------------- @@ -260,11 +260,10 @@ local function oneshot_value_op(is_ready, os, get_values, on_block) end ---@param self Scope ----@return "ok"|"failed"|"cancelled", any +---@return 'ok'|'failed'|'cancelled', any local function terminal_status(self) - local t = self._terminal - if t and t.failed ~= nil then return 'failed', t.failed end - if t and t.cancelled ~= nil then return 'cancelled', t.cancelled end + if self._failed_primary ~= nil then return 'failed', self._failed_primary end + if self._cancel_reason ~= nil then return 'cancelled', self._cancel_reason end return 'ok', nil end @@ -284,10 +283,8 @@ end ---@return string|nil local function reject_reason(self) if self._join_outcome ~= nil or self._join_started then return 'scope is joining' end - - local t = self._terminal - if t and t.failed ~= nil then return 'scope has failed' end - if t and t.cancelled ~= nil then return 'scope is cancelled' end + if self._failed_primary ~= nil then return 'scope has failed' end + if self._cancel_reason ~= nil then return 'scope is cancelled' end if self._closed then return 'scope is closed' end return nil @@ -303,9 +300,8 @@ function Scope:status() local out = self._join_outcome if out then return out.st, out.primary end - local t = self._terminal - if t and t.failed ~= nil then return 'failed', t.failed end - if t and t.cancelled ~= nil then return 'cancelled', t.cancelled end + if self._failed_primary ~= nil then return 'failed', self._failed_primary end + if self._cancel_reason ~= nil then return 'cancelled', self._cancel_reason end return 'running', nil end @@ -332,11 +328,9 @@ local function new_scope(parent) _order = {}, _wg = waitgroup.new(), - _closed = false, - _close_reason = nil, - _close_os = oneshot.new(), + _closed = false, + _close_os = oneshot.new(), - _terminal = nil, _cancel_os = oneshot.new(), _extra_errors = {}, @@ -344,7 +338,6 @@ local function new_scope(parent) _finalisers = {}, _join_started = false, - _join_outcome = nil, _join_os = oneshot.new(), }, Scope) @@ -353,9 +346,8 @@ local function new_scope(parent) parent._order[#parent._order + 1] = s -- Downward cancellation propagates immediately to new children. - local pt = parent._terminal - if pt and pt.cancelled ~= nil then - s:cancel(pt.cancelled) + if parent._cancel_reason ~= nil then + s:cancel(parent._cancel_reason) end end @@ -466,97 +458,58 @@ function Scope:cancel(reason) -- Cancellation implies admission is closed. self:close(reason) - local t = self._terminal - if not t then - t = { failed = nil, cancelled = nil } - self._terminal = t - end - - if t.cancelled == nil then - t.cancelled = (reason ~= nil) and reason or 'scope cancelled' + if self._cancel_reason == nil then + self._cancel_reason = (reason ~= nil) and reason or 'scope cancelled' self._cancel_os:signal() end -- Cancel attached children (snapshot avoids mutation hazards). local snap = snapshot_children_set(self) - for i = 1, #snap do snap[i]:cancel(t.cancelled) end + for i = 1, #snap do snap[i]:cancel(self._cancel_reason) end end ----@param err any function Scope:_record_fault(err) if is_cancelled(err) then - self:cancel(cancel_reason(err)) - return + return self:cancel(cancel_reason(err)) end - -- Normalise error to a reportable primitive (string/number). - local e = err - if type(e) ~= 'string' and type(e) ~= 'number' then - e = tostring(e) - end + local e = (type(err) == 'string' or type(err) == 'number') and err or tostring(err) - local t = self._terminal - if t and t.failed ~= nil then - -- Subsequent faults are recorded as extra errors. + if self._failed_primary ~= nil then self._extra_errors[#self._extra_errors + 1] = e return end - if not t then - t = { failed = nil, cancelled = nil } - self._terminal = t - end - - -- First fault becomes primary; fail-fast by cancelling. - t.failed = e + self._failed_primary = e self._fault_os:signal() - -- Ensure cancellation is requested (without overriding an existing reason). - if t.cancelled == nil then - t.cancelled = e - self._cancel_os:signal() - end - - -- Downward cancellation stops siblings/children. - self:cancel(t.cancelled) + -- single source of truth for cancellation + downward cascade + self:cancel(e) end ---@return Op function Scope:cancel_op() return oneshot_value_op( - function () - local t = self._terminal - return t ~= nil and t.cancelled ~= nil - end, + function () return self._cancel_reason ~= nil end, self._cancel_os, - function () - local t = self._terminal - return 'cancelled', t and t.cancelled or nil - end + function () return 'cancelled', self._cancel_reason end ) end ---@return Op function Scope:fault_op() return oneshot_value_op( - function () - local t = self._terminal - return t ~= nil and t.failed ~= nil - end, + function () return self._failed_primary ~= nil end, self._fault_os, - function () - local t = self._terminal - return 'failed', t and t.failed or nil - end + function () return 'failed', self._failed_primary end ) end ---@return Op function Scope:not_ok_op() return op.choice(self:fault_op(), self:cancel_op()):wrap(function () - local t = self._terminal - if t and t.failed ~= nil then return 'failed', t.failed end - return 'cancelled', t and t.cancelled or nil + if self._failed_primary ~= nil then return 'failed', self._failed_primary end + return 'cancelled', self._cancel_reason end) end @@ -585,15 +538,11 @@ function Scope:spawn(fn, ...) self._wg:add(1) runtime.spawn_raw(function () - local fib, prev = install_current_scope(self) - - local ok, err = safe.xpcall(function () + local ok, err = xpcall_in_scope(self, tb_handler, function () return fn(self, unpack(args, 1, args.n)) - end, tb_handler) - - restore_current_scope(fib, prev) - + end) if not ok then self:_record_fault(err) end + self._wg:done() end) @@ -659,15 +608,11 @@ function Scope:_start_join_worker() self._join_started = true runtime.spawn_raw(function () - local fib, prev = install_current_scope(self) - local child_outcomes - local ok, err = safe.xpcall(function () - child_outcomes = self:_finalise_join_body() - end, join_tb_handler) - - restore_current_scope(fib, prev) + local ok, err = xpcall_in_scope(self, join_tb_handler, function () + child_outcomes = self:_finalise_join_body() + end) if not ok then self:_record_fault(err) end local st, primary = terminal_status(self) @@ -686,11 +631,8 @@ function Scope:join_op() function () return self._join_outcome ~= nil end, self._join_os, function () - local out = self._join_outcome - if out then return out.st, out.report, out.primary end - -- Defensive fallback. - local st, primary = terminal_status(self) - return st, make_report(self, {}), primary + local out = assert(self._join_outcome, 'join signalled without outcome') + return out.st, out.report, out.primary end, function () self:_start_join_worker() end ) @@ -713,14 +655,12 @@ function Scope:try_op(ev) assert_op_value(ev) return op.guard(function () - local t = self._terminal - if t and t.failed ~= nil then return op.always('failed', t.failed) end - if t and t.cancelled ~= nil then return op.always('cancelled', t.cancelled) end + if self._failed_primary ~= nil then return op.always('failed', self._failed_primary) end + if self._cancel_reason ~= nil then return op.always('cancelled', self._cancel_reason) end local body = ev:wrap(function (...) - local t2 = self._terminal - if t2 and t2.failed ~= nil then return 'failed', t2.failed end - if t2 and t2.cancelled ~= nil then return 'cancelled', t2.cancelled end + if self._failed_primary ~= nil then return 'failed', self._failed_primary end + if self._cancel_reason ~= nil then return 'cancelled', self._cancel_reason end return 'ok', ... end) @@ -729,7 +669,7 @@ function Scope:try_op(ev) end ---@param ev Op ----@return "ok"|"failed"|"cancelled", ... +---@return 'ok'|'failed'|'cancelled', ... function Scope:try(ev) assert(runtime.current_fiber(), 'scope:try must be called from inside a fiber') return op.perform_raw(self:try_op(ev)) @@ -760,22 +700,33 @@ local function run_op(body_fn, ...) return op.guard(function () local parent = current() + -- Admission fast path: return an already-ready op. + local why = reject_reason(parent) + if why then return op.always('cancelled', make_report(parent, {}), why) end + + -- Per-perform state (initially unset). local child = nil local child_err = nil local results = nil local function start_once() - if child ~= nil or child_err ~= nil then return end + if child ~= nil or child_err ~= nil then + return + end child, child_err = parent:child() - if not child then return end + if not child then + return + end local ok_spawn, spawn_err = child:spawn(function (s) local ok, err = safe.xpcall(function () results = pack(body_fn(s, unpack(args, 1, args.n))) end, tb_handler) - if not ok then s:_record_fault(err) end + if not ok then + s:_record_fault(err) + end s:close('body complete') s:_start_join_worker() @@ -789,12 +740,8 @@ local function run_op(body_fn, ...) end local function complete_from_join(suspension, wrap_fn) - local out = child and child._join_outcome or nil - if not out then - local st, primary = terminal_status(child) - suspension:complete(wrap_fn, st, make_report(child, {}), primary) - return - end + local out = assert(child and child._join_outcome, + 'scope violated: child join signalled without outcome') if out.st == 'ok' then local r = results or pack() @@ -804,13 +751,8 @@ local function run_op(body_fn, ...) end end - local function try_fn() - local why = reject_reason(parent) - if why then - return true, 'cancelled', make_report(parent, {}), why - end - return false - end + -- This op does not have a meaningful non-blocking completion path. + local function try_fn() return false end local function block_fn(suspension, wrap_fn) start_once() @@ -821,10 +763,13 @@ local function run_op(body_fn, ...) end local cancel_join = child._join_os:add_waiter(function () - if suspension:waiting() then complete_from_join(suspension, wrap_fn) end + if suspension:waiting() then + complete_from_join(suspension, wrap_fn) + end end) suspension:add_cleanup(cancel_join) + -- Defensive fast completion. if child._join_outcome and suspension:waiting() then complete_from_join(suspension, wrap_fn) end @@ -834,6 +779,7 @@ local function run_op(body_fn, ...) return ev:on_abort(function () if not child then return end + child:cancel('aborted') child:_start_join_worker() safe.pcall(function () op.perform_raw(child:join_op()) end) @@ -843,7 +789,7 @@ end ---@param body_fn fun(s:Scope, ...): ... ---@param ... any ----@return '"ok"'|'"failed"'|'"cancelled"', ScopeReport, any ... +---@return 'ok'|'failed'|'cancelled', ScopeReport, any ... local function run(body_fn, ...) assert(type(body_fn) == 'function', 'scope.run expects a function body') assert(runtime.current_fiber(), 'scope.run must be called from inside a fiber') diff --git a/tests/test_scope.lua b/tests/test_scope.lua index 99a790f..e150422 100644 --- a/tests/test_scope.lua +++ b/tests/test_scope.lua @@ -9,6 +9,7 @@ local scope_mod = require 'fibers.scope' local op = require 'fibers.op' local performer = require 'fibers.performer' local cond_mod = require 'fibers.cond' +local safe = require 'coxpcall' ------------------------------------------------------------------------------- -- Helpers @@ -222,7 +223,7 @@ local function test_cancellation_sentinel_and_non_failure() s:cancel('cancel-reason') end) - local ok, err = pcall(function () + local ok, err = safe.pcall(function () s:perform(c:wait_op()) end) @@ -585,7 +586,7 @@ local function main() -- Capture any failure ourselves so it does not become an uncaught fiber error, -- and so we can always stop the scheduler. root:spawn(function (_) - local ok, err = xpcall(function () + local ok, err = safe.xpcall(function () run_all_tests() end, function (e) return debug.traceback(tostring(e), 2) From fd91df4cdfdaf959040f360a1ef74b5cfa169cd7 Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Mon, 22 Dec 2025 04:10:54 +0000 Subject: [PATCH 7/8] comprehensive scope testing --- tests/test_scope.lua | 669 ++++++++++++++++++++++++------------------- 1 file changed, 372 insertions(+), 297 deletions(-) diff --git a/tests/test_scope.lua b/tests/test_scope.lua index e150422..192be74 100644 --- a/tests/test_scope.lua +++ b/tests/test_scope.lua @@ -1,7 +1,16 @@ ---- Tests the Scope implementation (updated for new return shapes). -print('test: fibers.scope (new return shape: st, rep, ...)') +--- Revised tests for fibers.scope (status-first: st, rep, ...). +--- +--- This suite aims to test contractual behaviour, avoid overfitting to +--- incidental implementation details, and cover concurrency edges: +--- - idempotent ops (join_op / close_op / cancel_op / fault_op) +--- - join non-interruptibility (finalisers always run) +--- - failure vs cancellation precedence +--- - admission races around run_op +--- - report structure (children ordering + nested reports) +--- - runtime uncaught error attribution path (best-effort; see note) +--- +print('test: fibers.scope (revised contract-oriented suite)') --- look one level up package.path = '../src/?.lua;' .. package.path local runtime = require 'fibers.runtime' @@ -11,6 +20,14 @@ local performer = require 'fibers.performer' local cond_mod = require 'fibers.cond' local safe = require 'coxpcall' +-- Capture unscoped fiber errors during tests. +local unscoped = { n = 0, last = nil } + +scope_mod.set_unscoped_error_handler(function (_, err) + unscoped.n = unscoped.n + 1 + unscoped.last = err +end) + ------------------------------------------------------------------------------- -- Helpers ------------------------------------------------------------------------------- @@ -20,22 +37,51 @@ local function assert_contains(hay, needle, msg) assert(hay:find(needle, 1, true), msg or ('expected to find ' .. tostring(needle))) end +local function assert_is_report(rep, msg) + assert(type(rep) == 'table', msg or 'expected report table') + assert(rep.id ~= nil, msg or 'expected report.id') + assert(type(rep.extra_errors) == 'table', msg or 'expected report.extra_errors table') + assert(type(rep.children) == 'table', msg or 'expected report.children table') +end + +local function run_in_root(fn) + -- Run the suite in a fiber whose current scope is the root. + -- Capture any failure so it does not become an uncaught fiber error. + local outcome = { ok = true, err = nil } + + local root = scope_mod.root() + root:spawn(function () + local ok, err = safe.xpcall(fn, function (e) + return debug.traceback(tostring(e), 2) + end) + outcome.ok = ok + outcome.err = err + runtime.stop() + end) + + runtime.main() + + if not outcome.ok then + error(outcome.err or 'scope tests failed') + end +end + ------------------------------------------------------------------------------- --- 0. Outside-fiber current() snapshot +-- 0. Outside-fiber current() behaviour (policy test; keep minimal) ------------------------------------------------------------------------------- -do +local function test_outside_fiber_current_is_root() local r = scope_mod.root() - assert(scope_mod.current() == r, 'outside fibers, current() should be root/global scope') + assert(scope_mod.current() == r, 'outside fibers, current() should be root scope') end ------------------------------------------------------------------------------- --- 1. Current scope installation and restoration (run / nested run) +-- 1. Current scope installation and restoration (run and nested run) ------------------------------------------------------------------------------- local function test_current_scope_run_restores() local root = scope_mod.root() - assert(scope_mod.current() == root, 'test harness fiber should see current() == root') + assert(scope_mod.current() == root, 'test fiber should see current() == root') local outer_ref, inner_ref @@ -45,41 +91,38 @@ local function test_current_scope_run_restores() local st2, rep2, a, b, c = scope_mod.run(function (s2) inner_ref = s2 - assert(scope_mod.current() == s2, 'inside nested scope.run, current() should be nested child') + assert(scope_mod.current() == s2, 'inside nested run, current() should be nested child') return 1, 2, 3 end) - assert(st2 == 'ok', 'nested scope.run should succeed') - assert(a == 1 and b == 2 and c == 3, 'nested scope.run should return body results directly') - assert(type(rep2) == 'table' and rep2.id ~= nil, 'nested scope.run should return a report') - - assert(scope_mod.current() == s, 'after nested run, current() should restore to outer child scope') + assert(st2 == 'ok', 'nested run should succeed') + assert_is_report(rep2, 'nested run should return a report') + assert(a == 1 and b == 2 and c == 3, 'nested run should return body values') + assert(scope_mod.current() == s, 'after nested run, current() should restore to outer child') return 'outer-result' end) - assert(st == 'ok', 'outer scope.run should succeed') - assert(type(rep) == 'table' and rep.id ~= nil, 'outer scope.run should return a report') - assert(outer_val == 'outer-result', 'outer scope.run should return body results directly') + assert(st == 'ok', 'outer run should succeed') + assert_is_report(rep, 'outer run should return a report') + assert(outer_val == 'outer-result', 'outer run should return body values') - assert(scope_mod.current() == root, 'after scope.run returns, current() should restore to root in this fiber') + assert(scope_mod.current() == root, 'after run returns, current() should restore to root') - assert(outer_ref ~= nil and inner_ref ~= nil, 'outer/inner scopes should have been captured') - assert(outer_ref ~= inner_ref, 'outer and inner scopes must differ') + assert(outer_ref and inner_ref and outer_ref ~= inner_ref, 'outer/inner scopes must be captured and differ') - -- join_op should be idempotent and immediate after run has joined the scope - do - local jst, jrep, jprimary = op.perform_raw(outer_ref:join_op()) - assert(jst == 'ok' and jprimary == nil, 'join_op on already-joined ok scope should be ok') - assert(jrep and jrep.id == rep.id, 'join_op report id should match') - end + -- join_op should be idempotent and immediate after run has joined. + local jst, jrep, jprimary = op.perform_raw(outer_ref:join_op()) + assert(jst == 'ok' and jprimary == nil, 'join_op on joined ok scope should be ok') + assert_is_report(jrep, 'join_op should return a report') + assert(jrep.id == rep.id, 'join_op report id should match the run report id') end ------------------------------------------------------------------------------- --- 2. Structural lifetime: attached children are joined by parent join +-- 2. Structural lifetime and join ordering (attachment order) ------------------------------------------------------------------------------- -local function test_structural_lifetime_attached_children_joined() +local function test_structural_lifetime_children_joined_in_order() local child1_ref, child2_ref local child1_done, child2_done = false, false @@ -92,133 +135,112 @@ local function test_structural_lifetime_attached_children_joined() assert(c2, 'expected child2, got err: ' .. tostring(err2)) child2_ref = c2 - local ok1, se1 = c1:spawn(function (_) + local ok1 = c1:spawn(function () runtime.yield() child1_done = true end) - assert(ok1 and se1 == nil, 'child1:spawn should be admitted') + assert(ok1, 'child1:spawn should be admitted') - local ok2, se2 = c2:spawn(function (_) + local ok2 = c2:spawn(function () runtime.yield() child2_done = true end) - assert(ok2 and se2 == nil, 'child2:spawn should be admitted') + assert(ok2, 'child2:spawn should be admitted') return 'parent-ok' end) assert(st == 'ok', 'parent scope should be ok') - assert(parent_val == 'parent-ok', 'parent run should return body results directly') - assert(type(rep) == 'table' and type(rep.children) == 'table', 'report should include children array') + assert_is_report(rep, 'parent report must be present') + assert(parent_val == 'parent-ok', 'parent run should return body values') - assert(child1_done and child2_done, 'parent join should have waited for attached children work') + assert(child1_done and child2_done, 'parent join should wait for attached children work') - assert(#rep.children == 2, 'expected two child outcomes in report') + -- Attachment order is a stated guarantee; test it. + assert(#rep.children == 2, 'expected two child outcomes') assert(rep.children[1].id == child1_ref._id, 'first child outcome should correspond to first attachment') assert(rep.children[2].id == child2_ref._id, 'second child outcome should correspond to second attachment') - assert(rep.children[1].status == 'ok', 'child1 should end ok') - assert(rep.children[2].status == 'ok', 'child2 should end ok') + assert(rep.children[1].status == 'ok' and rep.children[2].status == 'ok', 'children should end ok') - -- Joined child scopes should not admit new work - do - local ch, err = child1_ref:child() - assert(ch == nil and err ~= nil, 'joined child scope should not admit new child scopes') - end + -- Joined child scopes should reject new work (admission gated by join). + local ch, err = child1_ref:child() + assert(ch == nil and err ~= nil, 'joined child should reject new child') + assert_contains(tostring(err), 'scope is joining', 'rejection should mention joining') end ------------------------------------------------------------------------------- --- 3. Admission gate: close() and cancel() stop new spawn/child +-- 3. Admission gate: close() rejects spawn/child; close_op is idempotent ------------------------------------------------------------------------------- -local function test_admission_close_and_cancel() +local function test_admission_close_and_close_op_idempotent() local st, rep, body_val = scope_mod.run(function (s) - local ast, _ = s:admission() + local ast = s:admission() assert(ast == 'open', 'new scope admission should be open') s:close() - s:close('closed-later') + s:close('closed-later') -- policy: allow setting reason after close if none set local ast2, areason2 = s:admission() - assert(ast2 == 'closed' and areason2 == 'closed-later', 'close() should close admission and record reason') + assert(ast2 == 'closed', 'close should close admission') + assert(areason2 == 'closed-later', 'close should record a later reason if none was set earlier') local ok, err = s:spawn(function () end) assert(ok == false and err ~= nil, 'spawn should be rejected when scope is closed') - assert_contains(tostring(err), 'scope is closed', 'close admission error should mention closed') + assert_contains(tostring(err), 'scope is closed', 'close rejection should mention closed') local child, cerr = s:child() - assert(child == nil and cerr ~= nil, 'child() should be rejected when scope is closed') - assert_contains(tostring(cerr), 'scope is closed', 'child admission error should mention closed') + assert(child == nil and cerr ~= nil, 'child should be rejected when scope is closed') + assert_contains(tostring(cerr), 'scope is closed', 'child rejection should mention closed') + -- close_op should resolve and be repeatable. local cst, creason = op.perform_raw(s:close_op()) - assert(cst == 'closed' and creason == 'closed-later', 'close_op should yield closed + reason') + assert(cst == 'closed' and creason == 'closed-later', 'close_op yields closed + reason') + + local cst2, creason2 = op.perform_raw(s:close_op()) + assert(cst2 == 'closed' and creason2 == 'closed-later', 'close_op is idempotent') return 'ok' end) - assert(st == 'ok', 'scope.run should succeed') + assert(st == 'ok', 'run should succeed') + assert_is_report(rep, 'report should be present') assert(body_val == 'ok', 'body result should be returned') - assert(rep and rep.id ~= nil, 'report should be present') - - do - local st2, rep2, primary2 = scope_mod.run(function (s) - s:cancel('bye') - end) - assert(st2 == 'cancelled' and primary2 == 'bye', 'explicit cancel should yield cancelled + reason') - assert(rep2 and rep2.id ~= nil, 'cancelled scope should still produce a report') - end end ------------------------------------------------------------------------------- --- 4. scope.run boundary when parent is not admitting work +-- 4. cancel(): rejects new work; cancel_op idempotent; cancel reason policy ------------------------------------------------------------------------------- -local function test_run_when_parent_closed_is_cancelled_boundary() - local st, rep, outer_val = scope_mod.run(function (s) - s:close('no more children') - - local st2, rep2, primary2 = scope_mod.run(function (_) - return 1 - end) +local function test_cancel_and_cancel_op_idempotent() + local st, rep, primary = scope_mod.run(function (s) + s:cancel('bye') + end) - assert(st2 == 'cancelled', 'nested run should be cancelled when parent is closed') - assert_contains(tostring(primary2), 'scope is closed', 'nested run should surface admission error') - assert(rep2 and rep2.id == s._id, 'nested run report should be based on parent scope') + assert(st == 'cancelled' and primary == 'bye', 'explicit cancel yields cancelled + reason') + assert_is_report(rep, 'cancelled scope should return a report') - return 'parent-ok' + -- cancel_op should return cancelled and be idempotent (even post-join). + local st2, rep2, primary2 = scope_mod.run(function (s) + s:cancel('first') + s:cancel('second') -- current policy: first wins + local ost, oval = op.perform_raw(s:cancel_op()) + assert(ost == 'cancelled', 'cancel_op yields cancelled') + assert(oval == 'first', 'cancel reason should follow first-wins policy') end) - assert(st == 'ok', 'outer run should remain ok') - assert(outer_val == 'parent-ok', 'outer body should complete') - assert(rep and rep.id ~= nil, 'outer run should return report') + assert(st2 == 'cancelled' and primary2 == 'first', 'scope should be cancelled with first reason') + assert_is_report(rep2, 'cancelled scope should return report') end ------------------------------------------------------------------------------- --- 5. Cancellation sentinel and cancellation-as-control-flow +-- 5. Cancellation sentinel behaviour (perform raises sentinel; try returns status) ------------------------------------------------------------------------------- -local function test_cancellation_sentinel_and_non_failure() - -- (A) cancellation escaping a fiber should not mark failure +local function test_cancellation_sentinel_and_try_semantics() local st, rep, primary = scope_mod.run(function (s) local c = cond_mod.new() - local ok, err = s:spawn(function (_) - s:perform(c:wait_op()) - end) - assert(ok and err == nil, 'spawn should be admitted') - - runtime.yield() - s:cancel('bye') - end) - - assert(st == 'cancelled' and primary == 'bye', - 'cancellation escaping a fiber should yield cancelled scope, not failed') - assert(rep and rep.id ~= nil, 'cancelled scope should return report') - - -- (B) perform should raise a distinguishable cancellation sentinel - local st2, rep2, primary2 = scope_mod.run(function (s) - local c = cond_mod.new() - - s:spawn(function (_) + s:spawn(function () runtime.yield() s:cancel('cancel-reason') end) @@ -228,383 +250,436 @@ local function test_cancellation_sentinel_and_non_failure() end) assert(ok == false, 'perform should raise on cancellation') - assert(scope_mod.is_cancelled(err), 'raised value should be a cancellation sentinel') - assert(scope_mod.cancel_reason(err) == 'cancel-reason', 'cancellation sentinel should carry reason') + assert(scope_mod.is_cancelled(err), 'raised value should be cancellation sentinel') + assert(scope_mod.cancel_reason(err) == 'cancel-reason', 'sentinel should carry reason') + + local t_st, t_val = s:try(c:wait_op()) + assert(t_st == 'cancelled' and t_val == 'cancel-reason', 'try returns cancelled + reason') end) - assert(st2 == 'cancelled' and primary2 == 'cancel-reason', 'scope should be cancelled with the correct reason') - assert(rep2 and rep2.id ~= nil, 'cancelled scope should return report') + assert(st == 'cancelled' and primary == 'cancel-reason', 'scope ends cancelled with correct reason') + assert_is_report(rep, 'cancelled report must be present') end ------------------------------------------------------------------------------- --- 6. Fail-fast on first fault: siblings observe failure (not cancellation) +-- 6. Fail-fast: first fault cancels scope; siblings observe failure (not cancellation) ------------------------------------------------------------------------------- -local function test_fail_fast_and_siblings_observe_failure() +local function test_fail_fast_and_siblings_observe_failed() local observed_st, observed_primary local st, rep, primary = scope_mod.run(function (s) local c = cond_mod.new() - -- Sibling that will be interrupted by fail-fast. - s:spawn(function (_) + s:spawn(function () observed_st, observed_primary = s:try(c:wait_op()) end) - -- Failing sibling. - s:spawn(function (_) + s:spawn(function () error('boom') end) - -- Give the siblings a chance to start. runtime.yield() end) assert(st == 'failed', 'scope should fail on first fault') - assert_contains(tostring(primary), 'boom', 'primary fault should mention the failing error') + assert_contains(tostring(primary), 'boom', 'primary should mention boom') - assert(observed_st == 'failed', 'blocked sibling should observe failed (not cancelled) when a fault occurs') - assert_contains(tostring(observed_primary), 'boom', - 'blocked sibling failure should reflect the primary fault') + assert(observed_st == 'failed', 'blocked sibling should observe failed on fault') + assert_contains(tostring(observed_primary), 'boom', 'blocked sibling should observe primary fault') - -- No extra errors should be recorded (the blocked sibling returned normally). - assert(rep and type(rep.extra_errors) == 'table', 'report should include extra_errors') - assert(#rep.extra_errors == 0, 'extra_errors should be empty when siblings exit without additional faults') + assert_is_report(rep, 'report must be present') + assert(#rep.extra_errors == 0, 'extra_errors should be empty when no further faults') end ------------------------------------------------------------------------------- --- 7. cancel_op / fault_op / not_ok_op behaviour and precedence +-- 7. Failure takes precedence over cancellation (race) ------------------------------------------------------------------------------- -local function test_not_ok_ops() - local st1, rep1, primary1 = scope_mod.run(function (s) - s:spawn(function (_) +local function test_failure_precedes_cancellation() + local st, rep, primary = scope_mod.run(function (s) + s:spawn(function () runtime.yield() - s:cancel('cancelled-here') + s:cancel('cancelling') end) - - local ost, oval = op.perform_raw(s:cancel_op()) - assert(ost == 'cancelled' and oval == 'cancelled-here', 'cancel_op should yield cancelled + reason') + s:spawn(function () + runtime.yield() + error('failing') + end) + runtime.yield() + runtime.yield() end) - assert(st1 == 'cancelled' and primary1 == 'cancelled-here', 'scope should be cancelled') - assert(rep1 and rep1.id ~= nil, 'cancelled scope should return report') - local st2, rep2, primary2 = scope_mod.run(function (s) - s:spawn(function (_) + assert(st == 'failed', 'failure should take precedence over cancellation') + assert_contains(tostring(primary), 'failing') + assert_is_report(rep, 'report must be present') +end + +------------------------------------------------------------------------------- +-- 8. fault_op / not_ok_op behaviour and idempotency +------------------------------------------------------------------------------- + +local function test_fault_ops_and_not_ok_precedence() + local st1, rep1, primary1 = scope_mod.run(function (s) + s:spawn(function () runtime.yield() error('fault-here') end) local ost, oval = op.perform_raw(s:fault_op()) - assert(ost == 'failed', 'fault_op should yield failed') - assert_contains(tostring(oval), 'fault-here', 'fault_op primary should mention the fault') - end) - assert(st2 == 'failed', 'scope should be failed on fault') - assert_contains(tostring(primary2), 'fault-here', 'scope primary should mention the fault') - assert(rep2 and rep2.id ~= nil, 'failed scope should return report') + assert(ost == 'failed', 'fault_op yields failed') + assert_contains(tostring(oval), 'fault-here', 'fault_op yields primary fault') - local st3, rep3, primary3 = scope_mod.run(function (s) - s:spawn(function (_) - runtime.yield() - error('precedence-fault') - end) - - local ost, oval = op.perform_raw(s:not_ok_op()) - assert(ost == 'failed', 'not_ok_op should prefer failed if a fault occurs') - assert_contains(tostring(oval), 'precedence-fault', 'not_ok_op should carry the primary fault') + local nst, nval = op.perform_raw(s:not_ok_op()) + assert(nst == 'failed', 'not_ok_op yields failed when a fault occurs') + assert_contains(tostring(nval), 'fault-here', 'not_ok_op yields the primary fault') end) - assert(st3 == 'failed', 'scope should be failed in precedence test') - assert_contains(tostring(primary3), 'precedence-fault', 'scope primary should mention precedence-fault') - assert(rep3 and rep3.id ~= nil, 'failed scope should return report') + + assert(st1 == 'failed', 'scope should be failed on fault') + assert_contains(tostring(primary1), 'fault-here') + assert_is_report(rep1, 'failed report must be present') end ------------------------------------------------------------------------------- --- 8. Finalisers: LIFO order; cancellation sentinel in finaliser becomes fault +-- 9. Finalisers: LIFO order; join non-interruptible (finalisers run under cancel) ------------------------------------------------------------------------------- -local function test_finalisers_lifo_and_cancel_in_finaliser() +local function test_finalisers_lifo_and_join_non_interruptible() + -- (A) LIFO finalisers in the straightforward case local order = {} - local st, rep, body_val = scope_mod.run(function (s) s:finally(function () table.insert(order, 'first') end) s:finally(function () table.insert(order, 'second') end) return 'ok' end) - assert(st == 'ok', 'scope should be ok when body and finalisers succeed') - assert(body_val == 'ok', 'body result should be returned on ok') - assert(rep and rep.id ~= nil, 'ok scope should return report') - assert(#order == 2 and order[1] == 'second' and order[2] == 'first', 'finalisers should run LIFO') + assert(st == 'ok' and body_val == 'ok', 'ok scope should return body result') + assert_is_report(rep, 'report must be present') + assert(#order == 2 and order[1] == 'second' and order[2] == 'first', 'finalisers run LIFO') - local st2, rep2, primary2 = scope_mod.run(function (s) - s:finally(function () - error(scope_mod.cancelled('finaliser-cancel')) + -- (B) Join is non-interruptible: finalisers must run even if cancelled mid-join. + -- + -- This test focuses on: + -- * the CHILD reaches terminal state and runs its finalisers + -- * the PARENT does not become not-ok because the child was cancelled + local ran = false + local child_ref + + local pst, prep, pprimary = scope_mod.run(function (s) + local ch = assert(s:child()) + child_ref = ch + + local blocker = cond_mod.new() + + ch:spawn(function () + -- This should be interrupted by cancellation (via ch:perform semantics). + ch:perform(blocker:wait_op()) end) - return 'ok' + + ch:finally(function () + ran = true + end) + + -- Start joining the child in a sibling fiber. + s:spawn(function () + op.perform_raw(ch:join_op()) + end) + + -- Let join start, then cancel the child. + runtime.yield() + ch:cancel('cancel-during-join') + + -- Give the join worker time to complete. + runtime.yield() + + -- Parent body completes without error. + return 'parent-ok' end) - assert(st2 == 'failed', 'cancellation sentinel from finaliser should fail the scope') - assert(rep2 and rep2.id ~= nil, 'failed scope should return report') - assert_contains(tostring(primary2), 'finaliser raised cancellation', 'primary should mention finaliser cancellation') - assert_contains(tostring(primary2), 'finaliser-cancel', 'primary should include the cancellation reason') + -- If this fails, print what actually happened to the parent so you can diagnose. + assert(pst == 'ok', ('parent scope should remain ok in join test; got st=%s primary=%s') + :format(tostring(pst), tostring(pprimary))) + assert_is_report(prep, 'parent report must be present') + + -- Now assert the child actually finished cancelled and ran finalisers. + assert(child_ref ~= nil, 'child scope must be captured') + + -- If join has completed, status() may be 'cancelled'/'failed'/'ok'; if not, it could be 'running'. + -- We make it deterministic by calling join_op now (idempotent). + local jst, jrep, jprim = op.perform_raw(child_ref:join_op()) + assert(jst == 'cancelled', ('child should end cancelled; got %s'):format(tostring(jst))) + assert(jprim == 'cancel-during-join', 'child cancellation reason should be preserved') + assert_is_report(jrep, 'child join should return a report') + + assert(ran, 'child finaliser must run even if cancelled during join') end ------------------------------------------------------------------------------- --- 9. Reports: children outcomes and extra_errors +-- 10. Finaliser cancellation sentinel becomes fault (policy test) ------------------------------------------------------------------------------- -local function test_reports_children_and_extra_errors() +local function test_cancel_sentinel_in_finaliser_becomes_fault() local st, rep, primary = scope_mod.run(function (s) - local ch, err = s:child() - assert(ch, 'expected child scope, got: ' .. tostring(err)) - - ch:spawn(function (_) - error('child-fault') + s:finally(function () + error(scope_mod.cancelled('finaliser-cancel')) end) + return 'ok' + end) - -- LIFO: finaliser-2 runs first and becomes primary, finaliser-1 becomes extra. - s:finally(function () error('finaliser-1') end) - s:finally(function () error('finaliser-2') end) + assert(st == 'failed', 'cancellation sentinel in finaliser should fail scope (policy)') + assert_is_report(rep, 'failed report must be present') + assert_contains(tostring(primary), 'finaliser raised cancellation', 'primary should mention finaliser cancellation') + assert_contains(tostring(primary), 'finaliser-cancel', 'primary should include cancellation reason') +end - return 'body-ok' - end) +------------------------------------------------------------------------------- +-- 11. Reports: nested child report structure (depth 2) +------------------------------------------------------------------------------- - assert(st == 'failed', 'finaliser failure should fail the scope') - assert(rep and rep.id ~= nil and type(rep.children) == 'table', 'report should be present') - assert(#rep.children == 1, 'report should include one child outcome') +local function test_reports_nested_children_depth_two() + local child_ref, grand_ref - assert_contains(tostring(rep.children[1].primary), 'child-fault', - 'child outcome primary should mention child fault') + local st, rep, body_val = scope_mod.run(function (s) + local ch = assert(s:child()) + child_ref = ch + local gch = assert(ch:child()) + grand_ref = gch - assert_contains(tostring(primary), 'finaliser-2', 'primary should come from the first failing finaliser (LIFO)') + gch:spawn(function () + error('grandchild-fault') + end) - assert(type(rep.extra_errors) == 'table', 'report.extra_errors should be a table') - assert(#rep.extra_errors >= 1, 'expected at least one extra error') - local joined = table.concat(rep.extra_errors, '\n') - assert_contains(joined, 'finaliser-1', 'extra_errors should include the later finaliser failure') + return 'ok' + end) + + -- Contract: no implicit upward failure propagation. + assert(st == 'ok' and body_val == 'ok', 'parent scope should remain ok when attached children fail') + assert_is_report(rep, 'report must be present') + + -- Parent report should contain child outcome; child report should contain grandchild outcome. + assert(#rep.children == 1, 'expected one child outcome') + assert(rep.children[1].id == child_ref._id, 'child outcome id should match') + assert(rep.children[1].report and rep.children[1].report.id == child_ref._id, 'child report should be present') + + local child_rep = rep.children[1].report + assert( + type(child_rep.children) == 'table' and #child_rep.children == 1, + 'child report should include one grandchild outcome' + ) + assert(child_rep.children[1].id == grand_ref._id, 'grandchild outcome id should match') + assert(child_rep.children[1].status == 'failed', 'grandchild should have failed') + assert_contains(tostring(child_rep.children[1].primary), 'grandchild-fault', + 'grandchild primary should mention fault') end ------------------------------------------------------------------------------- --- 10. Join closes admission: once join starts, spawn/child are rejected +-- 12. Join closes admission: spawn/child rejected once joining has started ------------------------------------------------------------------------------- local function test_join_closes_admission_and_rejects_new_work() local st, rep, body_val = scope_mod.run(function (s) - local child, err = s:child() - assert(child, 'expected child scope, got: ' .. tostring(err)) + local child = assert(s:child()) local blocker = cond_mod.new() local join_started = cond_mod.new() - -- Keep the child scope busy so join has something to wait for. - child:spawn(function (_) + child:spawn(function () child:perform(blocker:wait_op()) end) - -- Start joining the child from a sibling fiber in the parent scope. - s:spawn(function (_) + s:spawn(function () join_started:signal() op.perform_raw(child:join_op()) end) - -- Wait until join has started (this is enough: join worker closes admission immediately). performer.perform(join_started:wait_op()) runtime.yield() local ok1, e1 = child:spawn(function () end) assert(ok1 == false and e1 ~= nil, 'spawn should be rejected once join has started') - assert_contains(tostring(e1), 'scope is joining', 'spawn admission error should mention joining') + assert_contains(tostring(e1), 'scope is joining', 'spawn rejection should mention joining') local c2, e2 = child:child() assert(c2 == nil and e2 ~= nil, 'child() should be rejected once join has started') - assert_contains(tostring(e2), 'scope is joining', 'child admission error should mention joining') + assert_contains(tostring(e2), 'scope is joining', 'child rejection should mention joining') - -- Allow join to complete. blocker:signal() - return 'ok' end) - assert(st == 'ok', 'join/admission test should complete ok') - assert(rep and rep.id ~= nil, 'ok scope should return report') - assert(body_val == 'ok', 'join/admission test should return ok') + assert(st == 'ok' and body_val == 'ok', 'join/admission test should complete ok') + assert_is_report(rep, 'report must be present') end ------------------------------------------------------------------------------- --- 11. scope.run_op behaviour (status/report/...), failure confinement, abort --- --- Updated for new semantics: --- run_op(body_fn, ...) runs body_fn in a new fiber under a new child scope. --- body_fn returns values directly (like run), not an Op. +-- 13. run_op: basic success, confinement of failure, and abort on choice loss ------------------------------------------------------------------------------- -local function test_run_op_basic() +local function test_run_op_basic_and_failure_confinement() local parent = scope_mod.current() local child_ref local ev = scope_mod.run_op(function (child) child_ref = child - assert(scope_mod.current() == child, 'inside run_op body, current() should be child scope') - - -- Exercise op performance within the child scope. + assert(scope_mod.current() == child, 'inside run_op body, current() should be child') local a, b = child:perform(op.always(99, 'ok')) return a, b end) local st, rep, a, b = performer.perform(ev) assert(st == 'ok', 'run_op should return ok on success') - assert(a == 99 and b == 'ok', 'run_op should return body results on ok') - assert(rep and rep.id == child_ref._id, 'run_op report id should be child scope id') + assert_is_report(rep, 'run_op should return report') + assert(a == 99 and b == 'ok', 'run_op should return body values') + assert(rep.id == child_ref._id, 'run_op report id should match child scope id') - assert(scope_mod.current() == parent, 'after run_op, current() should remain the parent in this fiber') + assert(scope_mod.current() == parent, 'after run_op, current() should remain parent in this fiber') local cst, cprimary = child_ref:status() - assert(cst == 'ok' and cprimary == nil, 'run_op child scope should be ok after success') -end - -local function test_run_op_body_failure_confined() - local st, rep, outer_val = scope_mod.run(function (_) - local child_ref + assert(cst == 'ok' and cprimary == nil, 'child scope should be ok after success') - local ev = scope_mod.run_op(function (child) - child_ref = child + -- Failure confinement: failing run_op should not fail outer scope. + local outer_st, outer_rep, outer_val = scope_mod.run(function () + local cref + local ev2 = scope_mod.run_op(function (child) + cref = child error('run_op body failure') end) - local wst, wrep, wprimary = performer.perform(ev) - assert(wst == 'failed', 'run_op should return failed when body errors') - assert_contains(tostring(wprimary), 'run_op body failure', 'run_op primary should mention body failure') - assert(wrep and child_ref and wrep.id == child_ref._id, 'run_op report id should be child id') + local wst, wrep, wprimary = performer.perform(ev2) + assert(wst == 'failed', 'run_op should yield failed on body error') + assert_contains(tostring(wprimary), 'run_op body failure') + assert_is_report(wrep, 'run_op should return report even on failure') + assert(wrep.id == cref._id, 'report id should be child id') return 'outer-ok' end) - assert(st == 'ok', 'outer scope should remain ok when run_op fails') - assert(rep and rep.id ~= nil, 'outer scope should return report') - assert(outer_val == 'outer-ok', 'outer scope should return body results') + assert(outer_st == 'ok' and outer_val == 'outer-ok', 'outer scope should remain ok') + assert_is_report(outer_rep, 'outer report must be present') end -local function test_run_op_abort_on_choice() +local function test_run_op_abort_on_choice_loss() local child_ref local st, rep, outer_val = scope_mod.run(function (s) local ready = cond_mod.new() - -- Ensure the choice takes the blocking path so run_op actually starts. - s:spawn(function (_) + -- Ensure choice blocks so run_op starts. + s:spawn(function () runtime.yield() ready:signal() end) local ev_with = scope_mod.run_op(function (child) child_ref = child - -- Block until cancelled by abort. child:perform(op.never()) - -- If cancellation works, we should never reach here. return 'unexpected' end) local ev_right = ready:wait_op():wrap(function () return 'right' end) - local ev_choice = op.choice(ev_with, ev_right) + local res = performer.perform(ev_choice) - assert(res == 'right', "choice should pick the 'right' arm once signalled") + assert(res == 'right', 'choice should select right arm once ready') return res end) - assert(st == 'ok', 'outer scope should remain ok when run_op arm loses a choice') - assert(rep and rep.id ~= nil, 'outer scope should return report') - assert(outer_val == 'right', 'outer scope should return choice result') + assert(st == 'ok' and outer_val == 'right', 'outer scope should remain ok and return right') + assert_is_report(rep, 'outer report must be present') - assert(child_ref ~= nil, 'run_op should have created a child scope on the blocking path') + assert(child_ref ~= nil, 'run_op should create child scope on blocking path') local cst, cprimary = child_ref:status() - assert(cst == 'cancelled', 'run_op child should be cancelled when aborted by choice loss') - assert(cprimary == 'aborted', "run_op aborted child primary should be 'aborted'") + assert(cst == 'cancelled', 'aborted run_op child should be cancelled') + assert(cprimary == 'aborted', "aborted child cancellation reason should be 'aborted'") end -local function test_run_op_child_fiber_failure() - local st, rep, outer_val = scope_mod.run(function (_) - local ev = scope_mod.run_op(function (child) - child:spawn(function (_) - error('run_op child fiber failure') - end) - return 'ok' +------------------------------------------------------------------------------- +-- 14. Admission race around run_op (parent closes before run_op can start) +------------------------------------------------------------------------------- + +local function test_run_op_parent_closed_before_start_is_cancelled() + local st, rep = scope_mod.run(function (s) + s:close('no more') + + local ev = scope_mod.run_op(function () + return 1 end) local wst, wrep, wprimary = performer.perform(ev) - assert(wst == 'failed', 'run_op should return failed when a child fiber fails') - assert_contains(tostring(wprimary), 'run_op child fiber failure', - 'run_op primary should mention the child fiber failure') - assert(wrep and wrep.id ~= nil, 'run_op should return a report even on failure') - - return 'outer-ok' + assert(wst == 'cancelled', 'run_op should be cancelled when parent is closed') + assert_contains(tostring(wprimary), 'scope is closed') + assert_is_report(wrep, 'run_op should return a report') + assert(wrep.id == s._id, 'run_op cancelled report should be based on parent scope') end) - assert(st == 'ok', 'outer scope should remain ok after run_op child fiber failure') - assert(rep and rep.id ~= nil, 'outer scope should return report') - assert(outer_val == 'outer-ok', 'outer scope should return body results') + assert(st == 'ok', 'outer scope should remain ok') + assert_is_report(rep, 'outer report must be present') end ------------------------------------------------------------------------------- --- Test suite entry +-- 15. Uncaught runtime fiber error attribution (best-effort) ------------------------------------------------------------------------------- -local function run_all_tests() - test_current_scope_run_restores() - test_structural_lifetime_attached_children_joined() - - test_admission_close_and_cancel() - test_run_when_parent_closed_is_cancelled_boundary() +local function test_uncaught_raw_fiber_is_unscoped_by_default() + -- Reset capture. + unscoped.n = 0 + unscoped.last = nil - test_cancellation_sentinel_and_non_failure() - test_fail_fast_and_siblings_observe_failure() + local st, rep, body_val = scope_mod.run(function (_) + runtime.spawn_raw(function () + error('raw-fiber-boom') + end) + runtime.yield() + return 'ok' + end) - test_not_ok_ops() - test_finalisers_lifo_and_cancel_in_finaliser() - test_reports_children_and_extra_errors() + -- Contract: raw fibers are not scope-attributed, so they do not fail the scope. + assert(st == 'ok' and body_val == 'ok', 'parent scope should remain ok when a raw fiber errors') + assert_is_report(rep, 'report must be present') - test_join_closes_admission_and_rejects_new_work() - - test_run_op_basic() - test_run_op_body_failure_confined() - test_run_op_abort_on_choice() - test_run_op_child_fiber_failure() - test_run_op_abort_on_choice() + -- But the unscoped handler must see the error. + assert(unscoped.n >= 1, 'unscoped handler should observe the raw fiber error') + assert_contains(tostring(unscoped.last), 'raw-fiber-boom', 'unscoped error should include raw-fiber-boom') end ------------------------------------------------------------------------------- --- Main (avoid hangs; propagate failure to luajit exit code) +-- Suite entry ------------------------------------------------------------------------------- -local function main() - io.stdout:write('Running scope tests...\n') +local function run_all_tests() + test_outside_fiber_current_is_root() - local outcome = { ok = true, err = nil } + test_current_scope_run_restores() + test_structural_lifetime_children_joined_in_order() - local root = scope_mod.root() + test_admission_close_and_close_op_idempotent() + test_cancel_and_cancel_op_idempotent() - -- Run the suite in a fiber whose current scope is the root. - -- Capture any failure ourselves so it does not become an uncaught fiber error, - -- and so we can always stop the scheduler. - root:spawn(function (_) - local ok, err = safe.xpcall(function () - run_all_tests() - end, function (e) - return debug.traceback(tostring(e), 2) - end) + test_cancellation_sentinel_and_try_semantics() + test_fail_fast_and_siblings_observe_failed() + test_failure_precedes_cancellation() - outcome.ok = ok - outcome.err = err + test_fault_ops_and_not_ok_precedence() - runtime.stop() - end) + test_finalisers_lifo_and_join_non_interruptible() + test_cancel_sentinel_in_finaliser_becomes_fault() - runtime.main() + test_reports_nested_children_depth_two() - if not outcome.ok then - error(outcome.err or 'scope tests failed') - end + test_join_closes_admission_and_rejects_new_work() - io.stdout:write('OK\n') + test_run_op_basic_and_failure_confinement() + test_run_op_abort_on_choice_loss() + test_run_op_parent_closed_before_start_is_cancelled() + + test_uncaught_raw_fiber_is_unscoped_by_default() end -main() +------------------------------------------------------------------------------- +-- Main +------------------------------------------------------------------------------- + +run_in_root(function () + io.stdout:write('Running revised scope tests...\n') + run_all_tests() + io.stdout:write('OK\n') +end) From fb69436f2440ac9082c27e9076e9324c28cc3d5b Mon Sep 17 00:00:00 2001 From: Richard Thanki Date: Tue, 23 Dec 2025 00:52:26 +0000 Subject: [PATCH 8/8] allows detachment of finalisers --- src/fibers/scope.lua | 37 ++++++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/src/fibers/scope.lua b/src/fibers/scope.lua index 618d130..ee4ad64 100644 --- a/src/fibers/scope.lua +++ b/src/fibers/scope.lua @@ -153,7 +153,7 @@ local finaliser_handler = make_xpcall_handler('finaliser') ---@field _cancel_os Oneshot ---@field _extra_errors any[] ---@field _fault_os Oneshot ----@field _finalisers function[] +---@field _finalisers any[] ---@field _join_started boolean ---@field _join_outcome ScopeJoinOutcome|nil ---@field _join_os Oneshot @@ -518,9 +518,16 @@ end ---------------------------------------------------------------------- ---@param f fun(aborted:boolean, status:string, primary:any|nil) +---@return fun() detach function Scope:finally(f) assert(type(f) == 'function', 'scope:finally expects a function') - self._finalisers[#self._finalisers + 1] = f + local rec = { fn = f } + self._finalisers[#self._finalisers + 1] = rec + + return function () + -- idempotent: dropping fn releases closure references + rec.fn = nil + end end ---------------------------------------------------------------------- @@ -582,21 +589,25 @@ function Scope:_finalise_join_body() local fs = self._finalisers for i = #fs, 1, -1 do - local f = fs[i] + local rec = fs[i] fs[i] = nil - local ok, err = safe.xpcall(function () - return f(aborted, st, (st == 'failed') and primary or nil) - end, finaliser_handler) + local f = rec and rec.fn or nil + if f then + rec.fn = nil + local ok, err = safe.xpcall(function () + return f(aborted, st, (st == 'failed') and primary or nil) + end, finaliser_handler) - if not ok then - if is_cancelled(err) then - self:_record_fault('finaliser raised cancellation: ' .. tostring(cancel_reason(err))) - else - self:_record_fault(err) + if not ok then + if is_cancelled(err) then + self:_record_fault('finaliser raised cancellation: ' .. tostring(cancel_reason(err))) + else + self:_record_fault(err) + end + st, primary = terminal_status(self) + aborted = (st ~= 'ok') end - st, primary = terminal_status(self) - aborted = (st ~= 'ok') end end