diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile deleted file mode 100644 index cd44159..0000000 --- a/.devcontainer/Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -FROM ubuntu:22.04 - -# Install needed packages and setup non-root user. Use a separate RUN statement to add your own dependencies. -ARG USERNAME=vscode -ARG USER_UID=1000 -ARG USER_GID=$USER_UID - -# Create the user -RUN groupadd --gid $USER_GID $USERNAME \ - && useradd --uid $USER_UID --gid $USER_GID -m $USERNAME \ - # - # [Optional] Add sudo support. Omit if you don't need to install software after connecting. - && apt-get update \ - && apt-get install -y sudo \ - && echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \ - && chmod 0440 /etc/sudoers.d/$USERNAME - -# ******************************************************** -# * Anything else you want to do like clean up goes here * -# ******************************************************** - -# [Optional] Set the default user. Omit if you want to keep the default as root. -USER $USERNAME \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d277a64..c3acf13 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,11 +1,7 @@ { - "build": { - "dockerfile": "Dockerfile" - // [Optional] You can use build args to set options. e.g. 'VARIANT' below affects the image in the Dockerfile - // "args": { "VARIANT": "buster" } - }, - "remoteUser": "vscode", - "postCreateCommand": "bash .devcontainer/postCreateCommand.sh", + "image": "ghcr.io/jangala-dev/mini-lua-image:bookworm", + "remoteUser": "vscode", + "postCreateCommand": "bash .devcontainer/postCreateCommand.sh", "customizations": { "vscode": { "extensions": [ diff --git a/.devcontainer/postCreateCommand.sh b/.devcontainer/postCreateCommand.sh index 36fc65d..c52d3c2 100755 --- a/.devcontainer/postCreateCommand.sh +++ b/.devcontainer/postCreateCommand.sh @@ -1,45 +1,3 @@ #!/bin/sh -sudo apt update -y - -sudo apt install -y apt-utils unzip curl wget git build-essential libreadline-dev dialog libssl-dev m4 netcat pre-commit - -# install core lua packages - -sudo apt install -y lua5.1 liblua5.1-dev luarocks - -# install luarocks packages - -sudo luarocks install bit32 -sudo luarocks install cqueues -sudo luarocks install http -sudo luarocks install luaposix -sudo luarocks install luacheck - -# install cffi-lua - -sudo apt install -y meson pkg-config cmake libffi-dev - -cd /tmp -sudo rm -rf cffi-lua -git clone https://github.com/q66/cffi-lua -mkdir cffi-lua/build -cd cffi-lua/build -sudo meson .. -Dlua_version=5.1 --buildtype=release -sudo ninja all -sudo ninja test -sudo cp cffi.so /usr/local/lib/lua/5.1/cffi.so - -cd /tmp -git clone https://github.com/LuaJIT/LuaJIT -cd LuaJIT/ -git checkout v2.1.ROLLING -make && sudo make install -sudo ln -sf "$(ls -1 /usr/local/bin/luajit-2.1* | sort | tail -n 1)" /usr/local/bin/luajit -cd /tmp -rm -rf LuaJIT - -cd /workspaces/lua-fibers -pre-commit install - exit 0 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5cb0cb6 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,171 @@ + +# see https://github.com/CppCXY/EmmyLuaCodeStyle +[*.lua] +# [basic] + +# optional space/tab +indent_style = tab +# if indent_style is space, this is valid +indent_size = 4 +# if indent_style is tab, this is valid +tab_width = 4 +# none/single/double +quote_style = single + +continuation_indent = 4 +## extend option +# continuation_indent.before_block = 4 +# continuation_indent.in_expr = 4 +# continuation_indent.in_table = 4 + +# this mean utf8 length , if this is 'unset' then the line width is no longer checked +# this option decides when to chopdown the code +max_line_length = 120 + +# optional crlf/lf/cr/auto, if it is 'auto', in windows it is crlf other platforms are lf +# in neovim the value 'auto' is not a valid option, please use 'unset' +end_of_line = auto + +# none/ comma / semicolon / only_kv_colon +table_separator_style = none + +#optional keep/never/always/smart +trailing_table_separator = keep + +# keep/remove/remove_table_only/remove_string_only +call_arg_parentheses = keep + +detect_end_of_line = false + +# this will check text end with new line +insert_final_newline = true + +# [space] +space_around_table_field_list = true + +space_before_attribute = true + +space_before_function_open_parenthesis = false + +space_before_function_call_open_parenthesis = false + +space_before_closure_open_parenthesis = true + +# optional always/only_string/only_table/none +# or true/false +space_before_function_call_single_arg = always +## extend option +## always/keep/none +# space_before_function_call_single_arg.table = always +## always/keep/none +# space_before_function_call_single_arg.string = always + +space_before_open_square_bracket = false + +space_inside_function_call_parentheses = false + +space_inside_function_param_list_parentheses = false + +space_inside_square_brackets = false + +# like t[#t+1] = 1 +space_around_table_append_operator = false + +ignore_spaces_inside_function_call = false + +# detail number or 'keep' +space_before_inline_comment = 1 + +# convert '---' to '--- ' or '--' to '-- ' +space_after_comment_dash = false + +# [operator space] +space_around_math_operator = true +# space_around_math_operator.exponent = false + +space_after_comma = true + +space_after_comma_in_for_statement = true + +# true/false or none/always/no_space_asym +space_around_concat_operator = true + +space_around_logical_operator = true + +# true/false or none/always/no_space_asym +space_around_assign_operator = true + +# [align] + +align_call_args = false + +align_function_params = true + +# true/false or always +align_continuous_assign_statement = true + +align_continuous_rect_table_field = true + +align_continuous_line_space = 1 + +align_if_branch = false + +# option none / always / contain_curly/ +align_array_table = true + +align_continuous_similar_call_args = false + +align_continuous_inline_comment = true +# option none / always / only_call_stmt +align_chain_expr = none + +# [indent] + +never_indent_before_if_condition = false + +never_indent_comment_on_if_branch = false + +keep_indents_on_empty_lines = false + +allow_non_indented_comments = false +# [line space] + +# The following configuration supports four expressions +# keep +# fixed(n) +# min(n) +# max(n) +# for eg. min(2) + +line_space_after_if_statement = max(2) + +line_space_after_do_statement = max(2) + +line_space_after_while_statement = max(2) + +line_space_after_repeat_statement = max(2) + +line_space_after_for_statement = max(2) + +line_space_after_local_or_assign_statement = keep + +line_space_after_function_statement = fixed(2) + +line_space_after_expression_statement = keep + +line_space_after_comment = keep + +line_space_around_block = keep +# [line break] +break_all_list_when_line_exceed = false + +auto_collapse_lines = false + +break_before_braces = false + +# [preference] +ignore_space_after_colon = false + +remove_call_expression_list_finish_comma = false +# keep / always / same_line / replace_with_newline / never +end_statement_with_semicolon = keep diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e971dfa..f9d9f6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,22 +4,30 @@ on: [pull_request] jobs: validate-pr: - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest + container: + image: ghcr.io/jangala-dev/mini-lua-image:bookworm + options: --user 0:0 steps: - name: Check out repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Run custom install script run: | chmod +x .devcontainer/postCreateCommand.sh ./.devcontainer/postCreateCommand.sh - - name: Run luajit 2.17 Tests in Tests Directory + - name: Run lua 5.1 Tests in Tests Directory + run: | + cd tests + lua test.lua + + - name: Run luajit 2.1 Tests in Tests Directory run: | cd tests luajit test.lua - - name: Run Linter - run: luacheck . - + # - name: Run Linter + # run: luacheck . + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f7c513 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*DS_Store +/scratch* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 33480fe..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,8 +0,0 @@ -repos: - - repo: local - hooks: - - id: luacheck - name: Luacheck - entry: luacheck - language: system - types: [lua] diff --git a/.vscode/settings.json b/.vscode/settings.json index 903c319..278ae14 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,8 @@ { - "editor.tabSize": 2, + "editor.tabSize": 4, "editor.indentSize": "tabSize", "editor.insertSpaces": true, - "editor.formatOnSave": true, + "editor.formatOnSave": false, "editor.formatOnSaveMode": "modifications", "editor.codeActionsOnSave": { "source.fixAll.eslint": "never" @@ -14,4 +14,4 @@ "[lua]": { "editor.tabSize": 4 }, - } \ No newline at end of file + } diff --git a/DESIGN-NOTES.md b/DESIGN-NOTES.md new file mode 100644 index 0000000..839e85c --- /dev/null +++ b/DESIGN-NOTES.md @@ -0,0 +1,461 @@ +# DESIGN-NOTES + +This document captures the design choices behind `fibers`, and where the seams are for extension and porting. It assumes you already know the usual concurrent-programming vocabulary, and wants to tell you what *this* library is trying to be (and what it is trying hard *not* to be). + +The short version: **Ops describe waiting**, **scopes describe lifetime**, and everything else lines up behind those two ideas. + +--- + +## 1. Overview + +`fibers` provides: + +* a cooperative scheduler and lightweight fibers; +* an algebra of *operations* (“Ops”) for blocking, choice, abort, and cleanup; +* structured concurrency scopes with fail-fast supervision; +* a small “standard kit”: sleep, channels, streams, sockets, processes. + +The key design choices: + +* **Waiting is a value**: “this might complete later” is represented as an `Op`, not as “call this and hope it doesn’t block”. +* **Lifetime is a tree**: scopes form a tree; work and resources live and die within that tree. +* **One model for everything**: in-memory primitives, kernel I/O readiness, and subprocess completion all show up as Ops, and are governed by scopes. + +If you can race a channel receive against a timeout, you can do the same with `accept()`, `read_line()`, `waitpid()`, or “join this entire subtree”. + +--- + +## 2. Heritage and influences + +This is a hybrid of several well-known ideas: + +* **CSP**: rendezvous channels; composition through message passing. +* **Concurrent ML (CML)**: first-class events; choice; “losing arms” get abort signals (negative acknowledgements). +* **Structured concurrency** (Trio, Kotlin coroutines, Eio): scopes as supervision domains; tree-shaped lifetime; cancellation as a normal termination mode. +* **Actor/supervision systems** (Erlang/OTP): organise failure; “let it fail” locally, recover at boundaries. + +`fibers` is not a direct port. It borrows the *shapes*: + +* CML-style events become **Ops**. +* a supervision tree becomes a **scope tree**. +* CSP-style channels are expressed as **Ops**. +* I/O and processes become **more Ops**. + +--- + +## 3. Core concurrency model + +### 3.1 Fibers and the scheduler + +The runtime (`fibers.runtime`) manages **fibers**: cooperatively scheduled coroutines. + +* `fibers.spawn(fn, ...)` creates a fiber under the current scope. +* A fiber yields only when it performs an op (directly or via helpers like sleep, channels, I/O). +* There is no parallel execution inside one scheduler; concurrency is interleaving, not pre-emptive multithreading. + +The scheduler also hosts **task sources** (poller, timers, etc.) which can re-schedule fibers when an external condition changes. + +Finally, the runtime exposes an error pump (`wait_fiber_error`) so uncaught fiber failures can be attributed and handled above the runtime layer (by scopes). + +### 3.2 Ops: the event algebra + +Ops (`fibers.op`) represent deferred blocking operations. A primitive op is defined by: + +* how to check readiness without blocking (`try`); +* how to arrange suspension and future wake-up (`block`); +* and a commit-phase wrapper (`wrap`) that is only applied on completion. + +Primitive ops are constructed with: + +```lua +op.new_primitive(wrap, try, block) +``` + +* `try()` is a non-blocking probe: + + * returns `true, ...results...` if ready; + * returns `false` if not ready. +* `block(suspension, wrap)` registers interest (timer wheel, poller, waitset, etc.) and must arrange eventual completion. + +Everything else is composition: + +* `op.choice(...)` / `named_choice` / `boolean_choice` / `race` +* `op.guard(f)` (lazy construction at perform-time) +* `op.with_nack(f)` and `:on_abort(f)` (losing-arm behaviour) +* `op.bracket(acquire, release, use)` / `:finally(cleanup)` +* `op.always(...)` / `op.never()` + +Ops are **passive** until performed. + +#### Performing Ops + +There are two execution modes, and they exist for a reason: + +* `fibers.perform(ev)` + + * must be called from inside a fiber; + * performs under the current scope (so cancellation and fail-fast semantics are honoured). + +* `op.perform_raw(ev)` + + * performs without consulting scope state; + * used in carefully controlled internal paths (notably join/finalisation) where you must not be interrupted by cancellation. + +User code should almost always use `fibers.perform`. There is intentionally no top-level `try_perform`: the intended style is “throw freely; handle at boundaries; clean up with `finally`”. + +### 3.3 Structured concurrency scopes + +Scopes (`fibers.scope`) are supervision domains arranged as a tree. + +A scope provides: + +* **admission gating**: `close(reason)` stops new work (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 to stop siblings; +* **deterministic join**: join runs in a join worker and uses `op.perform_raw` so finalisation is not interrupted by scope cancellation; +* **finalisers**: `scope:finally(fn)` runs during join in LIFO order. + +Observable status (informally): + +* `"running"`: active, not yet terminal +* `"failed"`: primary failure recorded +* `"cancelled"`: cancellation recorded +* terminal outcome materialises at join (`"ok"|"failed"|"cancelled"`) + +A join report has the shape: + +```lua +report = { + id = , + extra_errors = { ... }, + children = { ... }, +} +``` + +#### Current scope attribution + +Attribution is fiber-local: + +* inside a fiber: `Scope.current()` is that fiber’s scope (defaulting to root); +* outside fibers: `Scope.current()` is root. + +Uncaught runtime fiber errors are attributed using a weak-key map `fiber -> scope`, so scope accounting stays accurate without creating memory leaks. + +#### Failure and cancellation policy + +Scopes keep one “primary” record: + +* on failure: record `_failed_primary`, then cancel the scope with that value (single source of truth); +* the cancellation reason propagates down to children; +* subsequent errors are appended to `extra_errors` and do not replace the primary. + +Cancellation is represented internally using a robust sentinel (`fibers.cancelled`) so it can travel through Lua’s error channel without colliding with ordinary errors. Escaping cancellation is treated as cancellation, not failure. + +#### Join and finalisation + +Join is represented as an op: + +* `Scope:join_op()` becomes ready once the join worker finishes; +* it yields `st, report, primary_or_nil`. + +Finalisation order: + +1. admission closes; +2. the scope’s internal waitgroup drains (spawned fibers complete); +3. attached child scopes join in attachment order; +4. finalisers run in LIFO order. + +Finalisers are called as: + +```lua +fn(aborted, st, primary_if_failed_or_nil) +``` + +If a finaliser raises: + +* if the scope would otherwise be ok, the first finaliser error becomes the primary failure; +* if the scope is already failed/cancelled, it becomes a secondary error. + +#### Scope-aware op performance + +Scopes integrate with ops by racing “the body” against “scope not-ok”, and re-checking after completion: + +* `Scope:try_op(ev)` yields one of: + +```lua +"ok", ...results... +"failed", primary +"cancelled", reason +``` + +* `Scope:perform(ev)` returns results on ok; raises on failed/cancelled. + +The rule is deliberately strict: **results are only returned if the scope remains ok**. If the scope has already failed or been cancelled, performing is treated as not-ok. + +### 3.4 Scope boundaries as values + +Boundaries are exposed in two forms: + +* `Scope.run(body_fn, ...)` returns status-first: + +```lua +st, report, ... -- on ok +st, report, primary -- on not-ok +``` + +* `Scope.run_op(body_fn, ...)` returns an op that resolves when the child scope has joined. + +On abort (losing a choice), the boundary op cancels the child scope with reason `"aborted"` and then joins it deterministically. + +A design note that matters in practice: the boundary op is not “eager”. Its readiness is driven by the child join, rather than trying to opportunistically fast-path completion. This keeps correctness simple and avoids partial-state races. + +### 3.5 Waitsets and `waitable` + +`fibers.wait` is the glue for building “real” blocking primitives in a disciplined way. + +#### Waitset + +`Waitset` is a keyed set of scheduler tasks (fd, pid, object key, anything): + +* `add(key, task)` returns a token with `token:unlink()`; +* `take_one` / `take_all`; +* `notify_one` / `notify_all`; +* `clear_key`, `clear_all`, `is_empty`, `size`. + +Pollers typically key by fd and direction; process backends key by pid or pidfd. + +#### `waitable(register, step, wrap)` + +`waitable` builds an op from: + +* a non-blocking `step()`: + + * returns `true, ...` when ready; + * returns `false` when not ready; +* a `register(task, suspension, leaf_wrap)` which arranges for `task:run()` to be called when progress may have occurred. + +Crucially: + +* registrations are cancelled on abort via `token:unlink()`. + +This is the mechanism that makes “race read against timeout” safe: losing arms do not keep dangling fd interest in the poller. In practice, this pattern underpins stream I/O, socket accept/connect, and process completion. + +--- + +## 4. I/O architecture + +The I/O stack is intentionally layered so that portability work is concentrated in backends. + +### 4.1 Streams and `StreamBackend` + +`fibers.io.stream` defines a buffered `Stream` over a `StreamBackend`. + +A backend provides: + +* `read_string(max)` / `write_string(data)` +* `on_readable(task)` / `on_writable(task)` → `WaitToken` +* `close()` +* optionally `seek`, `nonblock`, `block`, `fileno`, `filename` + +`Stream` then exposes ops: + +* core: `read_string_op`, `write_string_op` +* derived: `read_line_op`, `read_exactly_op`, `read_all_op` +* Lua-compat: `read_op` / `write_op` + +Synchronous wrappers call `fibers.perform`, so scope cancellation and fail-fast behaviour apply automatically. + +### 4.2 Poller and readiness + +A poller is a scheduler task source that translates kernel readiness into scheduled tasks, typically by: + +* keeping waitsets for read and write readiness, +* polling with a timeout, +* notifying and scheduling tasks for ready keys. + +Backends (epoll, poll/select, etc.) are intended to be interchangeable behind a stable interface. + +### 4.3 Files and sockets + +`fibers.io.file` and `fibers.io.socket` are thin layers over: + +* an fd backend (`fibers.io.fd_backend`) that does syscalls and integrates with the poller; +* `Stream` as the user-facing interface. + +Socket accept/connect are expressed as ops (typically via `waitable`), so they compose with `choice` and respect scope cancellation. + +--- + +## 5. Error handling, cancellation, and lifetimes + +### 5.1 Fail-fast supervision + +Errors are organised around scopes: + +* uncaught errors in fibers are attributed to the fiber’s scope; +* the first becomes the scope’s primary failure and triggers cancellation; +* cancellation propagates down to attached child scopes. + +This supports “let it fail” locally and reporting at boundaries. + +### 5.2 Cancellation as an event + +Cancellation is not bolted on as a separate signalling system; it participates in the same model: + +* scopes provide `fault_op`, `cancel_op`, `not_ok_op`; +* `Scope:try_op(ev)` races the body against scope not-ok and re-checks after completion; +* `fibers.perform` therefore only returns results when the scope remains ok. + +Timeouts and aborting subtrees are then just ordinary compositions: + +* timeout = `choice(op, sleep_op(dt))` +* abort subtree = `run_scope_op(...)` losing in an outer choice + +### 5.3 Finalisers and cleanup + +Finalisers are the main mechanism for tying external resources to a unit of work: + +* run exactly once during join; +* receive enough context to distinguish ok/failed/cancelled; +* errors are recorded as primary/secondary depending on whether the scope was otherwise ok. + +This is a deliberate push away from “try to catch everything and limp on”. Instead: register cleanup, throw, and let the boundary report. + +--- + +## 6. Relationship to other models + +### 6.1 Futures and async/await + +In many future-based systems: + +* futures are the primary unit of concurrency/cancellation; +* structured concurrency is layered on top. + +In `fibers`: + +* the primary representation of waiting is the op; +* cancellation is primarily a scope property; +* boundaries are explicit values (direct returns or ops) that compose with the same algebra. + +### 6.2 Go-style goroutines and channels + +There are familiar similarities (fibers + channels), but: + +* selection is expressed via the op algebra (`choice`, `named_choice`), not a language `select`; +* scopes enforce lifetime and cancellation boundaries for groups of fibers; +* blocking operations are explicit values, so you can compose without “helper goroutines”. + +### 6.3 Actor/supervision systems + +The scope tree resembles a supervision tree: + +* failures are attributed to a domain and prompt coordinated shutdown; +* cleanup is deterministic via join and finalisers. + +The execution model remains single-scheduler and cooperative; channels/streams are the primary coordination tools rather than actor mailboxes. + +--- + +## 7. Intended usage patterns + +### 7.1 Entry point + +From non-fiber code: + +* call `fibers.run(main_fn, ...)`. + +Inside `main_fn(scope, ...)`: + +* use `fibers.spawn(fn, ...)` for concurrent work under the current scope; +* use `fibers.run_scope(fn, ...)` when you want a boundary with structured outcomes; +* use `fibers.run_scope_op(fn, ...)` when a boundary must participate in `choice`/`race`; +* perform ops via `fibers.perform(ev)`. + +The default posture is “throw, don’t catch”; boundaries are where you observe outcomes. + +### 7.2 I/O services + +Use the public modules: + +* `fibers.io.file` (streams, pipes, tmpfiles) +* `fibers.io.socket` (UNIX sockets) +* `fibers.io.stream` (buffered stream ops) +* `fibers.channel` (in-memory coordination) +* `fibers.sleep` (timers) + +Avoid depending on platform-specific backends in application code. Portability lives in backend modules. + +### 7.3 Coordination and cancellation + +Express coordination using ops and scope boundaries: + +* race I/O against timeouts with `named_choice` or `boolean_choice`; +* coordinate producers/consumers via channels; +* bind requests/sessions/jobs to their own scope; cancel that scope to stop and clean up *everything* related to the job. + +--- + +## 8. Extension points and porting seams + +The library is built so that “porting” is mostly a backend exercise, not a redesign. + +### 8.1 Poller backends + +To add a new kernel event mechanism: + +* implement: + + * `new_backend()` + * `poll(state, timeout_ms, rd_waitset, wr_waitset)` + * optional `on_wait_change`, `close_backend`, `is_supported` + +* add it to the poller candidate list. + +### 8.2 FD and stream backends + +To support new handle types or platforms: + +* implement an fd backend providing: + + * non-blocking control + * read/write primitives + * readiness registration via the poller + * file helpers (open, pipe, tmpfile) and socket helpers where relevant + +Streams should not need modification. + +### 8.3 Exec backends + +To add process management support: + +* implement: + + * process start/spawn + * non-blocking status checks + * readiness registration (often via `waitable` + waitsets) + * termination/kill and backend cleanup + +The high-level `fibers.io.exec` layer is intended to remain stable. + +### 8.4 Cross-platform targets + +Current implementations focus on Unix-like platforms, but the layering is designed so that: + +* public I/O and exec APIs do not encode a syscall model; +* backends encapsulate epoll/select, signals, fork/exec, pidfd, etc. + +--- + +## 9. Summary + +The main design choices are: + +* adopt a CML-style event algebra (Ops) as the common representation of blocking; +* treat scope boundaries as values (direct returns or ops) so they compose with the same algebra; +* organise concurrent work into a scope tree with fail-fast supervision and structured cancellation; +* express channels, timers, I/O, and processes uniformly in terms of Ops and scopes; +* keep syscalls and platform specifics inside pluggable backends; +* make `fibers.perform` scope-aware so application code consistently observes cancellation and failure. + +The result is a small, coherent foundation for building concurrent systems where lifetime is explicit, waiting is composable, and cleanup is predictable-even when things go wrong. diff --git a/EVENTS-AND-OPS.md b/EVENTS-AND-OPS.md new file mode 100644 index 0000000..a3f7f86 --- /dev/null +++ b/EVENTS-AND-OPS.md @@ -0,0 +1,308 @@ +# Events and Ops + +This document is about the library’s “event algebra”: **Ops** (`Op` values), the small set of combinators that glue them together, and how you execute them through the public `fibers` API. + +Most users only need: + +* `fibers.perform(op)` — run an op in the current fiber and scope (may block; may raise). +* `fibers.choice`, `fibers.race`, `fibers.first_ready`, + `fibers.named_choice`, `fibers.boolean_choice` — compose ops. +* `fibers.guard`, `fibers.with_nack`, `fibers.bracket` — build ops with structure and cleanup. +* ops from other modules (channels, sleep, streams, sockets, exec, scopes…), which all return `Op` values. + +Lower-level tools live in `fibers.op` and `fibers.wait` and are mainly for people implementing new primitives. + +--- + +## 1. What is an `Op`? + +An `Op` is a *description* of something that may block. + +You can: + +* **perform** it (a fiber may suspend and later resume), +* **compose** it (races, timeouts, multi-way waits, structured cleanup), +* and let it be **interrupted by scope cancellation**. + +The key idea is: you do not “do blocking work”. You construct an op that *represents* the blocking work, then you combine/perform it. + +Examples of ops you’ll see in normal code: + +* Channels: `ch:get_op()`, `ch:put_op(value)` +* Timers: `sleep.sleep_op(dt)`, `sleep.sleep_until_op(t)` +* Streams: `stream:read_line_op()`, `stream:write_string_op("...")` +* Sockets: `sock:accept_op()`, `sock:connect_op(addr)` +* Processes: `cmd:run_op()`, `cmd:output_op()` +* Scopes: `scope:join_op()`, `scope:not_ok_op()` + +They’re all the same *kind* of thing: an `Op` value. + +--- + +## 2. Performing operations + +The usual way to execute an op is: + +```lua +local fibers = require "fibers" + +fibers.run(function(scope) + local ch = require("fibers.channel").new() + + fibers.spawn(function() + ch:put("hello from child") -- Channel:put performs internally + end) + + local msg = fibers.perform(ch:get_op()) + print("got:", msg) +end) +``` + +Important points: + +* `fibers.perform(op)` must be called from inside a running fiber (inside `fibers.run`, or inside a function started with `fibers.spawn`). +* It runs under the **current scope**. If the scope fails or is cancelled while you’re blocked, `perform` is interrupted according to scope semantics (see your structured concurrency docs). +* Many modules also provide convenience methods (`Channel:get()`, `Stream:read_line()`, etc.) which simply do `perform(self:..._op())`. + +You may see `op.perform_raw` or `scope:perform` internally. Treat `fibers.perform` as the public entry point. + +--- + +## 3. Core combinators (top-level API) + +The `fibers` module re-exports the main op constructors and combinators: + +```lua +local fibers = require "fibers" + +-- Constructors / guards +fibers.always(...) +fibers.never() +fibers.guard(build_fn) +fibers.with_nack(build_fn) +fibers.bracket(acquire, release, use) + +-- Choices +fibers.choice(...) +fibers.race(...) +fibers.first_ready(...) +fibers.named_choice(table_of_ops) +fibers.boolean_choice(op1, op2) +``` + +All of these take `Op` values and return new `Op` values. + +### 3.1 `always` and `never` + +```lua +local ev1 = fibers.always(42, "ok") -- immediately ready +local ev2 = fibers.never() -- never becomes ready +``` + +* `always(...)` is an op that completes immediately with the given values. +* `never()` is an op that never completes (useful for tests and placeholder wiring). + +### 3.2 `choice`: the workhorse + +```lua +local ev = fibers.choice( + ch:get_op(), + sleep.sleep_op(5.0) +) + +local result = fibers.perform(ev) +``` + +`fibers.choice(e1, e2, ...)` builds an op that: + +* waits until at least one arm can complete, +* picks one ready arm (if several are ready, selection is implementation-defined), +* completes with that arm’s results, +* and **aborts the non-winning arms** so they can clean up registrations/reservations. + +That last part matters more than it looks: it is how you avoid “timeout races” leaving stale I/O registrations behind, or leaving “I was waiting for X” state lying around in a channel/queue/poller. + +### 3.3 `race` and `first_ready` + +These are convenience wrappers around `choice`. + +* `fibers.race(e1, e2, ...)` reads as “whichever finishes first”. +* `fibers.first_ready(list)` is for polling-style patterns (when you’re selecting among readiness operations and want the first one that can make progress). + +Both behave like `choice`: one winner, losers aborted, scope cancellation still applies. + +### 3.4 `named_choice`: label the winner + +```lua +local lines_op = fibers.named_choice{ + stdout = out_stream:read_line_op(), + stderr = err_stream:read_line_op(), +} + +local which, line, err = fibers.perform(lines_op) + +if which == "stdout" then + -- handle stdout +elseif which == "stderr" then + -- handle stderr +end +``` + +`fibers.named_choice{ name = op, ... }` performs like `choice`, but returns: + +1. the winning key, then +2. that arm’s results. + +This pattern is used by helpers like `stream.merge_lines_op`. + +### 3.5 `boolean_choice`: two-way, with a flag + +```lua +local ev = fibers.boolean_choice( + cmd:run_op(), + sleep.sleep_op(5.0) +) + +local is_exit, status, code, signal, err = fibers.perform(ev) + +if is_exit then + -- process finished +else + -- timeout +end +``` + +`fibers.boolean_choice(a, b)` returns: + +* `true, ...` if `a` won +* `false, ...` if `b` won + +It’s a nice fit for “operation vs timeout” and “try X, otherwise do Y” flows. + +--- + +## 4. Guard, bracket, and nacks + +### 4.1 `guard`: build ops at performance time + +`guard` delays construction until the moment the op is actually performed: + +```lua +local ev = fibers.guard(function() + return ch:get_op() +end) +``` + +Why this exists: + +* you may need to capture dynamic context (current scope, current time, current state), +* you may need fresh internal state per synchronisation (tokens, registrations), +* you want the same op “shape” to be reusable without leaking per-run state. + +Many primitives use `guard` internally to keep state per-perform rather than per-definition. + +### 4.2 `bracket`: resource safety at the op level + +```lua +local ev = fibers.bracket( + function() -- acquire + return connect_somewhere() + end, + function(sock, aborted) -- release + if aborted then sock:close() else sock:shutdown() end + end, + function(sock) -- use + return sock:read_line_op() + end +) + +local line = fibers.perform(ev) +``` + +`fibers.bracket(acquire, release, use)`: + +* runs `acquire()` once the operation commits, +* passes the resource to `use(resource)` to produce the body op, +* guarantees `release(resource, aborted)` runs exactly once: + + * `aborted == false` if the body completes normally, + * `aborted == true` if the operation is aborted (e.g. loses a `choice`) or the scope is cancelled. + +Think of it as “finally, but composable with races”. + +### 4.3 `with_nack`: “tell me if I lose” + +`with_nack` is a specialised tool for ops that must hear “you didn’t win” promptly: + +```lua +local ev = fibers.with_nack(function(nack_op) + -- build and return an Op + -- if this op loses a choice, nack_op becomes ready +end) +``` + +Typical uses: + +* registrations that should be cancelled quickly if abandoned, +* protocols where a losing branch must send “never mind” to another party. + +Most users will not need this directly; it is mainly for robust primitive implementation. + +--- + +## 5. Ops, scopes, and cancellation + +Scopes are the lifetime boundary. Ops are the waiting boundary. They meet at `fibers.perform`. + +What you can rely on: + +* `fibers.perform(op)` runs under the current scope. +* If the scope is cancelled or fails, blocked ops are interrupted (cancellation participates in the same event algebra as other ops). +* Composite ops (`choice` et al.) abort losing arms, so primitives can reliably unregister interest and clean up. + +A useful shift in style: instead of catching cancellation around `perform`, prefer to let it propagate and put cleanup in `finally`/`bracket`/scope finalisers. That keeps “who owns what” obvious. + +If you need an explicit boundary that *returns* status rather than raising, use `fibers.run_scope` or `fibers.run_scope_op` — that is the intended place where errors become values. + +--- + +## 6. Implementing new primitives (overview) + +Most application code never touches this section. If you are implementing new blocking primitives, you will usually use: + +* `fibers.op` for low-level op construction (`new_primitive`, `:wrap`, `:on_abort`, …) +* `fibers.wait.waitable` to bridge “non-blocking step + registration” into an op + +The standard pattern: + +```lua +local wait = require "fibers.wait" + +local function my_primitive_op(...) + local function step() + -- Non-blocking probe: + -- return true, ...results... if ready + -- return false if not ready yet + end + + local function register(task, suspension, leaf_wrap) + -- Arrange for task:run() to be called when progress may have been made. + -- Return a token with optional token:unlink() to cancel registration. + end + + return wait.waitable(register, step) +end +``` + +Using `waitable` gets you a lot “for free”: + +* the op participates correctly in `choice`/`race`/`named_choice`, +* losing a choice triggers abort, and abort triggers `unlink` (so you do not leak registrations), +* the scheduler/poller can wake you efficiently without blocking the event loop. + +For users of the library, the takeaway is simple: + +> If something blocks in this library, it’s an `Op`. +> If it’s an `Op`, it composes with choice, timeouts, cancellation, and cleanup. + +That is the whole trick. diff --git a/EXECUTION.md b/EXECUTION.md new file mode 100644 index 0000000..2bc6b41 --- /dev/null +++ b/EXECUTION.md @@ -0,0 +1,425 @@ +# Process execution (`fibers.io.exec`) + +This document explains how to start and manage external processes with `fibers`. + +It focuses on the “normal user” surface: + +* `fibers.run(main_fn, ...)` starts the scheduler and a root scope. +* `fibers.spawn(fn, ...)` starts a new fiber in the current scope. +* `fibers.perform(op)` performs an operation (and will raise if the current scope is failed/cancelled). +* `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 API lives in `fibers.io.exec`: + +```lua +local fibers = require "fibers" +local exec = require "fibers.io.exec" +``` + +--- + +## What you get + +The exec subsystem provides: + +* A `Command` object representing one external process. +* **Scope-owned lifetime**: a `Command` belongs to the scope in which it was created. +* Configurable `stdin`/`stdout`/`stderr`: + + * inherit from the parent process, + * connect to `/dev/null`, + * pipe via `Stream`, + * reuse an existing stream (including `stderr = "stdout"`). +* Ops for: + + * waiting for completion, + * shutdown with a grace period then escalation, + * capturing output as a string. + +Backends vary by platform (pidfd where available, SIGCHLD fallbacks, etc.). The important bit is that the *shape* is stable: you get ops that compose with the rest of the library. + +--- + +## The one rule: create commands inside a fiber + +`exec.command(...)` must be called from inside a fiber. In practice: inside `fibers.run`, inside a function spawned by `fibers.spawn`, or inside `fibers.run_scope`. + +Basic pattern: + +```lua +local fibers = require "fibers" +local exec = require "fibers.io.exec" + +fibers.run(function() + local cmd = exec.command("ls", "-1") + + local out, st, code, sig, err = fibers.perform(cmd:output_op()) + + if st == "exited" and code == 0 then + print("ls output:\n" .. out) + else + print("ls failed:", st, code, sig, err) + end +end) +``` + +Key points: + +* You interact with processes by performing ops: `cmd:run_op()`, `cmd:shutdown_op()`, `cmd:output_op()`, … +* The command’s lifetime is bound to the current scope: when that scope joins, the process is shut down and its resources are cleaned up. + +There is no top-level `try_perform`. If you want “status as values”, use an explicit boundary (`run_scope` / `run_scope_op`). + +--- + +## Constructing commands + +`exec.command` supports both table and positional forms: + +```lua +local exec = require "fibers.io.exec" + +-- Table form +local cmd = exec.command{ + "sh", "-c", "echo hello", + cwd = "/tmp", + env = { FOO = "bar" }, + stdin = "null", + stdout = "pipe", + stderr = "stdout", + shutdown_grace = 2.0, +} + +-- Positional form (argv only, default options) +local cmd2 = exec.command("ls", "-l", "/") +``` + +Table fields: + +* `spec[1]`, `spec[2]`, …: argv elements +* `cwd`: working directory (string or nil) +* `env`: environment (`string -> string|nil`) +* `flags`: backend-specific flags (e.g. `setsid`) +* `stdin`, `stdout`, `stderr`: stdio config (next section) +* `shutdown_grace`: default grace period in seconds for shutdown + +You can also configure with setters before the command starts: + +```lua +cmd:set_cwd("/var/log") + :set_env{ LANG = "C" } + :set_stdin("null") + :set_stdout("pipe") + :set_stderr("stdout") + :set_shutdown_grace(5.0) +``` + +All setters raise if the command has already started. + +--- + +## Stdio configuration + +Each of `stdin`, `stdout`, `stderr` can be: + +* a string mode, or +* an existing `Stream`. + +String modes: + +* `"inherit"`: use the parent process’s fd (default) +* `"null"`: connect to `/dev/null` +* `"pipe"`: create a new pipe and expose the parent end as a `Stream` +* `"stdout"`: *stderr only*; share the same destination as stdout + +Passing a `Stream` uses its underlying fd. In that case the `Command` does **not** own the stream and will not close it. + +Examples: + +```lua +local file = require "fibers.io.file" +local exec = require "fibers.io.exec" + +-- Discard all output, no input. +local cmd1 = exec.command{ + "my-tool", + stdin = "null", + stdout = "null", + stderr = "null", +} + +-- 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 (not owned) +} + +-- Capture stdout and stderr together. +local cmd3 = exec.command{ + "my-tool", + stdout = "pipe", + stderr = "stdout", +} +``` + +If you configure `"pipe"`, the backend creates streams and the command typically owns and closes them during cleanup. + +--- + +## Command state and laziness + +Introspection: + +```lua +local st, code_or_sig, err = cmd:status() +local pid = cmd:pid() +local argv_copy = cmd:argv() +``` + +Status values: + +* `"pending"`: created but not started +* `"running"`: started and still running +* `"exited"`: exited normally (code is available) +* `"signalled"`: terminated by a signal (signal number is available) +* `"failed"`: failed to start or manage the process (err string available) + +Many commands start lazily. The process may not actually be spawned until you: + +* perform `run_op` / `shutdown_op` / `output_op`, or +* request a piped stream (`stdout_stream()` etc. when configured as `"pipe"`). + +This is intentional: the command is a plan until you first need it to be real. + +--- + +## Getting stdio streams + +You can ask for streams: + +```lua +local stdin_s, e1 = cmd:stdin_stream() +local stdout_s, e2 = cmd:stdout_stream() +local stderr_s, e3 = cmd:stderr_stream() +``` + +Typical behaviour: + +* `"inherit"` / `"null"`: returns `nil` +* user-supplied stream: returns that stream +* `"pipe"`: starts the process if necessary and returns a `Stream` +* `stderr = "stdout"`: `stderr_stream()` delegates to `stdout_stream()` + +Example: stream stdout line-by-line while the child runs: + +```lua +local fibers = require "fibers" +local exec = require "fibers.io.exec" + +fibers.run(function() + local cmd = exec.command{ + "sh", "-c", "printf 'a\nb\nc\n'; sleep 0.1", + stdout = "pipe", + stderr = "stdout", + } + + local out = assert(cmd:stdout_stream()) + + fibers.spawn(function() + while true do + local line, err = fibers.perform(out:read_line_op()) + if err then error(err) end + if not line then break end -- EOF + print("[child]", line) + end + end) + + local st, code, sig, err = fibers.perform(cmd:run_op()) + print("child finished:", st, code, sig, err) +end) +``` + +--- + +## Waiting for completion (`run_op`) + +To wait for the process: + +```lua +local st, code, sig, err = fibers.perform(cmd:run_op()) +``` + +Semantics: + +* Starts the process if it hasn’t started yet. +* Resolves immediately if already complete. +* Returns: + + * `st`: `"exited" | "signalled" | "failed"` + * `code`: exit code for `"exited"`, else nil + * `sig`: signal number for `"signalled"`, else nil + * `err`: error string for `"failed"`, else nil + +Because it’s an op, you can race it against timeouts or other events. + +If the *scope* fails/cancels while you are waiting, `fibers.perform` raises (that’s how “don’t outlive your scope” shows up in real code). + +--- + +## Graceful shutdown (`shutdown_op`) + +To ask the process to stop politely, then escalate: + +```lua +local st, code, sig, err = fibers.perform(cmd:shutdown_op(5.0)) -- grace seconds +``` + +Typical behaviour: + +1. Ensure the process is started. +2. Send a polite termination request (backend-defined). +3. Wait for exit within the grace period. +4. If still running, send a forceful kill. +5. Wait for final completion and return the final status. + +`shutdown_op` is useful in normal control flow (stopping a worker) and is also what scope cleanup will use when a command is still running at scope exit. + +--- + +## Capturing output + +### `output_op` (stdout only) + +Capture all stdout and wait for completion: + +```lua +local out, st, code, sig, err = fibers.perform(cmd:output_op()) +``` + +Behaviour: + +* If stdout is currently `"inherit"`, `output_op` will arrange piping for capture. +* Reads stdout to EOF (using stream ops). +* Waits for process completion. +* Returns `out` plus the same status tuple as `run_op`. + +Example: + +```lua +local cmd = exec.command("sh", "-c", "echo hello; exit 0") +local out, st, code, sig, err = fibers.perform(cmd:output_op()) + +if st == "exited" and code == 0 then + print("child said:", out) +else + print("child failed:", st, code, sig, err) +end +``` + +### `combined_output_op` (stdout + stderr) + +Merge stderr into stdout and capture: + +```lua +local out, st, code, sig, err = fibers.perform(cmd:combined_output_op()) +``` + +This is equivalent to “stderr goes to stdout for the lifetime of this operation”, subject to the usual configuration constraints. + +--- + +## Sending signals directly (`kill`) + +You can send a signal yourself: + +```lua +local ok, err = cmd:kill() -- default forceful kill (backend-defined) +-- or, if supported: +local ok2, err2 = cmd:kill("TERM") +``` + +Signal naming and behaviour are backend-dependent. Prefer `shutdown_op` when you want portable, structured behaviour. + +--- + +## Scope-bound lifetime (the important bit) + +A `Command` is owned by the scope in which it is created. + +Practical implications: + +* Create commands in the scope that should own them. +* Don’t stash a `Command` and keep using it after the scope that created it has joined. +* Expect operations performed under a cancelled/failed scope to raise: that is the mechanism that prevents “zombie work”. + +Cleanup happens during scope join, in a non-interruptible join worker. That is exactly where processes belong: if a scope is exiting (successfully or not), its commands are shut down and their owned resources are closed deterministically. + +--- + +## Patterns + +### Fire-and-wait with captured output + +```lua +local fibers = require "fibers" +local exec = require "fibers.io.exec" + +fibers.run(function() + local cmd = exec.command("uname", "-a") + local out, st, code, sig, err = fibers.perform(cmd:output_op()) + + if st == "exited" and code == 0 then + print(out) + else + print("uname failed:", st, code, sig, err) + end +end) +``` + +### Run a long-lived process in a child scope and observe the boundary + +This separates “did the scope run cleanly?” from “what did the process do?”: + +```lua +local fibers = require "fibers" +local exec = require "fibers.io.exec" + +fibers.run(function() + local scope_st, report, proc_st, code, sig, perr = + fibers.run_scope(function() + local cmd = exec.command{ + "some-daemon", "--foreground", + stdout = "inherit", + stderr = "inherit", + } + return fibers.perform(cmd:run_op()) + end) + + if scope_st ~= "ok" then + -- The scope failed/cancelled; proc_st is the primary error/reason. + print("worker scope not ok:", scope_st, tostring(proc_st)) + else + -- Scope ran normally; proc_* are the process results. + print("process finished:", proc_st, code, sig, perr) + end + + 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((" [%d] %s"):format(i, tostring(e))) + end + end +end) +``` + +--- + +## Summary + +* Create commands inside a scope (i.e. inside a fiber). +* Interact with them via ops: `run_op`, `shutdown_op`, `output_op`, `combined_output_op`. +* Perform those ops with `fibers.perform`, and let scope failure/cancellation raise naturally. +* Use `run_scope` / `run_scope_op` when you want structured outcomes as values. +* Rely on scope finalisers for deterministic cleanup: processes and owned streams should not leak past the scope that created them. diff --git a/README.md b/README.md index bc1d231..aaaf14e 100644 --- a/README.md +++ b/README.md @@ -1,147 +1,401 @@ -# lua-fibers +# fibers -Lightweight Go-like concurrency and non-blocking IO for Lua (5.1-5.4) and LuaJIT. +`fibers` runs lots of concurrent work in one Lua process, using cooperative fibers and an event loop. The aim is not “threads in Lua”, but something closer to: + +* **structured lifetimes** (supervision scopes), +* **first-class blocking operations** (Ops), +* **I/O and subprocesses that behave like Ops**, +* **errors as control flow** (throw freely; catch rarely). + +A typical entry point: ```lua -local function fibonacci(c, quit) - local x, y = 0, 1 - local done = false - repeat - op.choice( - c:put_op(x):wrap(function(value) - x, y = y, x+y - end), - quit:get_op():wrap(function(value) - print("quit") - done = true - end) - ):perform() - until done +local fibers = require "fibers" + +local function main(scope) + -- application code here end -fiber.spawn(function() - local c = channel.new() - local quit = channel.new() - fiber.spawn(function() - for i=1, 10 do - print(c:get()) - end - quit:put(0) +fibers.run(main) +``` + +Inside `main`, you write ordinary Lua. When you need to wait for something, you perform an op. When you need concurrency, you spawn. When you need cleanup, you register a finaliser. Scopes do the rest. + +--- + +## The mental model + +### 1) Scopes own time + +Every fiber runs “inside” a scope. A scope is the unit of: + +* what work is allowed to start, +* what gets cancelled when something goes wrong, +* what must be joined before the scope is considered finished, +* and where cleanup belongs. + +If you start work in a scope, that scope is responsible for joining it and running cleanup, even if things fail. + +### 2) Waiting is explicit + +Anything that might block is an **Op**. Examples: + +* `sleep.sleep_op(dt)` +* channel `get_op` / `put_op` +* stream reads/writes (`read_line_op`, `write_string_op`, …) +* socket accept/connect ops +* waiting for a subprocess +* joining a scope + +Ops compose. If you can race a channel receive against a timeout, you can do the same with I/O, process completion, or a scope boundary. + +### 3) Errors are normal (and scoped) + +Inside a scope, it is normal to use `error`, `assert`, or let exceptions escape. The scope boundary is what turns “chaos” into a reportable outcome. + +You rarely need `pcall`. When you do catch, it should be because you have a real local recovery plan. + +--- + +## Highlights + +## Fail-fast scopes + +Within a scope: + +* the **first non-cancellation failure** becomes the **primary failure**, +* siblings are cancelled (fail-fast), +* cleanup runs (finalisers), +* later faults become **secondary errors** collected in the report. + +At boundaries you get structured outcomes. In most application code, you just throw and move on. + +## Ops: a small algebra for waiting + +Ops can complete immediately, or they can suspend and resume later. You can combine them using: + +* `choice`, `named_choice`, `boolean_choice`, `race` +* `guard` +* `bracket` / `:finally` / `:wrap` +* (advanced) `with_nack` and abort behaviour + +Timeouts are not special-cased. They’re just “race X against sleep”. + +## I/O and subprocesses participate fully + +Streams, sockets, pollers, and subprocesses are built to “feel like ops”: + +* blocking reads/writes are ops, +* readiness is registered with the poller and unregistered on abort, +* subprocess lifetime is attached to a scope and shut down on scope exit, +* and you can race them against timeouts like anything else. + +--- + +## Examples + +## 1) Fail fast, and inspect the outcome at a 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" + +local function main() + fibers.spawn(function() + sleep.sleep(0.5) + print("sibling: finished ok") + end) + + local status, report, value_or_primary = fibers.run_scope(function(child) + child:finally(function() + print("finaliser 1") end) - fibonacci(c, quit) - fiber.stop() -end) -fiber.main() + child:finally(function() + print("finaliser 2 (oops)") + error("finaliser 2 failed") + end) + + sleep.sleep(0.1) + error("child: boom") + end) + + print("child scope:", 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((" [%d] %s"):format(i, tostring(e))) + end + end +end + +fibers.run(main) ``` -Ported from the Snabb Project's [`fibers`](https://github.com/snabbco/snabb/tree/master/src/lib/fibers) and [`streams`](https://github.com/snabbco/snabb/tree/master/src/lib/stream) libraries, written by -Andy Wingo as an implementation of Reppy et al's Concurrent ML(CML), and a fibers-based reimplementation of Lua's streams enabling smooth non-blocking access to files and sockets. +Inside scopes, errors can escape. The scope records the failure, cancels siblings, runs finalisers, and reports the outcome at the boundary. -Inspired by Andy's [blog post](https://wingolog.org/archives/2018/05/16/lightweight-concurrency-in-lua) introducing fibers on Lua +If the top-level `main` fails, `fibers.run(main)` raises the primary failure. -## Usage +--- -You can find examples in the `/examples` directory. +## 2) Channels and timeouts: the intended pattern -Much of the (excellent) documentation from the [Guile manual on -fibers](https://github.com/wingo/fibers/wiki/Manual) is directly relevant here, -with the following points to bear in mind: - - Guile's implementation runs X fibers across Y cores, with one work stealing - scheduler per core. The Lua port is single threaded, running in a single Lua - process. In the future we may well implement true parallelism perhaps using - a Lanes/Lindas approach +```lua +local fibers = require "fibers" +local chan = require "fibers.channel" +local sleep = require "fibers.sleep" + +local function main() + local c = chan.new() + + fibers.spawn(function() + sleep.sleep(0.1) + c:put("hello") + end) + + local ev = fibers.named_choice{ + data = c:get_op(), + timeout = sleep.sleep_op(1.0), + } + + local which, value = fibers.perform(ev) + + if which == "data" then + print("got:", value) + else + print("timed out") + end +end -## Installation +fibers.run(main) +``` -This is a pure Lua (with FFI) module with the following dependencies: - - lua-posix (for micro/nano timing and sleeping options, for forking and - other syscall operations) - - libffi and lua-cffi (if not using LuaJIT) - - lua-bit32 (if not using LuaJIT) +Timeouts are deliberately expressed as “race an op against `sleep_op`”. -These dependencies will be installed in a VScode devcontainer automatically. To install manually follow the following steps: +--- -1. Copy the `fibers` directory somewhere lua can find it -1. Choose between Lua and LuaJIT: - 1. If using LuaJIT: - 1. Install LuaJIT (tested with 2.1.0-beta 3) according to your platform instructions - 1. If using Lua: - 1. Install Lua (5.1 to 5.4) according to your platform instructions - 1. bit32 with `luarocks install bit32` - 1. Install `libffi` - 1. Install `cffi-lua` with `luarocks install cffi` -1. Install dependencies: - 1. luaposix with `luarocks install luaposix` +## 3) Race an entire subtree of work against a timeout -### Installation with LuaJIT on OpenWRT +A scope boundary can itself be an op: `fibers.run_scope_op`. It resolves when the child scope has joined (including its finalisers and attached children). -This is the simplest set up for running on OpenWRT. +```lua +local fibers = require "fibers" +local sleep = require "fibers.sleep" + +local function main() + local subtree = fibers.run_scope_op(function(child) + child:spawn(function() + sleep.sleep(2.0) + print("subtree: finished") + end) + return "started" + end) + + local ev = fibers.named_choice{ + subtree = subtree, -- yields: st, rep, results/primary + timeout = sleep.sleep_op(1.0), + } + + local which, st, rep, v = fibers.perform(ev) + + if which == "timeout" then + print("timed out; subtree was cancelled") + return + end + + if st == "ok" then + print("subtree ok:", tostring(v)) + else + print("subtree not ok:", st, tostring(v)) + end + + if rep and rep.extra_errors and #rep.extra_errors > 0 then + print("secondary errors:", #rep.extra_errors) + end +end -`opkg update; opkg install luajit; opkg install luaposix` +fibers.run(main) +``` -Note that `luaposix` should be installed with `opkg` and not with `luarocks` in this context. +If `run_scope_op(...)` loses in an outer `choice`, the child scope is cancelled (reason `"aborted"`) and then joined deterministically. -That's it! +--- -## Why Fibers? +## 4) Subprocesses are scope-owned -There are many possible advantages of fibers, in comparison to other async models such as callbacks, async/await, and promises. This is especially in highly complex programs with lots of semi-independent code doing async IO (such as in-process microservices). +```lua +local fibers = require "fibers" +local exec = require "fibers.io.exec" + +local function main() + local status, report, out_or_primary = fibers.run_scope(function() + local cmd = exec.command{ + "ls", "-l", + stdout = "pipe", + } + + local out, st, code, sig, err = fibers.perform(cmd:output_op()) + + if st == "exited" and code == 0 then + return out + end + + error(("command failed: %s code=%s sig=%s err=%s"):format( + tostring(st), tostring(code), tostring(sig), tostring(err) + )) + end) + + if status == "ok" then + print(out_or_primary) + else + print("scope failed:", status, tostring(out_or_primary)) + end +end + +fibers.run(main) +``` -| | **Callbacks** | **Promises** | **Async/Await** | **Fibers** | -|---|---|---|---|---| -| **Readability** | Less readable due to "callback hell", a situation where callbacks are nested within callbacks, making the code hard to read and debug. | More readable than callbacks, but still requires then-catch chains for error handling, which can become cumbersome. | Most readable, as it makes asynchronous code look synchronous, improving comprehension and maintainability. | Very readable, since fibers can be used to write asynchronous code in a synchronous style without the need for callbacks or promises. | -| **Stack Traces** | Less clear, as each callback function creates a new stack frame, so errors can be hard to trace back to their origin. | Better than callbacks, but still might have challenges because Promises swallow errors if not handled correctly. | Good, because async/await allows to use traditional try-catch error handling which provides clear stack traces. | Best, because fibers maintain their own stack, providing a clean and comprehensive trace. | -| [**'Coloured Function' Problem**](https://journal.stuffwithstuff.com/2015/02/01/what-color-is-your-function/) | Highly affected. If a function is asynchronous (uses callbacks), all of its callers must be too. | Less affected, but still present to some extent. | Still present but significantly reduced as compared to callbacks and promises. | Not affected. Fibers can pause and resume execution, meaning asynchronous functions can be called as if they were synchronous, without requiring callers to be async. | -| **Debugging** | Difficult, because the asynchronous nature of callbacks can make it hard to step through the code or maintain a consistent state for inspection. | Easier than callbacks, but may still be challenging due to the chaining of promises. | Easier, because async/await can be stepped through like synchronous code. | Easiest, because fibers allow you to write code that's structurally synchronous and therefore easier to debug. | -| **Performance** | Callbacks are generally faster as there is no extra abstraction layer. But the complexity may increase with the number of operations. | Promises have extra abstraction which may have some performance impact. | Similar performance to promises as it's built on top of them. | Fibers may introduce some performance overhead as each fiber has its own stack, but this is small and outweighed by benefits in terms of readability, error handling, and simplicity. | +Commands are attached to the current scope. On scope exit, they are shut down and their owned streams/handles are cleaned up. +--- -## Beyond Go's concurrency +## Concepts in brief -This library implements a simple version of Concurrent ML(CML), and provides primitives very similar to those offered by Go. However, while both Go and CML offer powerful models for concurrent programming, the flexibility and expressibility of Concurrent ML's first-class synchronisable events (here called 'operations') provide more advanced mechanisms to handle complex synchronisation scenarios. +## Fibers -| | Go | Concurrent ML (CML) | -|---|---|---| -| Basic Mechanism | Uses goroutines and channels as the primary concurrency mechanisms. | Uses threads and synchronous message-passing channels, but also introduces the concept of 'synchronisable events'. | -| Choice | Provides non-deterministic choice over multiple channel operations using the `select` statement. However, it can only work with channels and the options must be statically declared at compile-time. | Supports non-deterministic choice among a dynamic set of 'synchronisable events' that can be constructed at runtime. These events can represent a wider variety of actions beyond just message-passing. | -| Timeouts | Achievable with `select` and timer channels. However, the syntax can be more verbose. | Timeouts are easily created as an event and composed with other events using the choice combinator. | -| User-Defined Concurrency Primitives | Limited. The primary concurrency primitive is the channel, and `select` can't be easily extended by users. | Highly flexible. CML's first-class events and combinators allow users to build their own high-level concurrency primitives (like barriers, semaphores, or read/write locks). These user-defined primitives can be composed and manipulated just like built-in ones. | -| Overall Flexibility | Go's model is relatively simple and straightforward, but may not have the necessary flexibility for more complex synchronisation patterns. | CML's model is extremely flexible, allowing for the expression of complex synchronisation patterns in a direct and elegant manner. | +A **fiber** is a lightweight task scheduled by the runtime. -## Progress +* `fibers.run(main)` starts the scheduler and runs `main` inside a scope under the process root. +* `fibers.spawn(fn, ...)` creates a new fiber under the current scope and calls `fn(...)`. -All of the Snabb 'fibers' modules the following have so far been ported and -tested. All of Snabb's 'stream' module has also been ported, which can do -non-blocking reads and writes from file descriptors using familiar `line` -and `all` approaches. +You do not manually join fibers. Scopes track obligations and join deterministically. -We use the `cffi` module to port Wingo's `luajit` C ffi based -buffers implementation in a way that will work across multiple architectures, as -Lua versions of these buffers would be inefficient and lead to allocation and -garbage collection without a substantial investment of time. +## Scopes -## Structured concurrency +A **scope** is a supervision domain with a tree structure and fail-fast semantics. -While it's fun spinning off fibers/goroutines like popcorn, it comes with a [cost](https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/). Structured concurrency is a possible enhancement, basically the idea that no fiber/goroutine should outlive its parent: -https://about.sourcegraph.com/blog/building-conc-better-structured-concurrency-for-go +When a scope fails or is cancelled: +* admission closes (new work is rejected), +* attached child scopes are cancelled, +* in-flight ops observe cancellation via `fibers.perform`, +* finalisers run in LIFO order during join. -## Fibers module map +Scope outcomes at boundaries: -```mermaid -graph TD; - timer.lua-->sched.lua; - sched.lua-->fiber.lua; - fiber.lua-->op.lua; - fiber.lua-->sleep.lua; - op.lua-->sleep.lua; - epoll.lua-->file.lua; - fiber.lua-->file.lua; - op.lua-->file.lua; - op.lua-->cond.lua; - op.lua-->channel.lua; - fiber.lua-->queue.lua; - op.lua-->queue.lua; - channel.lua-->queue.lua; +```lua +status, report, ... -- on ok: ... are results +status, report, primary -- on not-ok: primary is error/reason ``` + +The `report` contains: + +* `extra_errors`: faults after the primary is established, +* `children`: joined child outcomes with nested reports. + +## Operations (Ops) + +An **Op** represents “something that may block”, they are a close translation of `events` in Concurrent ML. + +* Perform an op with `fibers.perform(op)` (must be called inside a fiber). +* If the current scope is cancelled or failed, `perform` raises (cancellation uses a sentinel internally). + +Because everything that blocks is an op, you can write one set of patterns and reuse them everywhere: + +* timeouts (`choice` against `sleep_op`) +* “first ready wins” (`race`, `named_choice`) +* resource safety (`bracket`, `:finally`) +* cancellation-safe cleanups (finalisers, abort handlers) + +If you want status-first handling, use an explicit boundary (`run_scope`, `run_scope_op`) or work directly with scope APIs. + +## I/O and streams + +The I/O layer wraps non-blocking file descriptors as buffered `Stream` objects and exposes ops such as: + +* `read_line_op`, `read_all_op`, `read_exactly_op` +* `write_string_op` + +These ops integrate with the poller and can be raced, timed out, and cancelled like anything else. + +A particularly useful helper is `stream.merge_lines_op`, which races a line read across multiple named streams: + +```lua +local name, line, err = fibers.perform(stream.merge_lines_op({ a = s1, b = s2 })) +``` + +## Subprocesses + +The exec layer runs subprocesses under scopes: + +* commands and stdio wiring are configured up-front, +* lifecycle is exposed as ops (`run_op`, `shutdown_op`, `output_op`, …), +* cleanup is attached to scope finalisers so processes are shut down on scope exit. + +--- + +## Error handling + +Inside a scope: + +* letting an error escape a fiber is normal; +* the first failure becomes the primary failure and triggers cancellation of siblings; +* additional failures (including finaliser failures once not-ok) become secondary errors in `report.extra_errors`. + +At boundaries: + +* `fibers.run(main)` returns results on success, otherwise raises the primary failure/reason (as a string/number), +* `fibers.run_scope(fn)` returns `status, report, ...` as described above. + +Rule of thumb: + +* **Throw freely** inside a scope. +* **Catch rarely**, only when you genuinely want local recovery. +* **Always register cleanup** with `finally`/`bracket`/scope finalisers rather than trying to “survive” cancellation. + +--- + +## Requirements and installation + +### Lua and platform + +* Lua 5.1–5.5 or LuaJIT. +* A POSIX-like platform (currently developed and tested on Linux). + +### Backend support + +`fibers` uses pluggable backends for polling and subprocess handling. You need at least one compatible stack: + +* **FFI backend (preferred)** + + * LuaJIT (or PUC Lua with cffi-lua) + * `epoll` for I/O; `pidfd` for process completion (when available) + +* **luaposix backend** + + * `luaposix` + * `poll`/`select` plus `SIGCHLD` for process completion + +* **nixio backend** + + * `nixio` + * `poll` for I/O; a double-fork scheme for evented process completion + +OS-specific code is isolated in `fibers.io.*_backend` and poller backends, so adding a platform is “implement the backend contract”, not “rewrite the library”. + +### Installation + +Add the repository to your `package.path` (and `package.cpath` if needed) so modules such as `fibers`, `fibers.channel`, `fibers.sleep`, `fibers.io.file`, `fibers.io.stream`, and `fibers.io.exec` can be `require`d. + +--- + +## Acknowledgements + +The design owes a substantial debt to Andy Wingo’s writing on concurrency, including his article [_lightweight concurrency in lua_](https://wingolog.org/archives/2018/05/16/lightweight-concurrency-in-lua) and his Snabb [`fibers`](https://github.com/snabbco/snabb/tree/master/src/lib/fibers) and [`stream`](https://github.com/snabbco/snabb/tree/master/src/lib/stream) implementations. And of course to John Reppy's original CML! diff --git a/STRUCTURED-CONCURRENCY.md b/STRUCTURED-CONCURRENCY.md new file mode 100644 index 0000000..8af7a57 --- /dev/null +++ b/STRUCTURED-CONCURRENCY.md @@ -0,0 +1,407 @@ +# Structured concurrency + +This document explains how `fibers` organises concurrent work using **scopes**, and how the top-level API in `fibers.lua` helps you keep lifetimes, failure, cancellation, and cleanup on a short lead. + +It covers: + +* `fibers.run` +* `fibers.spawn` +* `fibers.run_scope` +* `fibers.run_scope_op` +* `fibers.current_scope` +* `fibers.perform` + +It deliberately does **not** cover scheduler internals or the full op algebra. Think of this as “how to write code that behaves well under stress”. + +--- + +## 1. Overview + +`fibers` uses structured concurrency: + +* Every fiber runs inside a **scope**. +* Scopes form a **tree**: a scope can have child scopes. +* The first non-cancellation fault in a scope becomes its **primary failure** and triggers **fail-fast cancellation** of the scope and its descendants. +* Scopes provide **deterministic finalisation**: + + * attached child scopes are joined in attachment order; + * finalisers run in LIFO order. + +A scope is a supervision context. It owns a set of running fibers and resources, and it only becomes “done” once: + +1. its fibers have drained, +2. its child scopes have joined, +3. its finalisers have run. + +If you remember one thing: *work should not outlive the scope that started it*. + +--- + +## 2. Top-level API + +## 2.1 `fibers.run(main_fn, ...)` + +```lua +local fibers = require "fibers" + +fibers.run(function(scope, ...) + -- scope is a root-attached scope for this run +end) +``` + +`fibers.run`: + +* must be called from **outside** any fiber; +* 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. + +Results: + +* on success: returns the values returned by `main_fn`; +* on failure/cancellation: raises the **primary** error/reason to the calling thread. + +This is the “one door in / one door out” boundary for your program. + +--- + +## 2.2 `fibers.spawn(fn, ...)` + +```lua +fibers.run(function(scope) + fibers.spawn(function() + local s = fibers.current_scope() + -- ... + end) +end) +``` + +`fibers.spawn`: + +* spawns a new fiber under the **current scope**; +* calls `fn(...)` in that fiber; +* returns immediately. + +There is no join handle. The scope owns the lifetime and joins deterministically. + +--- + +## 2.3 `fibers.run_scope(body_fn, ...)` + +`fibers.run_scope` is a re-export of `Scope.run`. + +```lua +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) +``` + +Behaviour: + +* must be called from inside a fiber; +* creates a fresh child scope of the current scope; +* runs `body_fn(child_scope, ...)` inside that scope; +* joins the child scope deterministically and returns: + +```lua +status :: "ok" | "failed" | "cancelled" +report :: ScopeReport +... :: results from body_fn (only when status == "ok") + :: primary error/reason value (only when status ~= "ok") +``` + +`ScopeReport` shape: + +```lua +report.id -- scope id +report.extra_errors -- array of secondary errors +report.children -- array of joined child outcomes +``` + +Child outcomes: + +```lua +child.id +child.status -- "ok"|"failed"|"cancelled" +child.primary +child.report -- nested ScopeReport +``` + +Use `run_scope` when you want a clean boundary that turns “exceptions inside” into “status + report outside”. + +--- + +## 2.4 `fibers.run_scope_op(body_fn, ...)` + +`fibers.run_scope_op` is a re-export of `Scope.run_op`. + +It 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" + +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) +``` + +Key points: + +* If this op loses as an arm in an outer `choice`, the child scope is cancelled (reason `"aborted"`) and then joined deterministically. +* This is the supported way to make “run a structured subtree” participate in the op algebra (timeouts, races, readiness, etc.). + +If you find yourself wanting “spawn a big thing and maybe cancel it later”, this is usually the shape you want. + +--- + +## 2.5 `fibers.current_scope()` + +```lua +local s = fibers.current_scope() +``` + +* 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. + +Most code should accept scopes as parameters (from `fibers.run` or `fibers.run_scope`). `current_scope()` is for when threading a scope through arguments would be noise rather than clarity. + +--- + +## 2.6 `fibers.perform(op)` + +```lua +local fibers = require "fibers" +local sleep = require "fibers.sleep" + +fibers.run(function() + fibers.perform(sleep.sleep_op(0.5)) +end) +``` + +`fibers.perform(op)`: + +* performs an `Op` under the current scope; +* returns results on success; +* raises on failure; +* raises on cancellation. + +This is deliberate: *inside a scope, “throw and unwind” is normal*. The scope boundary is where you translate exceptions into a status/report. + +If you need status-first behaviour, use an explicit boundary: + +* `fibers.run_scope(...)` (returns status/report/results), +* `fibers.run_scope_op(...)` (an op yielding status/report/results), +* or scope methods directly (for library code that is intentionally doing something special). + +--- + +## 3. Scope lifecycle and reporting + +A scope has two closely related notions of status: + +### Observational status (`scope:status()`) + +* `"running"` +* `"failed", primary` +* `"cancelled", reason` +* `"ok"` (only once join has completed) + +This is a snapshot: useful for diagnostics, not a completion mechanism. + +### Terminal status (what boundaries return) + +Boundaries (`join_op`, `run`, `run_op`) return: + +* `"ok" | "failed" | "cancelled"` + +If both failure and cancellation are recorded, **failure wins**: cancellation is a consequence of failure (fail-fast), not a competing explanation. + +--- + +## 3.1 Primary failure and secondary errors + +Rules of the road: + +* The first non-cancellation fault becomes the scope’s **primary failure** and triggers cancellation. +* Later faults (finalisers failing, late fiber errors, etc.) are recorded as **secondary errors** in `report.extra_errors`. + +This is intentionally conservative: + +* the primary answers “what caused this scope to stop being OK?”; +* the report answers “what else went wrong on the way out?”. + +--- + +## 4. Resource management with finalisers + +Finalisers attach cleanup to a scope’s lifetime: + +```lua +scope:finally(function(aborted, status, primary_or_nil) + -- cleanup work +end) +``` + +Finalisers run during join, after: + +1. spawned fibers in the scope have drained, +2. attached child scopes have been joined (in attachment order). + +Calling convention: + +* `aborted` is `true` when terminal status is not `"ok"`; +* `status` is `"ok"|"failed"|"cancelled"`; +* `primary_or_nil` is provided only when `status == "failed"`. + +If a finaliser raises: + +* if the scope was otherwise `"ok"`, the finaliser error becomes the primary failure; +* otherwise it is recorded in `extra_errors` and the primary stays the same. + +Practical advice: treat finalisers as “best-effort cleanup”, not as a second place to do real work. + +--- + +## 5. Cancellation and operations + +Scopes integrate with ops so cancellation and failure have real teeth: + +* If a scope is already failed or cancelled, ops under it resolve as not-ok immediately (via the scope performer). +* Otherwise, a performed op implicitly races against the scope becoming not-ok. +* After the op completes, the scope is checked again; if the scope transitioned while the op was completing, the outcome is treated as not-ok. + +What this means in practice: + +* `fibers.perform(op)` is the default. It either gives you the result or unwinds the stack. +* Timeouts are written as op choice, not as “check a flag in a loop”. +* Losing arms in a `choice` are expected to clean up promptly (many primitives use abort hooks or unlink tokens so they stop waiting on fds, timers, etc.). + +--- + +## 6. Example: structured workers with an explicit outcome + +```lua +local fibers = require "fibers" +local sleep = require "fibers.sleep" + +local function run_workers(n) + return fibers.run_scope(function(scope) + for i = 1, n do + fibers.spawn(function(idx) + fibers.perform(sleep.sleep_op(0.1 * idx)) + if idx == 3 then + error("worker " .. idx .. " failed") + end + end, i) + end + end) +end + +fibers.run(function() + 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) +``` + +This style gives you one place to interpret outcomes: the boundary. Inside, workers just throw. + +--- + +## 7. Example: racing a structured task against a timeout + +The simplest “timeout” pattern is still the best one: race work against sleep. + +```lua +local fibers = require "fibers" +local sleep = require "fibers.sleep" + +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() + local which, st, rep, v_or_primary = fibers.perform(fibers.named_choice{ + task = task_op(), -- yields: st, rep, results/primary + timeout = sleep.sleep_op(0.5), + }) + + if which == "timeout" then + print("timed out; task scope cancelled and joined") + return + end + + if st == "ok" then + print("task ok:", v_or_primary) + else + print("task not ok:", st, v_or_primary) + end +end) +``` + +The important bit is not the tagging; it’s that `run_scope_op` makes “a whole subtree” act like a single op, with deterministic cleanup when it loses. + +--- + +## 8. Unscoped errors + +Most user code runs inside scopes created through `fibers.run`, `fibers.spawn`, and `fibers.run_scope`. + +If the runtime encounters a fiber that is not associated with any scope (typically internal fibers or externally spawned ones), uncaught errors are sent to the unscoped error handler: + +```lua +fibers.set_unscoped_error_handler(function(fib, err) + -- fib is the runtime fiber object + -- err is the error value +end) +``` + +The default handler writes to stderr. + +If you see this handler firing in application code, it is usually a sign that something started work “off the books”. + +--- + +## 9. Summary + +* Use `fibers.run` once at the top level. +* Use `fibers.spawn` to start concurrent fibers under the current scope. +* Use `fibers.run_scope` for a structured subtree with a status/report outcome. +* Use `fibers.run_scope_op` when you want that subtree to participate in `choice` (timeouts, races, etc.). +* Use `scope:finally` (and op `bracket`/`:finally`) for cleanup. +* Use `fibers.perform` for waiting; let it throw, and interpret outcomes at boundaries. + +This keeps lifetimes bounded, makes failure and cancellation meaningful, and makes cleanup predictable—even when things go wrong. diff --git a/demos/scoped_line_server.lua b/demos/scoped_line_server.lua new file mode 100644 index 0000000..1748820 --- /dev/null +++ b/demos/scoped_line_server.lua @@ -0,0 +1,244 @@ +-- scoped_line_server.lua +-- +-- Single-file runnable example for the fibers runtime: +-- * UNIX-domain line server +-- * per-connection worker pool using channels +-- * external “work” simulated via /bin/sh (sleep + tr) +-- * timeouts and cancellation via ops + scopes +-- * no pcall in application logic: failures propagate via scopes +-- +-- Run: +-- lua scoped_line_server.lua +-- +-- Optional: +-- SH=/usr/bin/sh lua demo.lua + +package.path = '../src/?.lua;' .. package.path + +local fibers = require 'fibers' +local socket = require 'fibers.io.socket' +local sleep = require 'fibers.sleep' +local chan = require 'fibers.channel' +local exec = require 'fibers.io.exec' + +local function log(msg) + io.stderr:write(('[demo] %s\n'):format(msg)) +end + +local SH = os.getenv('SH') or '/bin/sh' + +math.randomseed(os.time()) +local SOCK_PATH = ('/tmp/fibers-demo-%d-%d.sock'):format(os.time(), math.random(1, 10 ^ 9)) + +-- External work implemented via sh: +-- * sleep briefly (fall back to 1s if fractional sleep unsupported) +-- * uppercase the input via tr +local WORK_SH = table.concat({ + 'sleep 0.2 2>/dev/null || sleep 1', + 'printf "%s" "$1" | tr a-z A-Z', +}, '; ') + +local function perform_named_choice(arms) + return fibers.perform(fibers.named_choice(arms)) +end + +local function handle_client(scope, stream) + scope:finally(function () + stream:close() + end) + + local jobs = chan.new(16) -- backpressure when workers are busy + + local WORKERS = 2 + for _ = 1, WORKERS do + fibers.spawn(function () + while true do + local job = jobs:get() + if job == nil then + return + end + + -- argv: sh -c



 

Advanced search

© 2023 - Privacy - Terms

\ No newline at end of file diff --git a/examples/ops/always.lua b/examples/ops/always.lua new file mode 100644 index 0000000..0c9b489 --- /dev/null +++ b/examples/ops/always.lua @@ -0,0 +1,9 @@ +package.path = '../../src/?.lua;' .. package.path + +local fibers = require 'fibers' + +fibers.run(function () + local op = fibers.always('tea', 2) + local a, b = fibers.perform(op) + print(a, b) -- tea 2 +end) diff --git a/examples/ops/boolean_choice.lua b/examples/ops/boolean_choice.lua new file mode 100644 index 0000000..e5dc260 --- /dev/null +++ b/examples/ops/boolean_choice.lua @@ -0,0 +1,14 @@ +package.path = '../../src/?.lua;' .. package.path + +local fibers = require 'fibers' +local sleep = require 'fibers.sleep' + +fibers.run(function () + local op = fibers.boolean_choice( + sleep.sleep_op(0.05):wrap(function () return 'T' end), + sleep.sleep_op(0.01):wrap(function () return 'F' end) + ) + + local ok, v = fibers.perform(op) + print(ok, v) -- false F +end) diff --git a/examples/ops/bracket.lua b/examples/ops/bracket.lua new file mode 100644 index 0000000..4066ec7 --- /dev/null +++ b/examples/ops/bracket.lua @@ -0,0 +1,28 @@ +package.path = '../../src/?.lua;' .. package.path + +local fibers = require 'fibers' +local sleep = require 'fibers.sleep' + +fibers.run(function () + local function acquire() + print('acquire') + return { id = 1 } + end + + local function release(res, aborted) + print('release', res.id, 'aborted=', aborted) + end + + local function use(res) + return sleep.sleep_op(0.10):wrap(function () return 'used', res.id end) + end + + local protected = fibers.bracket(acquire, release, use) + + local op = fibers.choice( + protected, + sleep.sleep_op(0.01):wrap(function () return 'timeout' end) + ) + + print(fibers.perform(op)) +end) diff --git a/examples/ops/choice.lua b/examples/ops/choice.lua new file mode 100644 index 0000000..994cdb2 --- /dev/null +++ b/examples/ops/choice.lua @@ -0,0 +1,13 @@ +package.path = '../../src/?.lua;' .. package.path + +local fibers = require 'fibers' +local sleep = require 'fibers.sleep' + +fibers.run(function () + local op = fibers.choice( + sleep.sleep_op(0.05):wrap(function () return 'slow' end), + sleep.sleep_op(0.01):wrap(function () return 'fast' end) + ) + + print(fibers.perform(op)) -- fast +end) diff --git a/examples/ops/first_ready.lua b/examples/ops/first_ready.lua new file mode 100644 index 0000000..b4c3e83 --- /dev/null +++ b/examples/ops/first_ready.lua @@ -0,0 +1,14 @@ +package.path = '../../src/?.lua;' .. package.path + +local fibers = require 'fibers' +local sleep = require 'fibers.sleep' + +fibers.run(function () + local op = fibers.first_ready { + sleep.sleep_op(0.05):wrap(function () return 'A' end), + sleep.sleep_op(0.01):wrap(function () return 'B' end), + } + + local i, v = fibers.perform(op) + print(i, v) -- 2 B +end) diff --git a/examples/ops/guard.lua b/examples/ops/guard.lua new file mode 100644 index 0000000..79a6a66 --- /dev/null +++ b/examples/ops/guard.lua @@ -0,0 +1,32 @@ +package.path = '../../src/?.lua;' .. package.path + +local fibers = require 'fibers' +local op = require 'fibers.op' +local sleep = require 'fibers.sleep' + +fibers.run(function () + + local function fake_upstream() + local index, states = 1, { true, true, false, true } + return function () + local retval = states[index] + index = index + 1 + return retval + end + end + + local is_open = fake_upstream() + + local function breaker_op() + return fibers.guard(function () + local ok_op = op.always("ok") + local cooldown_op = sleep.sleep_op(0.1):wrap(function () return 'COOLDOWN' end) + return is_open() and ok_op or cooldown_op + end) + end + + for i = 1, 4 do + local ok = fibers.perform(breaker_op()) + print(i, ok) + end +end) diff --git a/examples/ops/named_choice.lua b/examples/ops/named_choice.lua new file mode 100644 index 0000000..4ba663f --- /dev/null +++ b/examples/ops/named_choice.lua @@ -0,0 +1,14 @@ +package.path = '../../src/?.lua;' .. package.path + +local fibers = require 'fibers' +local sleep = require 'fibers.sleep' + +fibers.run(function () + local op = fibers.named_choice { + slow = sleep.sleep_op(0.05):wrap(function () return 'S' end), + fast = sleep.sleep_op(0.01):wrap(function () return 'F' end), + } + + local name, v = fibers.perform(op) + print(name, v) -- fast F +end) diff --git a/examples/ops/never-or_else.lua b/examples/ops/never-or_else.lua new file mode 100644 index 0000000..e8ab78d --- /dev/null +++ b/examples/ops/never-or_else.lua @@ -0,0 +1,8 @@ +package.path = '../../src/?.lua;' .. package.path + +local fibers = require 'fibers' + +fibers.run(function () + local op = fibers.never():or_else(function () return 'fallback' end) + print(fibers.perform(op)) -- fallback +end) diff --git a/examples/ops/on_abort.lua b/examples/ops/on_abort.lua new file mode 100644 index 0000000..af9e1ee --- /dev/null +++ b/examples/ops/on_abort.lua @@ -0,0 +1,14 @@ +package.path = '../../src/?.lua;' .. package.path + +local fibers = require 'fibers' +local sleep = require 'fibers.sleep' + +fibers.run(function () + local slow = sleep.sleep_op(0.10) + :on_abort(function () print('slow arm aborted') end) + :wrap(function () return 'slow done' end) + + local fast = sleep.sleep_op(0.01):wrap(function () return 'fast done' end) + + print(fibers.perform(fibers.choice(slow, fast))) +end) diff --git a/examples/ops/race.lua b/examples/ops/race.lua new file mode 100644 index 0000000..33085fe --- /dev/null +++ b/examples/ops/race.lua @@ -0,0 +1,19 @@ +package.path = '../../src/?.lua;' .. package.path + +local fibers = require 'fibers' +local sleep = require 'fibers.sleep' + +fibers.run(function () + local op = fibers.race( + { + sleep.sleep_op(0.05):wrap(function () return 'A' end), + sleep.sleep_op(0.01):wrap(function () return 'B' end), + }, + function (i, v) + return ('winner=%d'):format(i), v + end + ) + + local which, v = fibers.perform(op) + print(which, v) -- winner=2 B +end) diff --git a/examples/ops/with_nack_loses.lua b/examples/ops/with_nack_loses.lua new file mode 100644 index 0000000..bd7ee2d --- /dev/null +++ b/examples/ops/with_nack_loses.lua @@ -0,0 +1,17 @@ +package.path = '../../src/?.lua;' .. package.path + +local fibers = require 'fibers' +local op = require 'fibers.op' + +fibers.run(function () + local ev = op.with_nack(function (nack) + -- Arrange to observe nack + return op.choice( + op.never():wrap(function () return 'impossible' end), + nack:wrap(function () return 'nacked' end) + ) + end) + + local chosen = fibers.perform(op.choice(ev, op.always('other'))) + print(chosen) -- other (and the nack branch is enabled, but only if someone perform) +end) diff --git a/examples/ops/with_nack_wins.lua b/examples/ops/with_nack_wins.lua new file mode 100644 index 0000000..6917ef2 --- /dev/null +++ b/examples/ops/with_nack_wins.lua @@ -0,0 +1,30 @@ +package.path = '../../src/?.lua;' .. package.path + +local fibers = require 'fibers' +local op = require 'fibers.op' +local cond = require 'fibers.cond' +local sleep = require 'fibers.sleep' + +fibers.run(function () + local ev = op.with_nack(function (nack) + local done = cond.new() + + fibers.spawn(function () + local which = fibers.perform(op.choice( + nack:wrap(function () return 'nack' end), + done:wait_op():wrap(function () return 'done' end) + )) + if which == 'nack' then print('lost') end + end) + + return sleep.sleep_op(0.05):wrap(function () + done:signal() -- this arm won; stop the watcher + return 'slow done' + end) + end) + + print(fibers.perform(op.choice( + ev, + sleep.sleep_op(0.01):wrap(function () return 'fast done' end) + ))) +end) diff --git a/examples/ops/wrap.lua b/examples/ops/wrap.lua new file mode 100644 index 0000000..99186e0 --- /dev/null +++ b/examples/ops/wrap.lua @@ -0,0 +1,8 @@ +package.path = '../../src/?.lua;' .. package.path + +local fibers = require 'fibers' + +fibers.run(function () + local op = fibers.always(21):wrap(function (n) return n * 2 end) + print(fibers.perform(op)) -- 42 +end) diff --git a/fibers/alarm.lua b/fibers/alarm.lua deleted file mode 100644 index 7445c36..0000000 --- a/fibers/alarm.lua +++ /dev/null @@ -1,278 +0,0 @@ --- Use of this source code is governed by the Apache 2.0 license; see COPYING. - --- Alarms. - -local op = require 'fibers.op' -local fiber = require 'fibers.fiber' -local timer = require 'fibers.timer' -local sc = require 'fibers.utils.syscall' - -local function days_in_year(y) - return y % 4 == 0 and (y % 100 ~= 0 or y % 400 == 0) and 366 or 365 -end - -local function to_time(t) - local new_t = {year = t.year, month=t.month, day=t.day, hour=t.hour, min=t.min, sec=t.sec} - local time = os.time(new_t) + t.msec/1e3 - local time_t = os.date("*t", time) - time_t.msec = t.msec - return time, time_t -end - --- let's define some constants -local periods = {"year", "month", "day", "hour", "min", "sec", "msec"} -local default = {month=1, day=1, hour=0, min=0, sec=0, msec=0} - --- This function validates a table t intended for scheduling an alarm, ensuring --- only appropriate fields are specified based on the scheduling type. -local function validate_next_table(t) - local inc_field - if t.year then - return nil, "year should not be specified for a relative alarm" - elseif t.yday then inc_field = "year" - if t.month or t.wday or t.day then - return nil, "neither month, weekday or day of month valid for day of year alarm" - end - elseif t.month then inc_field = "year" - if t.wday then - return nil, "day of week not valid for yearly alarm" - end - elseif t.day then inc_field = "month" - if t.wday then - return nil, "day of week not valid for monthly alarm" - end - elseif t.wday then inc_field = "day" - elseif t.hour then inc_field = "day" - elseif t.min then inc_field = "hour" - elseif t.sec then inc_field = "min" - elseif t.msec then inc_field = "sec" - else - return nil, "a next alarm must specify at least one of yday, month, day, wday, hour, minute, sec or msec" - end - - return inc_field, nil -end - --- calculates the absolute time until the next occurrence based on a given time --- structure t and the current epoch. -local function calculate_next(t, epoch) - - -- first let's make sure that the provided struct makes sense - local inc_field, _ = validate_next_table(t) -- the time table is pre-validated - - -- let's construct the new date table - local new_t = {} - - local now = os.date("*t", epoch) - now.msec = (epoch - math.floor(epoch)) * 1e3 - - local default_switch = false - for _, name in ipairs(periods) do - if not default_switch and t[name] then default_switch = true end - if (t.wday or t.yday) and name=="hour" then default_switch = true end - new_t[name] = (not default_switch and now[name]) or t[name] or default[name] - end - - -- now let's get the struct we need - local new_time, new_table = to_time(new_t) - - -- wday and yday are weird ones and we need to renormalise - if t.wday then - local increment = (t.wday - new_table.wday + 7) % 7 - new_table.day = new_table.day + increment - new_time, new_table = to_time(new_table) - elseif t.yday then - local no_days = days_in_year(new_table.year) - local increment = (t.yday - new_table.yday + no_days) % no_days - new_table.day = new_table.day + increment - new_time, new_table = to_time(new_table) - end - - if new_time < epoch then - new_table[inc_field] = new_table[inc_field] + 1 - new_time, new_table = to_time(new_table) - end - - return new_time, new_table -end - - -local AlarmHandler = {} -AlarmHandler.__index = AlarmHandler - -local function new_alarm_handler() - local now = sc.realtime() - return setmetatable( - { - realtime = false, - abs_buffer = {}, - next_buffer = {}, - abs_timer = timer.new(now), -- Task list for absolute time scheduling - }, AlarmHandler) -end - -local installed_alarm_handler = nil - ---- Installs the Alarm Handler into the current scheduler. --- Must be called before any alarm operations are used. --- @return The installed AlarmHandler instance. -local function install_alarm_handler() - if not installed_alarm_handler then - installed_alarm_handler = new_alarm_handler() - fiber.current_scheduler:add_task_source(installed_alarm_handler) - end - return installed_alarm_handler -end - ---- Uninstalls the Alarm Handler from the current scheduler. --- This should be called to clean up when the Alarm Handler is no longer needed. -local function uninstall_alarm_handler() - if installed_alarm_handler then - for i, source in ipairs(fiber.current_scheduler.sources) do - if source == installed_alarm_handler then - table.remove(fiber.current_scheduler.sources, i) - break - end - end - installed_alarm_handler = nil - end -end - -function AlarmHandler:schedule_tasks(sched) - local now = sc.realtime() - - self.abs_timer:advance(now, sched) - - while true do - local next_time = self.abs_timer:next_entry_time() - now - if next_time > sched.maxsleep then break end -- an empty timer will return 'inf' here so nil check not needed - local task = self.abs_timer:pop() - sched:schedule_after_sleep(next_time, task.obj) - end -end - -function AlarmHandler:block(time_to_start, t, task) - if time_to_start < fiber.current_scheduler.maxsleep then - fiber.current_scheduler:schedule_after_sleep(time_to_start, task) - else - self.abs_timer:add_absolute(t, task) - end -end - -function AlarmHandler:clock_synced() - self.realtime = true - local now = sc.realtime() - -- Process buffered absolute tasks - for _, buffered in ipairs(self.abs_buffer) do - local time_to_start = buffered.t - now - self:block(time_to_start, buffered.t, buffered.task) - end - -- Process next tasks - for _, buffered in ipairs(self.next_buffer) do - local next_time = calculate_next(buffered.t, now) - local time_to_start = next_time - now - self:block(time_to_start, next_time, buffered.task) - end - self.abs_buffer, self.next_buffer = {}, {} -- Clear the buffer -end - -function AlarmHandler:clock_desynced() - self.realtime = false -end - -function AlarmHandler:wait_absolute_op(t) - local time_to_start - local function try() - if not self.realtime then return false end - time_to_start = t - sc.realtime() - if time_to_start < 0 then return true end - end - local function block(suspension, wrap_fn) - local task = suspension:complete_task(wrap_fn) - if not self.realtime then table.insert(self.abs_buffer, {t=t, task=task}) - return - end - self:block(time_to_start, t, task) - end - return op.new_base_op(nil, try, block) -end - -function AlarmHandler:wait_next_op(t) - local function try() - return false - end - local function block(suspension, wrap_fn) - local task = suspension:complete_task(wrap_fn) - if not self.realtime then table.insert(self.next_buffer, {t=t, task=task}) - return - end - local now = sc.realtime() - local target, _ = calculate_next(t, now) - self:block(target-now, target, task) - end - return op.new_base_op(nil, try, block) -end - ---- Indicates to the Alarm Handler that time synchronisation has been achieved (through NTP or other methods). --- Until the user calls clock_synced() all alarms will block. When called, --- `absolute` alarms will return immediately if their time has elapsed, whereas --- `next` alarms will be scheduled for their next instance -local function clock_synced() - return assert(installed_alarm_handler):clock_synced() -end - ---- Indicates to the Alarm Handler that time synchronisation has been lost. --- All new alarms will be buffered until real-time is achieved. -local function clock_desynced() - return assert(installed_alarm_handler):clock_desynced() -end - ---- Creates an operation for an absolute alarm. --- The operation can be performed immediately if in real-time mode, --- or buffered to be scheduled upon achieving real-time. --- @param t The absolute time (epoch) for the alarm. --- @return A BaseOp representing the absolute alarm operation. -local function wait_absolute_op(t) - return assert(installed_alarm_handler):wait_absolute_op(t) -end - ---- Schedules a task to run at an absolute time. --- Wrapper for `absolute_op` that immediately performs the operation. --- @param t The absolute time (epoch) for the alarm. -local function wait_absolute(t) - return wait_absolute_op(t):perform() -end - ---- Creates an operation for a next (relative) alarm. --- The operation is always buffered until real-time is achieved, --- then scheduled based on the calculated next time. --- @param t A table specifying the relative time for the alarm. --- @return A BaseOp representing the next alarm operation. --- @return An error if the time table is invalid. -local function wait_next_op(t) - local _, err = validate_next_table(t) - return err or assert(installed_alarm_handler):wait_next_op(t) -end - ---- Schedules a task based on a relative next time. --- Wrapper for `next_op` that immediately performs the operation. --- @param t A table specifying the relative time for the alarm. --- @return An error if the time table is invalid. -local function wait_next(t) - local _, err = validate_next_table(t) - return err or assert(installed_alarm_handler):wait_next_op(t):perform() -end - --- Public API -return { - install_alarm_handler = install_alarm_handler, - uninstall_alarm_handler = uninstall_alarm_handler, - clock_synced = clock_synced, - clock_desynced = clock_desynced, - wait_absolute_op = wait_absolute_op, - wait_absolute = wait_absolute, - wait_next_op = wait_next_op, - wait_next = wait_next, - validate_next_table = validate_next_table, - calculate_next = calculate_next -} \ No newline at end of file diff --git a/fibers/channel.lua b/fibers/channel.lua deleted file mode 100644 index 09cc181..0000000 --- a/fibers/channel.lua +++ /dev/null @@ -1,134 +0,0 @@ --- Use of this source code is governed by the Apache 2.0 license; see COPYING. - ---- fibers.channel module --- Provides Concurrent ML style channels for communication between fibers. --- @module fibers.channel - -local op = require 'fibers.op' -local fifo = require 'fibers.utils.fifo' - ---- Channel class --- Represents a communication channel between fibers. --- @type Channel -local Channel = {} -Channel.__index = Channel - ---- Create a new Channel. --- @treturn Channel The created Channel. -local function new(buffer_size) - buffer_size = buffer_size or 0 -- Default to unbuffered - - local buffer = nil - if buffer_size > 0 then - buffer = fifo.new() - end - - return setmetatable({ - buffer = buffer, - buffer_size = buffer_size, - getq = fifo.new(), -- Queue of waiting receivers - putq = fifo.new(), -- Queue of waiting senders - }, Channel) -end - ---- Create a put operation for the Channel. --- Make an operation that if and when it completes will rendezvous with --- a receiver fiber to send VAL over the channel. --- @param val The value to put into the Channel. --- @treturn BaseOp The created put operation. -function Channel:put_op(val) - local getq, putq, buffer, buffer_size = self.getq, self.putq, self.buffer, self.buffer_size - local function try() - -- Case 1: If there's a waiting receiver, complete the rendezvous immediately - while not getq:empty() do - local remote = getq:pop() - if remote.suspension:waiting() then - remote.suspension:complete(remote.wrap, val) - return true - end - -- Otherwise the remote suspension is already completed, pop and continue - end - -- Case 2: If we have a buffer with space, add the value to the buffer - if buffer and buffer:length() < buffer_size then - buffer:push(val) - return true - end - -- Case 3: No receivers and no buffer space - return false - end - local function block(suspension, wrap_fn) - -- First, GC for canceled operations - while not putq:empty() and not putq:peek().suspension:waiting() do - putq:pop() - end - -- No space in buffer and no receivers, so block - putq:push({ suspension = suspension, wrap = wrap_fn, val = val }) - end - return op.new_base_op(nil, try, block) -end - ---- Create a get operation for the Channel. --- Make an operation that if and when it completes will rendezvous with --- a sender fiber to receive one value from the channel. --- @treturn BaseOp The created get operation. -function Channel:get_op() - local getq, putq, buffer = self.getq, self.putq, self.buffer - -- Attempt to service one sender from putq - local function pop_from_putq() - while not putq:empty() do - local remote = putq:pop() - if remote.suspension:waiting() then - remote.suspension:complete(remote.wrap) - return remote - end - end - return nil - end - local function try() - local remote = pop_from_putq() - -- Case 1: Take from buffer if available - if buffer and buffer:length() > 0 then - local val = buffer:pop() - -- Attempt to refill buffer with one sender - if remote then buffer:push(remote.val) end - return true, val - end - -- Case 2: No buffer value; try to take directly from a sender - if remote then return true, remote.val end - -- Case 3: Nothing available so block - return false - end - local function block(suspension, wrap_fn) - -- Clear any stale entries - while not getq:empty() and not getq:peek().suspension:waiting() do - getq:pop() - end - -- Block this receiver - getq:push({ suspension = suspension, wrap = wrap_fn }) - end - return op.new_base_op(nil, try, block) -end - ---- Put a message into the Channel. --- Send message on the channel. If there is already another fiber --- waiting to receive a message on this channel, give it our message and --- continue. Otherwise, block until a receiver becomes available. --- @tparam any message The message to put into the Channel. -function Channel:put(message) - self:put_op(message):perform() -end - ---- Get a message from the Channel. --- Receive a message from the channel and return it. If there is --- already another fiber waiting to send a message on this channel, take --- its message directly. Otherwise, block until a sender becomes --- available. --- @treturn any The message retrieved from the Channel. -function Channel:get() - return self:get_op():perform() -end - ---- @export -return { - new = new -} diff --git a/fibers/cond.lua b/fibers/cond.lua deleted file mode 100644 index 06d4f7f..0000000 --- a/fibers/cond.lua +++ /dev/null @@ -1,59 +0,0 @@ --- Use of this source code is governed by the Apache 2.0 license; see COPYING. - ---- fibers.cond module. --- This module implements a condition variable, a rendezvous point for --- fibers waiting for or announcing the occurrence of an event. --- @module fibers.cond - -local op = require 'fibers.op' - ---- Cond class. --- Represents a condition variable. --- @type Cond -local Cond = {} - ---- Create a new condition variable. --- @treturn Cond The new condition variable. -local function new() - return setmetatable({ waitq = {} }, { __index = Cond }) -end - ---- Create a new operation that will put the fiber into a wait state on the condition variable. --- @treturn operation The created operation. -function Cond:wait_op() - local function try() return not self.waitq end - local function gc() - local i = 1 - while i <= #self.waitq do - if self.waitq[i].suspension:waiting() then - i = i + 1 - else - table.remove(self.waitq, i) - end - end - end - local function block(suspension, wrap_fn) - gc() - table.insert(self.waitq, { suspension = suspension, wrap = wrap_fn }) - end - return op.new_base_op(nil, try, block) -end - ---- Put the fiber into a wait state on the condition variable. -function Cond:wait() return self:wait_op():perform() end - ---- Wake up all fibers that are waiting on this condition variable. -function Cond:signal() - if self.waitq ~= nil then - for _, remote in ipairs(self.waitq) do - if remote.suspension:waiting() then - remote.suspension:complete(remote.wrap) - end - end - self.waitq = nil - end -end - -return { - new = new -} diff --git a/fibers/context.lua b/fibers/context.lua deleted file mode 100644 index 21dd922..0000000 --- a/fibers/context.lua +++ /dev/null @@ -1,185 +0,0 @@ ---- A Lua context library for managing hierarchies of fibers with ---- cancellation, deadlines, and values. ---- This version more closely follows Go's context pattern: ---- - Only cancellation contexts (from with_cancel, with_deadline, ---- with_timeout) trigger cancellation. ---- - Value contexts merely hold extra keys and defer cancellation to their parent. ---- Each context is one of: ---- - base_context: The root logic for value lookup and for deferring .err() ---- and .done_op() to a parent. ---- - cancel_context: Extends base_context with a local cancellation cause and ---- a condition variable for signalling cancellation. ---- - value_context: Extends base_context with a local key/value, with no local ---- cancellation. --- @module context - -local fiber = require "fibers.fiber" -local op = require "fibers.op" -local cond = require "fibers.cond" - --- ------------------------------------------------------------------------ --- base_context: --- Minimal context that defers cancellation, deadlines and values to its parent. --- background() returns a base_context with no parent. --- ------------------------------------------------------------------------ -local base_context = {} -base_context.__index = base_context - ---- Create a new base_context with the specified parent. ---- Typically used internally by derived contexts (cancel_context, ---- value_context). --- @param parent The parent context. --- @return A new base_context. -function base_context:new(parent) - local ctx = setmetatable({ parent = parent, children = {} }, self) - if parent then table.insert(parent.children, ctx) end - return ctx -end - ---- Returns an op that completes when this context is done. ---- For a background context (no parent), the op never completes. -function base_context:done_op() - if self.parent then - return self.parent:done_op() - else - -- Background context: never cancelled, so done_op never completes. - return op.new_base_op(nil, function() return false end, function() end) - end -end - ---- Returns any cancellation cause if known. ---- If none exists, defers to the parent. --- @return The cancellation cause or nil. -function base_context:err() - return self.parent and self.parent:err() or nil -end - ---- Lookup a value in this context. ---- By default, the lookup defers to the parent. --- @param key The key to look up. --- @return The associated value, or nil. -function base_context:value(key) - return self.parent and self.parent:value(key) or nil -end - --- ------------------------------------------------------------------------ --- cancel_context: --- A context that can be cancelled locally or via its parent. --- It uses a condition variable to signal cancellation. --- ------------------------------------------------------------------------ -local cancel_context = {} -cancel_context.__index = cancel_context -setmetatable(cancel_context, { __index = base_context }) - ---- Create a new cancel_context with the specified parent. ---- This arranges for the child's cancellation if the parent is cancelled. --- @param parent The parent context. --- @return A new cancel_context. -function cancel_context:new(parent) - local base = base_context.new(self, parent) - base.cond = cond.new() -- condition variable for signalling cancellation - base.cause = nil -- local cancellation cause - return base -end - ---- Cancel this context with an optional cause. ---- If no cause is provided, "canceled" is used. --- @param cause The cancellation reason. -function cancel_context:cancel(cause) - if self.cause then return end - self.cause = cause or "canceled" - self.cond:signal() -- signal cancellation to waiters - for _, child in ipairs(self.children) do - if child.cancel then child:cancel(cause) end - end -end - ---- Returns an op that completes when this context is cancelled. ---- This op is a choice between the parent's done op and the local cond wait op. -function cancel_context:done_op() - local local_op = self.cond:wait_op() - if self.parent then - return op.choice(self.parent:done_op(), local_op) - else - return local_op - end -end - ---- Overridden err() that checks for a local cancellation cause. -function cancel_context:err() - return self.cause or (self.parent and self.parent:err() or nil) -end - --- ------------------------------------------------------------------------ --- value_context: --- A simple context that stores one additional key/value pair. --- It defers all cancellation to the parent. --- ------------------------------------------------------------------------ -local value_context = {} -value_context.__index = value_context -setmetatable(value_context, { __index = base_context }) - ---- Create a new value_context with the specified parent, key and value. -function value_context:new(parent, key, val) - local ctx = base_context.new(self, parent) - ctx.key, ctx.val = key, val - return ctx -end - ---- Lookup a value in this context. ---- If the key matches this context's key, its value is returned; ---- otherwise, the lookup defers to the parent. --- @param k The key to look up. --- @return The associated value, or nil. -function value_context:value(k) - return k == self.key and self.val or base_context.value(self, k) -end - --- ------------------------------------------------------------------------ --- Top-level functions for external use. --- ------------------------------------------------------------------------ - ---- The root context that is never cancelled and holds no values. --- @return A new background context. -local function background() - return setmetatable({ parent = nil, children = {} }, base_context) -end - ---- Returns a child cancel_context and a cancellation function. --- @param The parent context. --- @return The new cancel_context and a function to cancel it. -local function with_cancel(parent) - local ctx = cancel_context:new(parent) - return ctx, function(cause) ctx:cancel(cause) end -end - ---- Returns a cancel_context that is automatically cancelled when the specified deadline is reached. --- @param The parent context. --- @param The deadline at which the context will be cancelled. --- @return The new with_deadline_context and a function to cancel it. -local function with_deadline(parent, deadline) - local ctx, cancel_fn = with_cancel(parent) - fiber.current_scheduler:schedule_at_time(deadline, { run = function() ctx:cancel("deadline_exceeded") end }) - return ctx, cancel_fn -end - ---- Returns a cancel_context that is automatically cancelled after timeout seconds. --- @param The parent context. --- @param The timeout at which the context will be cancelled. --- @return The new with_timeout_context and a function to cancel it. -local function with_timeout(parent, timeout) - return with_deadline(parent, fiber.now() + timeout) -end - ---- Returns a value_context that stores a key/value pair. -local function with_value(parent, key, val) - return value_context:new(parent, key, val) -end - -return { - background = background, - with_cancel = with_cancel, - with_deadline = with_deadline, - with_timeout = with_timeout, - with_value = with_value -} diff --git a/fibers/epoll.lua b/fibers/epoll.lua deleted file mode 100644 index bc26fa5..0000000 --- a/fibers/epoll.lua +++ /dev/null @@ -1,74 +0,0 @@ --- (c) Snabb project --- (c) Jangala - --- Use of this source code is governed by the XXXXXXXXX license; see COPYING. - --- Epoll. - -local sc = require 'fibers.utils.syscall' -local bit = rawget(_G, "bit") or require 'bit32' - -local Epoll = {} - -local INITIAL_MAXEVENTS = 8 - -local function new() - local ret = { - epfd = assert(sc.epoll_create()), - active_events = {}, - maxevents = INITIAL_MAXEVENTS, - } - return setmetatable(ret, { __index = Epoll }) -end - -local RD = sc.EPOLLIN + sc.EPOLLRDHUP -local WR = sc.EPOLLOUT -local RDWR = RD + WR -local ERR = sc.EPOLLERR + sc.EPOLLHUP - -function Epoll:add(s, events) - -- local fd = type(s) == 'number' and s or sc.fileno(s) - local fd = s - local active = self.active_events[fd] or 0 - local eventmask = bit.bor(events, active, sc.EPOLLONESHOT) - local ok, _ = sc.epoll_modify(self.epfd, fd, eventmask) - if not ok then assert(sc.epoll_register(self.epfd, fd, eventmask)) end - self.active_events[fd] = eventmask -end - -function Epoll:poll(timeout) - -- Returns a table, an iterator would be more efficient. - -- print("self.epfd", self.epfd) - -- print("self.maxevents", self.maxevents) - local events, err = sc.epoll_wait(self.epfd, timeout or 0, self.maxevents) - if not events then - error(err) - end - local count = 0 - -- Since we add fd's with EPOLL_ONESHOT, now that the event has - -- fired, the fd is now deactivated. Record that fact. - for fd, _ in pairs(events) do - count = count + 1 - self.active_events[fd] = nil - end - if count == self.maxevents then - -- If we received `maxevents' events, it means that probably there - -- are more active fd's in the queue that we were unable to - -- receive. Expand our event buffer in that case. - self.maxevents = self.maxevents * 2 - end - return events, err -end - -function Epoll:close() - sc.epoll_close(self.epfd) - self.epfd = nil -end - -return { - new = new, - RD = RD, - WR = WR, - RDWR = RDWR, - ERR = ERR, -} diff --git a/fibers/exec.lua b/fibers/exec.lua deleted file mode 100644 index e61a594..0000000 --- a/fibers/exec.lua +++ /dev/null @@ -1,245 +0,0 @@ --- fibers/exec.lua --- Provides facilities for executing external commands asynchronously using fibers. -local file = require 'fibers.stream.file' -local pollio = require 'fibers.pollio' -local op = require 'fibers.op' -local buffer = require 'string.buffer' -local sc = require 'fibers.utils.syscall' - -local io_mappings = { - stdin = sc.STDIN_FILENO, - stdout = sc.STDOUT_FILENO, - stderr = sc.STDERR_FILENO -} - --- Define the command type -local Cmd = {} -Cmd.__index = Cmd -- set metatable - ---- Constructor for Cmd. --- @param name The name or path of the command to execute. --- @param ... Additional arguments for the command. --- @return A new Cmd instance. -local function command(name, ...) - local self = setmetatable({}, Cmd) - self.path = name - self.args = { ... } - self.process = {} - self.pipes = { - child = {}, - parent = {} - } - return self -end - ---- Constructor for Cmd taking a `context`. --- @param ctx The context to run the command under. --- @param name The name or path of the command to execute. --- @param ... Additional arguments for the command. --- @return A new Cmd instance. -local function command_context(ctx, name, ...) - local cmd = command(name, ...) - cmd.ctx = ctx - return cmd -end - ---- Sets the command to launch with a different pgid. --- @param status True if diff pgid desired. -function Cmd:setpgid(status) - self._setpgid = status - return self -end - ---- Sets the signal to send to the child process on parent death. ---- @param sig integer The signal to send. -function Cmd:setprdeathsig(sig) - self._prdeathsig = sig - return self -end --- Re-emits the specified signals to the child process (or its process group, --- if setpgid(true) is used). This mirrors the behaviour of Go’s exec.Cmd and --- allows child processes to receive termination signals like SIGINT or SIGTERM. -function Cmd:forward_signals(...) - local signals = { ... } - for _, sig in ipairs(signals) do - sc.signal(sig, function() - if self.process and self.process.pid then - local target = self._setpgid and -self.process.pid or self.process.pid - sc.kill(target, sig) - end - end) - end -end - -function Cmd:_output_collector(pipes) - local function close_pipes() - for i = #pipes, 1, -1 do - pipes[i]:close() - pipes[i] = nil - end - end - - local err = self:start() - if err then - close_pipes() - return nil, err - end - - local buf = buffer.new() - - while #pipes > 0 do - local ops = {} - - -- build a read operation for each still-open pipe - for idx, pipe in ipairs(pipes) do - ops[#ops + 1] = pipe:read_some_chars_op():wrap(function(chunk) - if chunk then - buf:put(chunk) - else -- EOF: close and mark for removal - pipe:close() - table.remove(pipes, idx) - end - end) - end - - if self.ctx then - ops[#ops + 1] = self.ctx:done_op():wrap(close_pipes) - end - - op.choice(unpack(ops)):perform() - end - - return buf:tostring(), self:wait() -end - ---- Gets combined stdout + stderr as a single string. --- @return output string on success, or nil + error on failure -function Cmd:combined_output() - if self.ctx and self.ctx:err() then return nil, "context cancelled" end - - local pipes = { self:stdout_pipe(), self:stderr_pipe() } - return self:_output_collector(pipes) -end - ---- Gets the output of stdout. --- @return The output and any error. -function Cmd:output() - if self.ctx and self.ctx:err() then return nil, "context cancelled" end - - local pipes = { self:stdout_pipe() } - return self:_output_collector(pipes) -end - ---- Starts the command and waits for it to complete. --- @return Any error. -function Cmd:run() - local err = self:start() - return err and err or self:wait() -end - ---- Starts the command. --- @return Any error. -function Cmd:start() - if self.process.pid then return "process already started" end - if self.ctx and self.ctx:err() then - for _, v in pairs(self.pipes.child) do sc.close(v) end - return "context cancelled" - end - - local ready_read, ready_write = assert(sc.pipe()) - - local pid = assert(sc.fork()) - if pid == 0 then -- child - if self._prdeathsig then sc.prctl(sc.PR_SET_PDEATHSIG, self._prdeathsig) end - if self._setpgid then assert(sc.setpid('p', 0, 0) == 0) end - - -- pipework - for name, fd in pairs(io_mappings) do - if self.pipes.parent[name] then sc.close(self.pipes.parent[name]) end - - local child_fd = self.pipes.child[name] or - assert(sc.open("/dev/null", (fd == sc.STDIN_FILENO) and sc.O_RDONLY or sc.O_WRONLY)) - - assert(sc.dup2(child_fd, fd)) - if child_fd ~= fd then sc.close(child_fd) end -- always close duplicate if distinct - end - - sc.close(ready_read) - sc.set_cloexec(ready_write) -- Close on successful exec - local _, _, errno = sc.execp(self.path, self.args) -- will not return unless unsuccessful - if errno then - sc.write(ready_write, string.char(errno)) -- Write errno to pipe - sc.close(ready_write) - sc.exit(1) - end - end - -- parent - self.process.pid = pid - for _, v in pairs(self.pipes.child) do sc.close(v) end - - sc.close(ready_write) - ready_read = file.fdopen(ready_read) - local byte = ready_read:read_char() -- will politely block until child is ready - ready_read:close() - if byte then - return "child failed to start: errno=" .. string.byte(byte) - end - - self.process.pidfd = assert(sc.pidfd_open(self.process.pid, 0)) - - return nil -end - ---- Kills the command. --- @return Any error. -function Cmd:kill() - if not self.process.pid then return "process not started" end - if self.process.state then return "process has already completed" end - - local target = self._setpgid and -self.process.pid or self.process.pid - local res, err, errno = sc.kill(target, sc.SIGKILL) - assert(res == 0 or errno == sc.ESRCH, err) -end - -function Cmd:_pipe_creator(stdio) - local rd, wr = assert(sc.pipe()) - self.pipes.parent[stdio] = (stdio == 'stdin') and wr or rd - self.pipes.child[stdio] = (stdio == 'stdin') and rd or wr - return file.fdopen(self.pipes.parent[stdio]) -end - ---- Creates a pipe for stdout. Call `:close()` when finished. --- @return The stdout pipe or an error. -function Cmd:stdout_pipe() return self:_pipe_creator('stdout') end - ---- Creates a pipe for stderr. Call `:close()` when finished. --- @return The stderr pipe or an error. -function Cmd:stderr_pipe() return self:_pipe_creator('stderr') end - ---- Creates a pipe for stdin. Call `:close()` when finished. --- @return The stdin pipe or an error. -function Cmd:stdin_pipe() return self:_pipe_creator('stdin'):setvbuf('no') end - ---- Waits for the command to complete. --- @return The completion status or an error. -function Cmd:wait() - local ops = { pollio.fd_readable_op(self.process.pidfd) } - - if self.ctx then - ops[#ops + 1] = self.ctx:done_op():wrap(function() - self:kill() - pollio.fd_readable_op(self.process.pidfd):perform() - end) - end - - op.choice(unpack(ops)):perform() - local _, _, status = sc.waitpid(self.process.pid) - self.process.state = status - sc.close(self.process.pidfd) - if status ~= 0 then return status end -end - -return { - command = command, - command_context = command_context -} diff --git a/fibers/fiber.lua b/fibers/fiber.lua deleted file mode 100644 index a3c7d94..0000000 --- a/fibers/fiber.lua +++ /dev/null @@ -1,157 +0,0 @@ --- (c) Snabb project --- (c) Jangala - --- Use of this source code is governed by the XXXXXXXXX license; see COPYING. - ---- Fiber module. --- Implements a fiber system using Lua's coroutines for cooperative multitasking. --- @module fibers.fiber - --- Required packages -local sched = require 'fibers.sched' - -local current_fiber -local current_scheduler = sched.new() - ---- The Fiber class --- Represents a single fiber, or lightweight thread. --- @type Fiber -local Fiber = {} -Fiber.__index = Fiber - ---- Spawns a new fiber. --- @function spawn --- @tparam function fn The function to run in the new fiber. -local function spawn(fn) - -- Capture the traceback - local tb = debug.traceback("", 2):match("\n[^\n]*\n(.*)") or "" - -- If we're inside another fiber, append the traceback to the parent's traceback - if current_fiber and current_fiber.traceback then - tb = tb .. "\n" .. current_fiber.traceback - end - - current_scheduler:schedule( - setmetatable({ - coroutine = coroutine.create(fn), - alive = true, - sockets = {}, - traceback = tb - }, Fiber)) -end - ---- Resumes execution of the fiber. --- If the fiber is already dead, this will throw an error. --- @tparam vararg ... The arguments to pass to the fiber. -function Fiber:resume(wrap, ...) - assert(self.alive, "dead fiber") -- checks that the fiber is alive - local saved_current_fiber = current_fiber -- shift the old current fiber into a safe place - current_fiber = self -- we are the new current fiber - local ok, err = coroutine.resume(self.coroutine, wrap, ...) -- rev up our coroutine - -- current_fiber = saved_current_fiber the KEY bit, we only get here when the coroutine above has yielded, - -- but we then pop back in the fiber we previously displaced - current_fiber = saved_current_fiber - if not ok then - print('Error while running fiber: ' .. tostring(err)) - print(debug.traceback(self.coroutine)) - print('fibers history:\n' .. self.traceback) - os.exit(255) - end -end - -Fiber.run = Fiber.resume - ---- Suspends execution of the fiber. --- The fiber will be resumed when the provided blocking function finishes. --- @tparam function block_fn The function to block on. --- @tparam vararg ... The arguments to pass to the blocking function. -function Fiber:suspend(block_fn, ...) - assert(current_fiber == self) - -- The block_fn should arrange to reschedule the fiber when it - -- becomes runnable. - block_fn(current_scheduler, current_fiber, ...) - return coroutine.yield() -end - ---- Returns the socket associated with the provided descriptor. --- @tparam number sd The socket descriptor. --- @treturn table The socket. -function Fiber:get_socket(sd) - return assert(self.sockets[sd]) -end - ---- Adds a new socket to the fiber. --- @tparam table sock The socket to add. --- @treturn number The descriptor of the added socket. -function Fiber:add_socket(sock) - local sd = #self.sockets - -- FIXME: add refcount on socket - self.sockets[sd] = sock - return sd -end - ---- Closes the socket associated with the provided descriptor. --- @tparam number sd The socket descriptor. -function Fiber:close_socket(sd) - self:get_socket(sd) - self.sockets[sd] = nil - -- FIXME: remove refcount on socket -end - ---- Waits until the socket associated with the provided descriptor is readable. --- @tparam number sd The socket descriptor. -function Fiber:wait_for_readable(sd) - local s = self:get_socket(sd) - current_scheduler:resume_when_readable(s, self) - return coroutine.yield() -end - ---- Waits until the socket associated with the provided descriptor is writable. --- @tparam number sd The socket descriptor. -function Fiber:wait_for_writable(sd) - local s = self:get_socket(sd) - current_scheduler:schedule_when_writable(s, self) - return coroutine.yield() -end - ---- Returns the traceback of the fiber. --- @function get_traceback -function Fiber:get_traceback() - return self.traceback or "No traceback available" -end - ---- Returns the current time according to the current scheduler. --- @treturn number The current time. -local function now() return current_scheduler:now() end - ---- Suspends execution of the current fiber. --- The fiber will be resumed when the provided blocking function finishes. --- @function suspend --- @tparam function block_fn The function to block on. --- @tparam vararg ... The arguments to pass to the blocking function. -local function suspend(block_fn, ...) return current_fiber:suspend(block_fn, ...) end - -local function schedule(scheduler, fiber) scheduler:schedule(fiber) end - ---- Suspends execution of the current fiber. --- The fiber will be resumed when the scheduler is ready to run it again. --- @function yield -local function yield() return suspend(schedule) end - ---- Stops the current scheduler from running more tasks. --- @function stop -local function stop() current_scheduler:stop() end - ---- Runs the main event loop of the current scheduler. --- The scheduler will continue to run tasks and wait for events until stopped. --- @function main -local function main() return current_scheduler:main() end - -return { - current_scheduler = current_scheduler, - spawn = spawn, - now = now, - suspend = suspend, - yield = yield, - stop = stop, - main = main -} diff --git a/fibers/op.lua b/fibers/op.lua deleted file mode 100644 index 0592ac2..0000000 --- a/fibers/op.lua +++ /dev/null @@ -1,199 +0,0 @@ --- (c) Snabb project --- (c) Jangala - --- Use of this source code is governed by the XXXXXXXXX license; see COPYING. - ---- fibers.op module --- Provides Concurrent ML style operations for managing concurrency. --- @module fibers.op - -local fiber = require 'fibers.fiber' - -local unpack = table.unpack or unpack -- luacheck: ignore -- Compatibility fallback -local pack = table.pack or function(...) -- luacheck: ignore -- Compatibility fallback - return { n = select("#", ...), ... } -end - -local Suspension = {} -Suspension.__index = Suspension - -local CompleteTask = {} -CompleteTask.__index = CompleteTask - -function Suspension:waiting() return self.state == 'waiting' end - -function Suspension:complete(wrap, ...) - assert(self:waiting()) - self.state = 'synchronized' - self.wrap = wrap - self.val = {...} - self.sched:schedule(self) -end - -function Suspension:complete_and_run(wrap, ...) - assert(self:waiting()) - self.state = 'synchronized' - return self.fiber:resume(wrap, ...) -end - -function Suspension:complete_task(wrap, ...) - return setmetatable({ suspension = self, wrap = wrap, val = {...} }, CompleteTask) -end - -function Suspension:run() - assert(not self:waiting()) - return self.fiber:resume(self.wrap, unpack(self.val)) -end - -local function new_suspension(sched, fib) - return setmetatable( - { state = 'waiting', sched = sched, fiber = fib }, - Suspension) -end - ---- A complete task is a task that when run, completes a suspension, if ---- the suspension hasn't been completed already. There can be multiple ---- complete tasks for a given suspension, if the suspension can complete ---- in multiple ways (e.g. via a choice op). -function CompleteTask:run() - if self.suspension:waiting() then - -- Use complete-and-run so that the fiber runs in this turn. - self.suspension:complete_and_run(self.wrap, unpack(self.val)) - end -end - ---- A complete task can also be cancelled, which makes it complete with a ---- call to "error". --- @param reason A string describing the reason for the cancellation -function CompleteTask:cancel(reason) - if self.suspension:waiting() then - self.suspension:complete(error, reason or 'cancelled') - end -end - ---- BaseOp class --- Represents a base operation. --- @type BaseOp -local BaseOp = {} -BaseOp.__index = BaseOp - ---- Create a new base operation. --- @tparam function wrap_fn The wrap function. --- @tparam function try_fn The try function. --- @tparam function block_fn The block function. --- @treturn BaseOp The created base operation. -local function new_base_op(wrap_fn, try_fn, block_fn) - if wrap_fn == nil then wrap_fn = function(...) return ... end end - return setmetatable( - { wrap_fn = wrap_fn, try_fn = try_fn, block_fn = block_fn }, - BaseOp) -end - ---- ChoiceOp class --- Represents a choice operation. --- @type ChoiceOp -local ChoiceOp = {} -ChoiceOp.__index = ChoiceOp -local function new_choice_op(base_ops) - return setmetatable( - { base_ops = base_ops }, - ChoiceOp) -end - ---- Create a choice operation from the given operations. --- @tparam vararg ops The operations. --- @treturn ChoiceOp The created choice operation. -local function choice(...) - local ops = {} - -- Build a flattened list of choices that are all base ops. - for _, op in ipairs({ ... }) do - if op.base_ops then - for _, base_op in ipairs(op.base_ops) do table.insert(ops, base_op) end - else - table.insert(ops, op) - end - end - if #ops == 1 then return ops[1] end - return new_choice_op(ops) -end - ---- Wrap the base operation with the given function. --- @tparam function f The function. --- @treturn BaseOp The created base operation. -function BaseOp:wrap(f) - local wrap_fn, try_fn, block_fn = self.wrap_fn, self.try_fn, self.block_fn - return new_base_op(function(...) return f(wrap_fn(...)) end, try_fn, block_fn) -end - ---- Wrap the choice operation with the given function. --- @tparam function f The function. --- @treturn ChoiceOp The created choice operation. -function ChoiceOp:wrap(f) - local ops = {} - for _, op in ipairs(self.base_ops) do table.insert(ops, op:wrap(f)) end - return new_choice_op(ops) -end - -local function block_base_op(sched, fib, op) - op.block_fn(new_suspension(sched, fib), op.wrap_fn) -end - ---- Perform the base operation. --- @treturn vararg The value returned by the operation. -function BaseOp:perform() - local retval = pack(self.try_fn()) - local success = table.remove(retval, 1) - if success then return self.wrap_fn(unpack(retval)) end - local new_retval = pack(fiber.suspend(block_base_op, self)) - local wrap = table.remove(new_retval, 1) - return wrap(unpack(new_retval)) -end - -local function block_choice_op(sched, fib, ops) - local suspension = new_suspension(sched, fib) - for _, op in ipairs(ops) do op.block_fn(suspension, op.wrap_fn) end -end - ---- Perform the choice operation. --- @treturn vararg The value returned by the operation. -function ChoiceOp:perform() - local ops = self.base_ops - local base = math.random(#ops) - for i = 1, #ops do - local op = ops[((i + base) % #ops) + 1] - local retval = pack(op.try_fn()) - local success = table.remove(retval, 1) - if success then return op.wrap_fn(unpack(retval)) end - end - local retval = pack(fiber.suspend(block_choice_op, ops)) - local wrap = table.remove(retval, 1) - return wrap(unpack(retval)) -end - ---- Perform the base operation or return the result of the function if the operation cannot be performed. --- @tparam function f The function. --- @treturn vararg The value returned by the operation or the function. -function BaseOp:perform_alt(f) - local success, val = self.try_fn() - if success then return self.wrap_fn(val) end - return f() -end - ---- Perform the choice operation or return the result of the function if the operation cannot be performed. --- @tparam function f The function. --- @treturn vararg The value returned by the operation or the function. -function ChoiceOp:perform_alt(f) - local ops = self.base_ops - local base = math.random(#ops) - for i = 1, #ops do - local op = ops[((i + base) % #ops) + 1] - local success, val = op.try_fn() - if success then return op.wrap_fn(val) end - end - return f() -end - -return { - new_base_op = new_base_op, - choice = choice -} diff --git a/fibers/pollio.lua b/fibers/pollio.lua deleted file mode 100644 index d78f97a..0000000 --- a/fibers/pollio.lua +++ /dev/null @@ -1,196 +0,0 @@ --- Use of this source code is governed by the Apache 2.0 license; see COPYING. - --- File events. - -local op = require 'fibers.op' -local fiber = require 'fibers.fiber' -local epoll = require 'fibers.epoll' -local sc = require 'fibers.utils.syscall' -local bit = rawget(_G, "bit") or require 'bit32' - -local PollIOHandler = {} -PollIOHandler.__index = PollIOHandler - -local function new_poll_io_handler() - return setmetatable( - { - epoll = epoll.new(), - waiting_for_readable = {}, -- sock descriptor => array of task - waiting_for_writable = {} - }, -- sock descriptor => array of task - PollIOHandler) -end - -function PollIOHandler:init_nonblocking(fd) - sc.set_nonblock(fd) -end - -local function add_waiter(fd, waiters, task) - local tasks = waiters[fd] - if tasks == nil then - tasks = {}; waiters[fd] = tasks - end - table.insert(tasks, task) -end - -local function make_block_fn(fd, waiting, poll, events) - return function(suspension, wrap_fn) - local task = suspension:complete_task(wrap_fn) - -- local fd = sc.fileno(fd) - add_waiter(fd, waiting, task) - poll:add(fd, events) - end -end - -function PollIOHandler:task_on_readable(fd, task) - add_waiter(fd, self.waiting_for_readable, task) - self.epoll:add(fd, epoll.RD) -end - -function PollIOHandler:task_on_writable(fd, task) - add_waiter(fd, self.waiting_for_writable, task) - self.epoll:add(fd, epoll.WR) -end - -function PollIOHandler:fd_readable_op(fd) - local function try() return false end - local block = make_block_fn( - fd, self.waiting_for_readable, self.epoll, epoll.RD) - return op.new_base_op(nil, try, block) -end - -function PollIOHandler:fd_writable_op(fd) - local function try() return false end - local block = make_block_fn( - fd, self.waiting_for_writable, self.epoll, epoll.WR) - return op.new_base_op(nil, try, block) -end - -function PollIOHandler:stream_readable_op(stream) - local fd = assert(stream.io.fd) - local function try() return not stream.rx:is_empty() end - local block = make_block_fn( - fd, self.waiting_for_readable, self.epoll, epoll.RD) - return op.new_base_op(nil, try, block) -end - --- A stream_writable_op is the same as fd_writable_op, as a stream's --- buffer is never left full -- any stream method that fills the buffer --- flushes it directly. Knowing something about the buffer state --- doesn't tell us anything useful. -function PollIOHandler:stream_writable_op(stream) - local fd = assert(stream.io.fd) - return self:fd_writable_op(fd) -end - -local function schedule_tasks(sched, tasks) - -- It's possible for tasks to be nil, as an IO error will notify for - -- both readable and writable, and maybe we only have tasks waiting - -- for one side. - if tasks == nil then return end - for i = 1, #tasks do - sched:schedule(tasks[i]) - tasks[i] = nil - end -end - --- These method is called by the fibers scheduler. -function PollIOHandler:schedule_tasks(sched, _, timeout) - if timeout == nil then timeout = 0 end - if timeout >= 0 then timeout = timeout * 1e3 end - for fd, event in pairs(self.epoll:poll(timeout)) do - if bit.band(event, epoll.RD + epoll.ERR) ~= 0 then - local tasks = self.waiting_for_readable[fd] - schedule_tasks(sched, tasks) - end - if bit.band(event, epoll.WR + epoll.ERR) ~= 0 then - local tasks = self.waiting_for_writable[fd] - schedule_tasks(sched, tasks) - end - end -end - -PollIOHandler.wait_for_events = PollIOHandler.schedule_tasks - -function PollIOHandler:cancel_tasks_for_fd(fd) - local function cancel_tasks(waiting) - local tasks = waiting[fd] - if tasks ~= nil then - for i = 1, #tasks do tasks[i]:cancel() end - waiting[fd] = nil - end - end - cancel_tasks(self.waiting_for_readable) - cancel_tasks(self.waiting_for_writable) -end - -function PollIOHandler:cancel_all_tasks() - for fd, _ in pairs(self.waiting_for_readable) do - self:cancel_tasks_for_fd(fd) - end - for fd, _ in pairs(self.waiting_for_writable) do - self:cancel_tasks_for_fd(fd) - end -end - -local installed = 0 -local installed_poll_handler -local function install_poll_io_handler() - installed = installed + 1 - if installed == 1 then - installed_poll_handler = new_poll_io_handler() - -- file.set_blocking_handler(installed_poll_handler) - fiber.current_scheduler:add_task_source(installed_poll_handler) - end - return installed_poll_handler -end - -local function uninstall_poll_io_handler() - installed = installed - 1 - if installed == 0 then - -- file.set_blocking_handler(nil) - -- FIXME: Remove task source. - for i, source in ipairs(fiber.current_scheduler.sources) do - if source == installed_poll_handler then - table.remove(fiber.current_scheduler.sources, i) - break - end - end - installed_poll_handler.epoll:close() - installed_poll_handler = nil - end -end - -local function init_nonblocking(fd) - return assert(installed_poll_handler):init_nonblocking(fd) -end -local function fd_readable_op(fd) - return assert(installed_poll_handler):fd_readable_op(fd) -end -local function fd_readable(fd) - return fd_readable_op(fd):perform() -end -local function fd_writable_op(fd) - return assert(installed_poll_handler):fd_writable_op(fd) -end -local function fd_writable(fd) - return fd_writable_op(fd):perform() -end -local function stream_readable_op(stream) - return assert(installed_poll_handler):stream_readable_op(stream) -end -local function stream_writable_op(stream) - return assert(installed_poll_handler):stream_writable_op(stream) -end - -return { - init_nonblocking = init_nonblocking, - fd_readable_op = fd_readable_op, - fd_readable = fd_readable, - fd_writable_op = fd_writable_op, - fd_writable = fd_writable, - stream_readable_op = stream_readable_op, - stream_writable_op = stream_writable_op, - install_poll_io_handler = install_poll_io_handler, - uninstall_poll_io_handler = uninstall_poll_io_handler -} diff --git a/fibers/queue.lua b/fibers/queue.lua deleted file mode 100644 index e5ec8fc..0000000 --- a/fibers/queue.lua +++ /dev/null @@ -1,19 +0,0 @@ --- Use of this source code is governed by the Apache 2.0 license; see COPYING. - ---- fibers.queue module --- Buffered channels for communication between fibers. --- @module fibers.queue - -local channel = require 'fibers.channel' - ---- Create a new Queue. --- @int[opt] bound The upper bound for the number of items in the queue. --- @treturn Queue The created Queue. -local function new(bound) - if bound then assert(bound >= 1) end - return channel.new(bound and bound or math.huge) -end - -return { - new = new -} diff --git a/fibers/sched.lua b/fibers/sched.lua deleted file mode 100644 index 46065fa..0000000 --- a/fibers/sched.lua +++ /dev/null @@ -1,146 +0,0 @@ --- (c) Snabb project --- (c) Jangala - --- Use of this source code is governed by the XXXXXXXXX license; see COPYING. - ---- Scheduler module. --- Implements the core scheduler for managing tasks. --- @module fibers.sched - --- Required modules -local sc = require 'fibers.utils.syscall' -local timer = require 'fibers.timer' - --- Constants -local MAX_SLEEP_TIME = 10 - -local Scheduler = {} -Scheduler.__index = Scheduler - ---- Creates a new Scheduler. --- @function new --- @return A new Scheduler. -local function new() - local ret = setmetatable( - { next = {}, cur = {}, sources = {}, wheel = timer.new(nil), maxsleep = MAX_SLEEP_TIME }, - Scheduler) - local timer_task_source = { wheel = ret.wheel } - -- private method for timer_tast_source - function timer_task_source:schedule_tasks(sched, now) - self.wheel:advance(now, sched) - end - - -- private method for timer_tast_source - function timer_task_source:cancel_all_tasks() - -- Implement me! - end - - ret:add_task_source(timer_task_source) - return ret -end - ---- Adds a task source to the scheduler. --- @param source The source to add. -function Scheduler:add_task_source(source) - table.insert(self.sources, source) - if source.wait_for_events then self.event_waiter = source end -end - ---- Schedules a task. --- @param task Task to be scheduled. -function Scheduler:schedule(task) - table.insert(self.next, task) -end - ---- Gets the current time from the timer wheel. --- @return Current time. -function Scheduler:now() - return self.wheel.now -end - ---- Schedules a task to be run at a specific time. --- @tparam number t The time to run the task. --- @tparam function task The task to run. -function Scheduler:schedule_at_time(t, task) - self.wheel:add_absolute(t, task) -end - ---- Schedules a task to be run after a certain delay. --- @tparam number dt The delay after which to run the task. --- @tparam function task The task to run. -function Scheduler:schedule_after_sleep(dt, task) - self.wheel:add_delta(dt, task) -end - ---- Schedules tasks from all sources to the scheduler. --- @tparam number now The current time. -function Scheduler:schedule_tasks_from_sources(now) - for i = 1, #self.sources do - self.sources[i]:schedule_tasks(self, now) - end -end - ---- Runs all scheduled tasks in the scheduler. --- If a specific time is provided, tasks scheduled for that time are run. --- @tparam number now (optional) The time to run tasks for. -function Scheduler:run(now) - if now == nil then now = self:now() end - self:schedule_tasks_from_sources(now) - self.cur, self.next = self.next, self.cur - for i = 1, #self.cur do - local task = self.cur[i] - self.cur[i] = nil - task:run() - end -end - ---- Returns the time of the next scheduled task. --- @treturn number The time of the next task. -function Scheduler:next_wake_time() - if #self.next > 0 then return self:now() end - return self.wheel:next_entry_time() -end - ---- Waits for the next scheduled event. -function Scheduler:wait_for_events() - local now, next_time = sc.monotime(), self:next_wake_time() - local timeout = math.min(self.maxsleep, next_time - now) - timeout = math.max(timeout, 0) - if self.event_waiter then - self.event_waiter:wait_for_events(self, now, timeout) - else - sc.floatsleep(timeout) - end -end - ---- Stops the main loop of the Scheduler. -function Scheduler:stop() - self.done = true -end - ---- Runs the main event loop of the scheduler. --- The scheduler will continue to run tasks and wait for events until stopped. -function Scheduler:main() - self.done = false - repeat - self:wait_for_events() - self:run(sc.monotime()) - until self.done -end - ---- Shuts down the scheduler. --- Cancels all tasks from all sources and runs remaining tasks. --- If there are still tasks after 100 attempts, returns false. --- @treturn boolean Whether the shutdown was successful. -function Scheduler:shutdown() - for _ = 1, 100 do - for i = 1, #self.sources do self.sources[i]:cancel_all_tasks(self) end - if #self.next == 0 then return true end - self:run() - end - return false -end - -return { - new = new -} diff --git a/fibers/sleep.lua b/fibers/sleep.lua deleted file mode 100644 index 808d9cb..0000000 --- a/fibers/sleep.lua +++ /dev/null @@ -1,57 +0,0 @@ --- Use of this source code is governed by the Apache 2.0 license; see COPYING. - ---- fibers.sleep module. --- Provides functions to suspend execution of fibers for a certain duration (sleep) or until a specific time. --- @module fibers.sleep - -local op = require 'fibers.op' -local fiber = require 'fibers.fiber' - ---- Timeout class. --- Represents a timeout for a fiber. --- @type Timeout --- local Timeout = {} --- Timeout.__index = Timeout - ---- Create a new operation that puts the current fiber to sleep until the time t. --- @tparam number t The time to sleep until. --- @treturn operation The created operation. -local function sleep_until_op(t) - local function try() - return t <= fiber.now() - end - local function block(suspension, wrap_fn) - suspension.sched:schedule_at_time(t, suspension:complete_task(wrap_fn)) - end - return op.new_base_op(nil, try, block) -end - ---- Put the current fiber to sleep until time t. --- @tparam number t The time to sleep until. -local function sleep_until(t) - return sleep_until_op(t):perform() -end - ---- Create a new operation that puts the current fiber to sleep for a duration dt. --- @tparam number dt The duration to sleep. --- @treturn operation The created operation. -local function sleep_op(dt) - local function try() return dt <= 0 end - local function block(suspension, wrap_fn) - suspension.sched:schedule_after_sleep(dt, suspension:complete_task(wrap_fn)) - end - return op.new_base_op(nil, try, block) -end - ---- Put the current fiber to sleep for a duration dt. --- @tparam number dt The duration to sleep. -local function sleep(dt) - return sleep_op(dt):perform() -end - -return { - sleep = sleep, - sleep_op = sleep_op, - sleep_until = sleep_until, - sleep_until_op = sleep_until_op -} diff --git a/fibers/stream.lua b/fibers/stream.lua deleted file mode 100644 index e8639da..0000000 --- a/fibers/stream.lua +++ /dev/null @@ -1,504 +0,0 @@ ---- Module implementing a fiber-aware streaming I/O interface. --- This module provides an abstraction layer over typical I/O operations, --- facilitating buffered reads and writes with non-blocking support --- in a fiber-based concurrency framework. --- @module fibers.stream - -local sc = require 'fibers.utils.syscall' -local op = require 'fibers.op' -local fixed_buffer = require 'fibers.utils.fixed_buffer' -local buffer = require 'string.buffer' - -local unpack = table.unpack or unpack -- luacheck: ignore -- Compatibility fallback - -local Stream = {} -Stream.__index = Stream - -local DEFAULT_BUFFER_SIZE = 2^12 -- 4096 bytes as a sensible default buffer size - ---- Open a new stream. --- Creates and returns a new stream object. --- @param io The underlying I/O object to wrap. Must support read, write, and optionally seek. --- @param readable Whether the stream should be readable. Defaults to true if not specified. --- @param writable Whether the stream should be writable. Defaults to true if not specified. --- @param buffer_size The size of the buffer to use for the stream. Defaults to 4096 bytes. --- @return A new stream object. -local function open(io, readable, writable, buffer_size) - local ret = setmetatable( - { io = io, line_buffering = false, random_access = false }, - Stream) - if readable ~= false then - ret.rx = fixed_buffer.new(buffer_size or DEFAULT_BUFFER_SIZE) - end - if writable ~= false then - ret.tx = fixed_buffer.new(buffer_size or DEFAULT_BUFFER_SIZE) - end - if io.seek and io:seek(sc.SEEK_CUR, 0) then ret.random_access = true end - return ret -end - ---- Check if an object is a stream. --- @param x The object to check. --- @return True if the object is a stream, false otherwise. -local function is_stream(x) - return type(x) == 'table' and getmetatable(x) == Stream -end - ---- Set the stream to non-blocking mode. -function Stream:nonblock() self.io:nonblock() end - ---- Set the stream to blocking mode. -function Stream:block() self.io:block() end - -local function core_write_op(stream, buf, count, flush_needed) - local ptr = nil - if buf then - ptr = buf:ref() - end - - local tally = 0 - local write_directly - - local function write_attempt() - while true do - -- Flush buffered writes first - if flush_needed then - while not stream.tx:is_empty() do - local written, err = stream.io:write(stream.tx:peek()) - if err then return true, tally, err end - if written == nil then - stream._part_write.tally = tally - return false - end - if written == 0 then return true, tally, nil end - stream.tx:advance_read(written) - end - flush_needed = nil - end - if tally == count then - return true, tally - end - if write_directly then - assert(ptr, "write_directly requires a non-nil buffer") - local written, err = stream.io:write(ptr + tally, count - tally) - if err then return true, tally, err end - if written == nil then - stream._part_write.tally = tally - return false - end - if written == 0 then return true, tally, nil end - tally = tally + written - else - assert(ptr, "buffer required for buffered write") - local to_write = math.min(stream.tx:write_avail(), count - tally) - stream.tx:write(ptr + tally, to_write) - tally = tally + to_write - flush_needed = stream.tx:is_full() or - (stream.line_buffering and stream.tx:find('\n')) - end - end - end - - local function try() - stream._part_write = { buf = buf, count = count, tally = 0 } - write_directly = buf ~= nil and count >= stream.tx.size - return write_attempt() - end - - local function block(suspension, wrap_fn) - local task = {} - task.run = function() - if not suspension:waiting() then return end - local success, _, err = write_attempt() - if success then - suspension:complete(wrap_fn, tally, err) - else - stream.io:task_on_writable(task) - end - end - stream.io:task_on_writable(task) - end - - local function wrap(...) - stream._part_write = nil - return ... - end - - return op.new_base_op(wrap, try, block) -end - - - -function Stream:partial_write() - if self._part_write then return self._part_write.tally end -end - -function Stream:reset_partial_write() - self._part_write = nil -end - -function Stream:write_chars_op(str) - local buf = require("string.buffer").new() - buf:set(str) -- zero-copy reference to the string - return core_write_op(self, buf, #str) -end - ---- Write a string to the stream. --- @param n string to write --- @return number of bytes written --- @return error encountered during the write -function Stream:write_chars(str) - return self:write_chars_op(str):perform() -end - -local function core_read_op(stream, buf, min, max, terminator) - local tally = 0 - local function find_terminator() - local term_loc = stream.rx:find(terminator) - if term_loc then - local final = tally + term_loc + #terminator - return final, final - end - return min, max - end - - local function read_attempt() - while true do - if terminator then - min, max = find_terminator() - end - - local from_buffer = math.min(stream.rx:read_avail(), max - tally) - local ptr = buf:reserve(from_buffer) - stream.rx:read(ptr, from_buffer) - buf:commit(from_buffer) - tally = tally + from_buffer - if tally >= min then return true, buf, tally end - - stream.rx:reset() - local ptr2, _ = stream.rx:reserve(stream.rx.size) - local did_read, err = stream.io:read(ptr2, stream.rx.size) - stream.rx:commit(did_read or 0) - - if err then - return true, buf, tally, err - elseif did_read == nil then - stream._part_read.tally = tally - return false - elseif did_read == 0 then - return true, buf, tally, nil - end - end - end - - local function try() - stream._part_read = { buf = buf, tally = 0 } - return read_attempt() - end - - local function block(suspension, wrap_fn) - local task = {} - task.run = function() - if not suspension:waiting() then return end - local success, _, _, err = read_attempt() - if success then - suspension:complete_and_run(wrap_fn, buf, tally, err) - else - stream.io:task_on_readable(task) - end - end - stream.io:task_on_readable(task) - end - - local function wrap(...) - stream._part_read = nil - return ... - end - - return op.new_base_op(wrap, try, block) -end - -function Stream:partial_read() - if self._part_read then - return self._part_read.tally, self._part_read.buf:tostring() - end -end - -function Stream:reset_partial_read() - self._part_read = nil -end - ---- Operation to read a specified number of characters from the stream. --- @param count number of characters to read --- @return operation -function Stream:read_chars_op(count) - local buf = buffer.new(count) - return core_read_op(self, buf, count, count):wrap(function(ret_buf, cnt, err) - if cnt == 0 then return nil, err end - return ret_buf:tostring(), err - end) -end - ---- Read a specified number of characters from the stream. --- @param count number of characters to read --- @return string containing the characters read --- @return error during read, if any -function Stream:read_chars(count) - return self:read_chars_op(count):perform() -end - ---- Operation to read up to a specified number of characters from the stream. --- @param count maximum number of characters to read --- @return operation -function Stream:read_some_chars_op(count) - if count == nil then count = self.rx.size end - local buf = buffer.new(count) - return core_read_op(self, buf, 1, count):wrap(function(ret_buf, cnt, err) - return cnt > 0 and ret_buf:tostring() or nil, err - end) -end - ---- Read up to a specified number of characters from the stream. --- @param count maximum number of characters to read --- @return string containing the characters read --- @return error during read, if any -function Stream:read_some_chars(count) - return self:read_some_chars_op(count):perform() -end - ---- Operation to read all characters from the stream. --- @return operation -function Stream:read_all_chars_op() - local buf = buffer.new(self.rx.size) - return core_read_op(self, buf, math.huge, math.huge):wrap(function(ret_buf, _, err) - return ret_buf:tostring(), err - end) -end - ---- Read all characters from the stream. --- @return string containing all characters read --- @return error during read, if any -function Stream:read_all_chars() - return self:read_all_chars_op():perform() -end - ---- Operation to read a single character from the stream. --- @return operation -function Stream:read_char_op() - local buf = buffer.new(1) - return core_read_op(self, buf, 1, 1):wrap(function(ret_buf, cnt, err) - return cnt == 1 and ret_buf:tostring() or nil, err - end) -end - ---- Read a single character from the stream. --- @return the character read, or nil if at end of file --- @return error during read, if any -function Stream:read_char() - return self:read_char_op():perform() -end - ---- Operation to read a line from the stream. --- @param style 'keep' to keep the line terminator, 'discard' to remove it (default 'discard') --- @return operation -function Stream:read_line_op(style) - style = style or 'discard' - local buf = buffer.new(self.rx.size) - return core_read_op(self, buf, math.huge, math.huge, "\n"):wrap(function(ret_buf, cnt) - if cnt == 0 then return nil end - local str = ret_buf:tostring() - return style == 'keep' and str or str:sub(1, -2) - end) -end - ---- Read a line from the stream. --- @param style 'keep' to keep the line terminator, 'discard' to remove it (default 'discard') --- @return the line read, or nil if at end of file --- @return error during read, if any -function Stream:read_line(style) - return self:read_line_op(style):perform() -end - -function Stream:flush_input() - if self.random_access and self.rx then - local buffered = self.rx:read_avail() - if buffered ~= 0 then - assert(self.io:seek('cur', -buffered)) - self.rx:reset() - end - end -end - -function Stream:flush_output_op() - return core_write_op(self, nil, 0, true) -end - ---- Flush the output buffer, writing all buffered data to the underlying IO. -function Stream:flush_output() - return self:flush_output_op():perform() -end - -Stream.flush = Stream.flush_output - ---- Close the stream, optionally flushing remaining data. --- @return true on success, followed by any additional return values from the underlying IO close operation -function Stream:close() - if self.tx then self:flush_output() end - self.rx, self.tx = nil, nil - local success, exit_type, code = self.io:close() - self.io = nil - return success, exit_type, code -end - ---- Create an iterator over lines in the stream. --- The iterator returns each line, stripped of its end-of-line marker. --- @return function iterator over lines -function Stream:lines(...) - local formats = { ... } - if #formats == 0 then - return function() return self:read_line('discard') end -- Fast path. - end - return function() return self:read(unpack(formats)) end -end - ---- Lua 5.1 inspired file:read_op() method. --- The function supports various formats to control the reading behavior. --- @param ... format (optional) specifies the reading format: --- '*a': reads the whole file from the current position to the end. --- '*l': reads the next line not including the end of the line. --- '*L': reads the next line including the end of the line. --- number: reads a string up to the number of characters specified. --- If no format is specified, it defaults to reading the next line without the end-of-line marker. --- @return operation to perform the read based on the specified format -function Stream:read_op(...) - assert(self.rx, "expected a readable stream") - local args = { ... } - if #args == 0 then return self:read_line_op('discard') end -- Default format. - if #args > 1 then error('multiple formats unimplemented') end - local format = args[1] - if format == '*n' then - error('read numbers unimplemented') - elseif format == '*a' then - return self:read_all_chars_op() - elseif format == '*l' then - return self:read_line_op('discard') - elseif format == '*L' then - return self:read_line_op('keep') - else - assert(type(format) == 'number' and format > 0, 'bad format') - local number = format - return self:read_chars_op(number) - end -end - ---- Lua 5.1's file:read() method. --- This method simplifies reading data by automatically performing the operation initiated by `read_op`. --- @param ... format (optional) specifies the reading format. Refer to `read_op` for details. --- @return the data read from the stream according to the specified format, or nil on end of file --- @return error during read, if any -function Stream:read(...) - return self:read_op(...):perform() -end - ---- Get or set the file position. --- @param whence base for the offset: 'set', 'cur', or 'end' --- @param offset offset from the base, in bytes --- @return new position, or nil and an error message if the operation fails -function Stream:seek(whence, offset) - if not self.random_access then return nil, 'stream is not seekable' end - if whence == nil then whence = sc.SEEK_CUR end - if offset == nil then offset = 0 end - if whence == sc.SEEK_CUR and offset == 0 then - -- Just a position query. - local ret, err = self.io:seek(sc.SEEK_CUR, 0) - if ret == nil then return ret, err end - if self.tx and self.tx:read_avail() ~= 0 then - return ret + self.tx:read_avail() - end - if self.rx and self.rx:read_avail() ~= 0 then - return ret - self.rx:read_avail() - end - return ret - end - if self.rx then self:flush_input() end; if self.tx then self:flush_output() end - return self.io:seek(whence, offset) -end - ---- Set the buffering mode for the stream. --- Adjusts the buffering strategy for the stream's input and output. --- @param mode The buffering mode: 'no', 'line', or 'full'. --- @param size The size of the buffer, in bytes. Defaults to DEFAULT_BUFFER_SIZE if not specified. --- @return The stream object. -function Stream:setvbuf(mode, size) - if mode == 'no' then - self.line_buffering, size = false, 1 - elseif mode == 'line' then - self.line_buffering = true - elseif mode == 'full' then - self.line_buffering = false - else - error('bad mode', mode) - end - - size = size or DEFAULT_BUFFER_SIZE - - local function transfer_buffered_bytes(old, new) - while old:read_avail() > 0 do - local buf, count = old:peek() - new:write(buf, count) - old:advance_read(count) - end - end - if self.rx and self.rx.size ~= size then - if self.rx:read_avail() > size then - error('existing buffered input exceeds new buffer size') - end - local new_rx = fixed_buffer.new(size) - transfer_buffered_bytes(self.rx, new_rx) - self.rx = new_rx - end - - if self.tx and self.tx.size ~= size then - while self.tx:read_avail() >= size do self:flush() end - local new_tx = fixed_buffer.new(size) - transfer_buffered_bytes(self.tx, new_tx) - self.tx = new_tx - end - - return self -end - ---- Lua 5.1 inspired file:write_op() method. --- Returns an op that will write the value of each of its arguments to the --- file. The arguments must be strings or numbers. To write other values, --- use tostring or string.format before write. --- @param ... arguments to write (must be strings or numbers) --- @return operation -function Stream:write_op(...) - local n = select('#', ...) - for i = 1, n do - local arg = select(i, ...) - if type(arg)~="number" and type(arg)~="string" then - return nil, 'arguments must be strings or numbers' - end - end - return self:write_chars_op(table.concat({...})) -end - ---- Lua 5.1 file:write() method. --- Write the value of each of its arguments to the file. The arguments must be --- strings or numbers. To write other values, use tostring or string.format --- before write. --- @param ... data to write (strings or numbers) --- @return true on success, or nil plus an error message on failure -function Stream:write(...) - return self:write_op(...):perform() -end - --- The result may be nil. -function Stream:filename() return self.io.filename end - -return { - open = open, - is_stream = is_stream -} diff --git a/fibers/stream/compat.lua b/fibers/stream/compat.lua deleted file mode 100644 index f42b588..0000000 --- a/fibers/stream/compat.lua +++ /dev/null @@ -1,90 +0,0 @@ --- Use of this source code is governed by the Apache 2.0 license; see COPYING. - --- Shim to replace Lua's built-in IO module with streams. - -local stream = require 'fibers.stream' -local file = require 'fibers.stream.file' -local sc = require 'fibers.utils.syscall' - -local unpack = table.unpack or unpack -- luacheck: ignore -- Compatibility fallback - -local original_io = _G.io -- Save the original io module -local io = {} - -function io.close(f) - if f == nil then f = io.current_output end - f:close() -end - -function io.flush() - io.current_output:flush() -end - -function io.input(new) - if new == nil then return io.current_input end - if type(new) == string then new = io.open(new, 'r') end - io.current_input = new -end - -function io.lines(filename, ...) - if filename == nil then return io.current_input:lines() end - local fileStream = assert(io.open(filename, 'r')) - local iter = fileStream:lines(...) - return function() - local line = { iter() } - if line[1] == nil then - fileStream:close() - return nil - end - return unpack(line) - end -end - -io.open = file.open - -function io.output(new) - if new == nil then return io.current_output end - if type(new) == string then new = io.open(new, 'w') end - io.current_output = new -end - -function io.popen(prog, mode) - return file.popen(prog, mode) -end - -function io.read(...) - return io.current_input:read(...) -end - -io.tmpfile = file.tmpfile - -function io.type(x) - if not stream.is_stream(x) then return nil end - if not x.io then return 'closed file' end - return 'file' -end - -function io.write(...) - return io.current_output:write(...) -end - -local function install() - if _G.io == io then return end - _G.io = io - io.stdin = file.fdopen(sc.STDIN_FILENO, sc.O_RDONLY) - io.stdout = file.fdopen(sc.STDOUT_FILENO, sc.O_WRONLY) - io.stderr = file.fdopen(sc.STDERR_FILENO, sc.O_WRONLY) - if sc.isatty(io.stdout.io.fd) then io.stdout:setvbuf('line') end - io.stderr:setvbuf('no') - io.input(io.stdin) - io.output(io.stdout) -end - -local function uninstall() - _G.io = original_io -end - -return { - install = install, - uninstall = uninstall -} diff --git a/fibers/stream/file.lua b/fibers/stream/file.lua deleted file mode 100644 index 2e79b91..0000000 --- a/fibers/stream/file.lua +++ /dev/null @@ -1,249 +0,0 @@ --- Use of this source code is governed by the Apache 2.0 license; see COPYING. - ---- fibers.stream.file module --- A stream IO implementation for file descriptors. --- @module fibers.stream.file - -package.path = "../../?.lua;../?.lua;" .. package.path - -local stream = require 'fibers.stream' -local pollio = require 'fibers.pollio' -local sc = require 'fibers.utils.syscall' - -local pio_handler = pollio.install_poll_io_handler() - -local bit = rawget(_G, "bit") or require 'bit32' - --- A blocking handler provides for configurable handling of EWOULDBLOCK --- conditions. The goal is to allow for normal blocking operations, but --- also to allow for a cooperative coroutine-based multitasking system --- to run other tasks when a stream would block. --- --- In the case of normal, blocking file descriptors, the blocking --- handler will only be called if a read or a write returns EAGAIN, --- presumably because the sc was interrupted by a signal. In that --- case the correct behavior is to just return directly, which will --- cause the stream to try again. --- --- For nonblocking file descriptors, the blocking handler could suspend --- the current coroutine and arrange to restart it once the FD becomes --- readable or writable. However the default handler here doesn't --- assume that we're running in a coroutine. In that case we could --- block in a poll() without suspending. Currently however the default --- blocking handler just returns directly, which will cause the stream --- to busy-wait until the FD becomes active. - -local File = {} -File.__index = File - -sc.signal(sc.SIGPIPE, sc.SIG_IGN) - -local function new_file_io(fd, filename) - pollio.init_nonblocking(fd) - return setmetatable({ fd = fd, filename = filename }, File) -end - -function File:nonblock() sc.set_nonblock(self.fd) end - -function File:block() sc.set_block(self.fd) end - -function File:read(buf, count) - local did_read, err, errno = sc.ffi.read(self.fd, buf, count) - if errno then - -- If the read would block, indicate to caller with nil return. - if errno == sc.EAGAIN or errno == sc.EWOULDBLOCK then return nil, nil end - -- Otherwise, signal an error. - return nil, err - else - -- Success; return number of bytes read. If EOF, count is 0. - return did_read, nil - end -end - -function File:write(buf, count) - -- local success, did_write, err, errno = pcall(sc.ffi.write, self.fd, buf, count) - local did_write, err, errno = sc.ffi.write(self.fd, buf, count) - if err then - -- If the write would block, indicate to caller with nil return. - if errno == sc.EAGAIN or errno == sc.EWOULDBLOCK then return nil, nil end - -- Otherwise, signal an error. - return nil, err - elseif did_write == 0 then - -- This is a bit of a squirrely case: no bytes written, but no - -- error code. Return nil to indicate that it's probably a good - -- idea to wait until the FD is writable again. - return nil, nil - else - -- Success; return number of bytes written. - return did_write, nil - end -end - -function File:seek(whence, offset) - -- In case of success, return the final file position, measured in - -- bytes from the beginning of the file. On failure, return nil, - -- plus a string describing the error. - return sc.lseek(self.fd, offset, whence) -end - -function File:wait_for_readable_op() return pollio.fd_readable_op(self.fd) end - -function File:wait_for_readable() self:wait_for_readable_op():perform() end - -function File:wait_for_writable_op() return pollio.fd_writable_op(self.fd) end - -function File:wait_for_writable() self:wait_for_writable_op():perform() end - -function File:task_on_readable(t) if self.fd then pio_handler:task_on_readable(self.fd, t) end end - -function File:task_on_writable(t) if self.fd then pio_handler:task_on_writable(self.fd, t) end end - -function File:close() - sc.close(self.fd) - self.fd = nil -end - -local function fdopen(fd, flags, filename) - local io = new_file_io(fd, filename) - if flags == nil then - flags = assert(sc.fcntl(fd, sc.F_GETFL)) - -- this appears only to be relevant to 32 bit systems, ljsc has - -- reference to this being a flag with value octal('0100000') on such systems - else - flags = bit.bor(flags, sc.O_LARGEFILE) - end - local readable, writable = false, false - local mode = bit.band(flags, sc.O_ACCMODE) - if mode == sc.O_RDONLY or mode == sc.O_RDWR then readable = true end - if mode == sc.O_WRONLY or mode == sc.O_RDWR then writable = true end - local stat = sc.fstat(fd) - return stream.open(io, readable, writable, stat and stat.st_blksize) -end - -local modes = { - r = sc.O_RDONLY, - w = bit.bor(sc.O_WRONLY, sc.O_CREAT, sc.O_TRUNC), - a = bit.bor(sc.O_WRONLY, sc.O_CREAT, sc.O_APPEND), - ['r+'] = sc.O_RDWR, - ['w+'] = bit.bor(sc.O_RDWR, sc.O_CREAT, sc.O_TRUNC), - ['a+'] = bit.bor(sc.O_RDWR, sc.O_CREAT, sc.O_APPEND) -} -do - local binary_modes = {} - for k, v in pairs(modes) do binary_modes[k .. 'b'] = v end - for k, v in pairs(binary_modes) do modes[k] = v end -end - -local permissions = {} -permissions['rw-r--r--'] = bit.bor(sc.S_IRUSR, sc.S_IWUSR, sc.S_IRGRP, sc.S_IROTH) -permissions['rw-rw-rw-'] = bit.bor(permissions['rw-r--r--'], sc.S_IWGRP, sc.S_IWOTH) - -local function open(filename, mode, perms) - if mode == nil then mode = 'r' end - local flags = modes[mode] - if flags == nil then return nil, 'invalid mode: ' .. tostring(mode) end - -- This set of permissions is what open() uses. Note that these - -- permissions will be modulated by the umask. - if perms == nil then perms = permissions['rw-rw-rw-'] end - local fd, err, _ = sc.open(filename, flags, permissions[perms]) - if fd == nil then return nil, err end - return fdopen(fd, flags, filename) -end - -local function mktemp(name, perms) - if perms == nil then perms = permissions['rw-r--r--'] end - -- In practice this requires that someone seeds math.random with good - -- entropy. In Snabb that is the case (see core.main:initialize()). - local t = math.random(1e7) - local tmpnam, fd, err, _ - for i = t, t + 10 do - tmpnam = name .. '.' .. i - fd, err, _ = sc.open(tmpnam, bit.bor(sc.O_CREAT, sc.O_RDWR, sc.O_EXCL), perms) - if fd then return fd, tmpnam end - end - error("Failed to create temporary file " .. tmpnam .. ": " .. err) -end - -local function tmpfile(perms, tmpdir) - if tmpdir == nil then tmpdir = os.getenv("TMPDIR") or "/tmp" end - local fd, tmpnam = mktemp(tmpdir .. '/' .. 'tmp', perms) - local f = fdopen(fd, sc.O_RDWR, tmpnam) - -- FIXME: Doesn't arrange to ensure the file is removed in all cases; - -- calling close is required. - function f:rename(new) - self:flush() - sc.fsync(self.io.fd) - local res, err = sc.rename(self.io.filename, new) - if not res then - error("failed to rename " .. self.io.filename .. " to " .. new .. ": " .. tostring(err)) - end - self.io.filename = new - self.io.close = File.close -- Disable remove-on-close. - end - - function f.io:close() - File.close(self) - local res, err = sc.unlink(self.filename) - if not res then - error('failed to remove ' .. self.filename .. ': ' .. tostring(err)) - end - end - - return f -end - -local function pipe() - local rd, wr = assert(sc.pipe()) - return fdopen(rd, sc.O_RDONLY), fdopen(wr, sc.O_WRONLY) -end - -local function popen(prog, mode) - assert(type(prog) == 'string') - assert(mode == 'r' or mode == 'w') - local parent_half, child_half - do - local rd, wr = assert(sc.pipe()) - if mode == 'r' then - parent_half, child_half = rd, wr - else - parent_half, child_half = wr, rd - end - end - local pid = assert(sc.fork()) - if pid == 0 then - sc.close(parent_half) - sc.dup2(child_half, mode == 'r' and 1 or 0) - sc.close(child_half) - sc.execve('/bin/sh', { "-c", prog }) - sc.write(2, "io.popen: Failed to exec /bin/sh!") - sc.exit(255) - end - sc.close(child_half) - local io = new_file_io(parent_half) - local close = io.close - function io:close() - if not pid then return end - close(self) - local ch_pid, status, code - repeat - ch_pid, status, code = sc.waitpid(pid, sc.WNOHANG) - -- some kind of sleep here, surely, if used in a fibers context - until (ch_pid and status ~= 'running') or (not ch_pid and code ~= sc.EINTR) - pid = nil - local retval1 = (status == "exited" and code == 0) or nil - local retval2 = status == "exited" and "exit" or "signal" - local retval3 = code - return retval1, retval2, retval3 - end - - return stream.open(io, mode == 'r', mode == 'w') -end - -return { - init_nonblocking = pollio.init_nonblocking, - fdopen = fdopen, - open = open, - tmpfile = tmpfile, - pipe = pipe, - popen = popen, -} diff --git a/fibers/stream/mem.lua b/fibers/stream/mem.lua deleted file mode 100644 index 4d74e1a..0000000 --- a/fibers/stream/mem.lua +++ /dev/null @@ -1,123 +0,0 @@ --- Use of this source code is governed by the Apache 2.0 license; see COPYING. - --- A memory-backed stream IO implementation. - -local stream = require 'fibers.stream' -local sc = require 'fibers.utils.syscall' - -local ffi = sc.is_LuaJIT and require 'ffi' or require 'cffi' - -local Mem = {} -Mem.__index = Mem - -local INITIAL_SIZE = 4096 - -local function new_buffer(len) return ffi.new('uint8_t[?]', len) end - -local function new_mem_io(buf, len, size, growable) - if buf == nil then - if size == nil then size = len or INITIAL_SIZE end - buf = new_buffer(size) - else - if size == nil then size = len end - assert(size ~= nil) - end - if len == nil then len = 0 end - return setmetatable( - { buf = buf, pos = 0, len = len, size = size, growable = growable }, - Mem) -end - -function Mem:nonblock() end - -function Mem:block() end - -function Mem:read(buf, count) - count = math.min(count, self.len - self.pos) - ffi.copy(buf, self.buf + self.pos, count) - self.pos = self.pos + count - return count -end - -function Mem:grow_buffer(count) - assert(self.growable, "ran out of space while writing") - if self.len == self.size then - self.size = math.max(self.size * 2, 1024) - local buf = new_buffer(self.size) - ffi.copy(buf, self.buf, self.len) - self.buf = buf - end - self.len = math.min(self.size, self.len + count) - return self.len -end - -function Mem:write(buf, count) - if self.pos == self.len then self:grow_buffer(count) end - count = math.min(count, self.len - self.pos) - ffi.copy(self.buf + self.pos, buf, count) - self.pos = self.pos + count - return count -end - -function Mem:seek(whence, offset) - if whence == sc.SEEK_CUR then - offset = self.pos + offset - elseif whence == sc.SEEK_END then - offset = self.len + offset - elseif whence ~= sc.SEEK_SET then - error('bad "whence": ' .. tostring(whence)) - end - if offset < 0 then return nil, "invalid offset" end - while self.len < offset do self:grow_buffer(offset - self.len) end - self.pos = offset - return offset -end - -function Mem:wait_for_readable() end - -function Mem:wait_for_writable() end - -function Mem:close() - self.buf, self.pos, self.len, self.size, self.growable = nil, nil, nil, nil, nil -end - -local readable_modes = { r = true, ['r+'] = true, ['w+'] = true } -local writable_modes = { ['r+'] = true, w = true, ['w+'] = true } - -local function open(buf, len, mode) - if mode == nil then mode = 'r+' end - local readable, writable = readable_modes[mode], writable_modes[mode] - assert(readable or writable) - local io = new_mem_io(buf, len, len, writable) - return stream.open(io, readable, writable) -end - -local function tmpfile() - return open() -end - -local function open_input_string(str) - local len = #str - local buf = new_buffer(len) - ffi.copy(buf, str, len) - local readable, writable = true, false - local io = new_mem_io(buf, len, len, writable) - return stream.open(io, readable, writable) -end - -local function call_with_output_string(f, ...) - local out = tmpfile() - local args = { ... } - table.insert(args, out) - f(unpack(args)) - out:flush_output() - -- Can take advantage of internals to read directly. - return ffi.string(out.io.buf, out.io.len) -end - -return { - open = open, - tmpfile = tmpfile, - open_input_string = open_input_string, - call_with_output_string = call_with_output_string -} diff --git a/fibers/stream/socket.lua b/fibers/stream/socket.lua deleted file mode 100644 index 45f69d9..0000000 --- a/fibers/stream/socket.lua +++ /dev/null @@ -1,90 +0,0 @@ --- Use of this source code is governed by the Apache 2.0 license; see COPYING. - --- A stream IO implementation for sockets. - -local file = require 'fibers.stream.file' -local sc = require 'fibers.utils.syscall' - -local Socket = {} -Socket.__index = Socket - --- local sigpipe_handler - -local function socket(domain, stype, protocol) - -- if sigpipe_handler == nil then sigpipe_handler = sc.signal(sc.SIGPIPE, sc.SIG_IGN) end - local fd = assert(sc.socket(domain, stype, protocol or 0)) - file.init_nonblocking(fd) - return setmetatable({ fd = fd }, Socket) -end - -function Socket:listen_unix(f) - local sa = sc.getsockname(self.fd) - sa.path = f - assert(sc.bind(self.fd, sa)) - assert(sc.listen(self.fd)) -end - -function Socket:accept() - while true do - local fd, err, errno = sc.accept(self.fd) - if fd then - return file.fdopen(fd) - elseif errno == sc.EAGAIN or errno == sc.EWOULDBLOCK then - file.wait_for_readable(self.fd) - else - error(err) - end - end -end - -function Socket:connect(sa) - local ok, err, errno = sc.connect(self.fd, sa) - if not ok and errno == sc.EINPROGRESS then - -- Bonkers semantics; see connect(2). - file.wait_for_writable(self.fd) - err = assert(sc.getsockopt(self.fd, sc.SOL_SOCKET, sc.SO_ERROR)) - if err == 0 then ok = true end - end - if ok then - local fd = self.fd - self.fd = nil - return file.fdopen(fd) - end - error(err) -end - -function Socket:connect_unix(f) - local sa = sc.getsockname(self.fd) - sa.path = f - return self:connect(sa) -end - -local function listen_unix(f, args) - args = args or {} - local s = socket(sc.AF_UNIX, args.stype or sc.SOCK_STREAM, args.protocol) - s:listen_unix(f) - if args.ephemeral then - local parent_close = s.close - function s:close() - parent_close(s) - sc.unlink(f) - end - end - return s -end - -local function connect_unix(f, stype, protocol) - local s = socket(sc.AF_UNIX, stype or sc.SOCK_STREAM, protocol) - return s:connect_unix(f) -end - -function Socket:close() - if self.fd then sc.close(self.fd) end - self.fd = nil -end - -return { - socket = socket, - listen_unix = listen_unix, - connect_unix = connect_unix -} diff --git a/fibers/timer.lua b/fibers/timer.lua deleted file mode 100644 index 04d7a99..0000000 --- a/fibers/timer.lua +++ /dev/null @@ -1,135 +0,0 @@ --- (c) Jangala - --- Use of this source code is governed by the XXXXXXXXX license; see COPYING. - ---- Binary Heap based timer. --- Implements a Binary Heap based timer. This is a time based event scheduler, --- used for efficiently scheduling and managing events. --- @module fibers.timer - --- Required packages -local sc = require 'fibers.utils.syscall' - ---- BinaryHeap class. --- @type BinaryHeap -local BinaryHeap = {} -BinaryHeap.__index = BinaryHeap - ---- BinaryHeap constructor. --- @treturn BinaryHeap BinaryHeap instance. -function BinaryHeap:new() - return setmetatable({heap = {}, size = 0}, BinaryHeap) -end - ---- Pushes a node into the heap and heapify it. --- @tparam table node The node to be pushed into the heap. -function BinaryHeap:push(node) - self.size = self.size + 1 - self.heap[self.size] = node - self:heapify_up(self.size) -end - ---- Pops a node from the underlying heap and reheapifies. Does not advance the timer! --- @treturn table|nil The root node popped from the heap, nil if the heap is empty. -function BinaryHeap:pop() - if self.size == 0 then - return nil - end - - local root = self.heap[1] - self.heap[1] = self.heap[self.size] - self.size = self.size - 1 - self:heapify_down(1) - return root -end - ---- Maintains the heap property by moving a node up the heap. --- @tparam number idx The index of the node in the heap array. -function BinaryHeap:heapify_up(idx) - if idx <= 1 then - return - end - - local parent = math.floor(idx / 2) - if self.heap[parent].time > self.heap[idx].time then - self.heap[parent], self.heap[idx] = self.heap[idx], self.heap[parent] - self:heapify_up(parent) - end -end - ---- Maintains the heap property by moving a node down the heap. --- @tparam number idx The index of the node in the heap array. -function BinaryHeap:heapify_down(idx) - local smallest = idx - local left = 2 * idx - local right = 2 * idx + 1 - - if left <= self.size and self.heap[left].time < self.heap[smallest].time then - smallest = left - end - if right <= self.size and self.heap[right].time < self.heap[smallest].time then - smallest = right - end - if smallest ~= idx then - self.heap[idx], self.heap[smallest] = self.heap[smallest], self.heap[idx] - self:heapify_down(smallest) - end -end - ---- Timer class. --- @type Timer -local Timer = {} -Timer.__index = Timer - ---- Timer constructor. --- @tparam[opt=now] number now The current time. --- @treturn Timer New Timer instance. -local function new(now) - now = now or sc.monotime() - return setmetatable({now = now, heap = BinaryHeap:new()}, Timer) -end - ---- Adds an object to the timer with an absolute time. --- @tparam number t The absolute time. --- @tparam any obj The object to add to the timer. -function Timer:add_absolute(t, obj) - self.heap:push({time = t, obj = obj}) -end - ---- Adds an object to the timer with a delta time. --- @tparam number dt The delta time. --- @tparam any obj The object to add to the timer. -function Timer:add_delta(dt, obj) - return self:add_absolute(self.now + dt, obj) -end - ---- Returns the time of the next entry in the timer. --- @treturn number The time of the next entry in the timer, or infinity if the heap is empty. -function Timer:next_entry_time() - if self.heap.size == 0 then - return 1/0 -- infinity - end - return self.heap.heap[1].time -end - ---- Returns the time of the next entry in the timer. --- @treturn number The time of the next entry in the timer, or infinity if the heap is empty. -function Timer:pop() - return self.heap:pop() -end - ---- Advances the timer, popping and scheduling objects from the heap as necessary. --- @tparam number t The time to advance the timer to. --- @tparam table sched The scheduler to use for scheduling objects. -function Timer:advance(t, sched) - while self.heap.size > 0 and t >= self.heap.heap[1].time do - local node = self.heap:pop() - self.now = node.time - sched:schedule(node.obj) - end - self.now = t -end - -return { - new = new -} \ No newline at end of file diff --git a/fibers/utils/fixed_buffer.lua b/fibers/utils/fixed_buffer.lua deleted file mode 100644 index 211a2d4..0000000 --- a/fibers/utils/fixed_buffer.lua +++ /dev/null @@ -1,81 +0,0 @@ -local is_LuaJIT = rawget(_G, "jit") and true or false -local ffi = is_LuaJIT and require 'ffi' or require 'cffi' - -local ljbuf = require("string.buffer") - -local ring_buffer = {} - -function ring_buffer:reset() - self.buf:reset() -end - -function ring_buffer:read_avail() - return #self.buf -end - -function ring_buffer:write_avail() - return self.size - #self.buf -end - -function ring_buffer:is_empty() - return #self.buf == 0 -end - -function ring_buffer:is_full() - return #self.buf >= self.size -end - -function ring_buffer:advance_read(count) - assert(count >= 0 and count <= #self.buf, "advance_read out of range") - self.buf:skip(count) -end - -function ring_buffer:write(bytes, count) - assert(count >= 0, "invalid write count") - assert(count <= self:write_avail(), "write xrun") - self.buf:putcdata(bytes, count) -end - -function ring_buffer:read(bytes, count) - assert(count >= 0 and count <= #self.buf, "read xrun") - local data = self.buf:get(count) - ffi.copy(bytes, data, #data) -end - -function ring_buffer:peek() - local ptr, len = self.buf:ref() - return ptr, math.min(len, self.size) -end - -function ring_buffer:tostring() - return self.buf:tostring() -end - -function ring_buffer:find(pattern) - assert(type(pattern) == "string" and #pattern > 0, "pattern must be non-empty string") - local str = self.buf:tostring() - local i = string.find(str, pattern) - return i and (i - 1) or nil -end - -function ring_buffer:reserve(size) - return self.buf:reserve(size) -end - -function ring_buffer:commit(n) - return self.buf:commit(n) -end - -local function new(size) - assert(type(size) == "number" and size > 0, "new() requires positive size") - local self = { - buf = ljbuf.new(), - size = size - } - - return setmetatable(self, { __index = ring_buffer }) -end - -return { - new = new -} diff --git a/fibers/utils/helper.lua b/fibers/utils/helper.lua deleted file mode 100644 index 4549ac5..0000000 --- a/fibers/utils/helper.lua +++ /dev/null @@ -1,46 +0,0 @@ --- Copyright Snabb --- Copyright Jangala - -local sc = require 'fibers.utils.syscall' -local ffi = sc.is_LuaJIT and require 'ffi' or require 'cffi' -ffi.type = ffi.type or type - --- Returns true if x and y are structurally similar (isomorphic). -local function equal(x, y) - if type(x) ~= type(y) then return false end - if type(x) == 'table' then - for k, v in pairs(x) do - if not equal(v, y[k]) then return false end - end - for k, _ in pairs(y) do - if x[k] == nil then return false end - end - return true - elseif ffi.type(x) == 'cdata' then - if x == y then return true end - if ffi.typeof(x) ~= ffi.typeof(y) then return false end - local size = ffi.sizeof(x) - if ffi.sizeof(y) ~= size then return false end - return sc.ffi.memcmp(x, y, size) == 0 - else - return x == y - end -end - -local function dump(o) - if type(o) == 'table' then - local s = '{ ' - for k, v in pairs(o) do - if type(k) ~= 'number' then k = '"' .. k .. '"' end - s = s .. '[' .. k .. '] = ' .. dump(v) .. ',' - end - return s .. '} ' - else - return tostring(o) - end -end - -return { - equal = equal, - dump = dump -} diff --git a/fibers/utils/syscall.lua b/fibers/utils/syscall.lua deleted file mode 100644 index 2d9fc30..0000000 --- a/fibers/utils/syscall.lua +++ /dev/null @@ -1,577 +0,0 @@ ----@diagnostic disable: inject-field --- Copyright Jangala - -local p_fcntl = require 'posix.fcntl' -local p_unistd = require 'posix.unistd' -local p_stdio = require 'posix.stdio' -local p_wait = require 'posix.sys.wait' -local p_stat = require 'posix.sys.stat' -local p_signal = require 'posix.signal' -local p_socket = require 'posix.sys.socket' -local p_errno = require 'posix.errno' -local p_time = require 'posix.time' -local bit = rawget(_G, "bit") or require 'bit32' - -local M = { ffi = {} } -- used this module format due to large number of exported functions - ---detect LuaJIT -M.is_LuaJIT = rawget(_G, "jit") and true or false - -local ffi = M.is_LuaJIT and require 'ffi' or require 'cffi' -ffi.tonumber = ffi.tonumber or tonumber -ffi.type = ffi.type or type - -local ARCH = ffi.arch - -------------------------------------------------------------------------------- --- Compatibility functions -table.pack = table.pack or function(...) -- luacheck: ignore -- Compatibility fallback - return { n = select("#", ...), ... } -end - - -------------------------------------------------------------------------------- --- Local functions (for efficiency) - -local band, bor, bnot, _ = bit.band, bit.bor, bit.bnot, bit.lshift - - -------------------------------------------------------------------------------- --- Syscall constants - -M.SEEK_CUR = p_unistd.SEEK_CUR -M.SEEK_END = p_unistd.SEEK_END -M.SEEK_SET = p_unistd.SEEK_SET - -M.O_ACCMODE = 3 -M.O_RDONLY = p_fcntl.O_RDONLY -M.O_WRONLY = p_fcntl.O_WRONLY -M.O_RDWR = p_fcntl.O_RDWR -M.O_CREAT = p_fcntl.O_CREAT -M.O_TRUNC = p_fcntl.O_TRUNC -M.O_APPEND = p_fcntl.O_APPEND -M.O_EXCL = p_fcntl.O_EXCL -M.O_NONBLOCK = p_fcntl.O_NONBLOCK -M.O_LARGEFILE = ffi.abi('32bit') and 32768 or 0 - -M.F_GETFL = p_fcntl.F_GETFL -M.F_SETFL = p_fcntl.F_SETFL -M.F_GETFD = p_fcntl.F_GETFD -M.F_SETFD = p_fcntl.F_SETFD -M.FD_CLOEXEC = p_fcntl.FD_CLOEXEC - -M.EAGAIN = p_errno.EAGAIN -M.EWOULDBLOCK = p_errno.EWOULDBLOCK -M.EINTR = p_errno.EINTR -M.EINPROGRESS = p_errno.EINPROGRESS -M.ESRCH = p_errno.ESRCH - -M.SIGKILL = p_signal.SIGKILL -M.SIGTERM = p_signal.SIGTERM - -M.S_IRUSR = p_stat.S_IRUSR -M.S_IWUSR = p_stat.S_IWUSR -M.S_IXUSR = p_stat.S_IXUSR -M.S_IRGRP = p_stat.S_IRGRP -M.S_IWGRP = p_stat.S_IWGRP -M.S_IXGRP = p_stat.S_IXGRP -M.S_IROTH = p_stat.S_IROTH -M.S_IWOTH = p_stat.S_IWOTH -M.S_IXOTH = p_stat.S_IXOTH - -M.STDIN_FILENO = p_unistd.STDIN_FILENO -M.STDOUT_FILENO = p_unistd.STDOUT_FILENO -M.STDERR_FILENO = p_unistd.STDERR_FILENO - -M.SIGPIPE = p_signal.SIGPIPE -M.SIG_IGN = p_signal.SIG_IGN -M.SIGCHLD = p_signal.SIGCHLD - -M.CLOCK_REALTIME = p_time.CLOCK_REALTIME -M.CLOCK_MONOTONIC = p_time.CLOCK_MONOTONIC - -M.AF_INET = p_socket.AF_INET -M.AF_INET6 = p_socket.AF_INET6 -M.AF_NETLINK = p_socket.AF_NETLINK -M.AF_PACKET = p_socket.AF_PACKET -M.AF_UNIX = p_socket.AF_UNIX -M.AF_UNSPEC = p_socket.AF_UNSPEC -M.SO_ERROR = p_socket.SO_ERROR -M.SOCK_DGRAM = p_socket.SOCK_DGRAM -M.SOCK_RAW = p_socket.SOCK_RAW -M.SOCK_STREAM = p_socket.SOCK_STREAM -M.SOL_SOCKET = p_socket.SOL_SOCKET -M.SOMAXCONN = p_socket.SOMAXCONN - -M.WNOHANG = p_wait.WNOHANG - -M.PR_SET_PDEATHSIG = 1 -------------------------------------------------------------------------------- --- Luafied stdlib syscalls - -function M.fcntl(fd, ...) return p_fcntl.fcntl(fd, ...) end - -function M.open(path, mode, perm) return p_fcntl.open(path, mode, perm) end - -function M.strerror(err) return p_errno.errno(err) end - -function M.stat(path) return p_stat.stat(path) end - -function M.fstat(file, ...) return p_stat.fstat(file, ...) end - -function M.signal(signum, handler) return p_signal.signal(signum, handler) end - -function M.kill(pid, options) return p_signal.kill(pid, options) end - -function M.killpg(pgid, sig) return p_signal.kill(pgid, sig) end - -function M.accept(fd) return p_socket.accept(fd) end - -function M.bind(file, sockaddr) return p_socket.bind(file, sockaddr) end - -function M.connect(fd, addr) return p_socket.connect(fd, addr) end - -function M.getpeername(sockfd) return p_socket.getpeername(sockfd) end - -function M.getsockname(sockfd) return p_socket.getsockname(sockfd) end - -function M.getsockopt(fd, level, name) return p_socket.getsockopt(fd, level, name) end - -function M.listen(fd, backlog) return p_socket.listen(fd, backlog or M.SOMAXCONN) end - -function M.socket(family, socktype, protocol) return p_socket.socket(family, socktype, protocol) end - -function M.fileno(file) return p_stdio.fileno(file) end - -function M.rename(from, to) return p_stdio.rename(from, to) end - -function M.clock_gettime(id) return p_time.clock_gettime(id) end - -function M.access(path, mode) return p_unistd.access(path, mode) end - -function M.close(fd) return p_unistd.close(fd) end - -function M.dup2(fd1, fd2) return p_unistd.dup2(fd1, fd2) end - -function M.exec(path, argt) return p_unistd.exec(path, argt) end - -function M.execp(path, argt) return p_unistd.execp(path, argt) end - -function M.execve(path, argv, _) return p_unistd.exec(path, argv) end - -function M.fork() return p_unistd.fork() end - -function M.fsync(fd) return p_unistd.fsync(fd) end - -function M.getpgrp() return p_unistd.getpgrp() end - -function M.getpid() return p_unistd.getpid() end - -function M.isatty(fd) return p_unistd.isatty(fd) end - -function M.lseek(file, offset, whence) return p_unistd.lseek(file, offset, whence) end - -function M.pipe() return p_unistd.pipe() end - -function M.read(fd, count) return p_unistd.read(fd, count) end - -function M.setpid(what, id, gid) return p_unistd.setpid(what, id, gid) end - -function M.unlink(path) return p_unistd.unlink(path) end - -function M.write(fd, buf) return p_unistd.write(fd, buf) end - -function M.waitpid(pid, options) return p_wait.wait(pid, options) end - -function M.exit(status) return os.exit(status) end - -------------------------------------------------------------------------------- --- Convenience functions - -function M.set_nonblock(fd) - local flags = assert(M.fcntl(fd, M.F_GETFL)) - assert(M.fcntl(fd, M.F_SETFL, bor(flags, M.O_NONBLOCK))) -end - -function M.set_block(fd) - local flags = assert(M.fcntl(fd, M.F_GETFL)) - assert(M.fcntl(fd, M.F_SETFL, band(flags, bnot(M.O_NONBLOCK)))) -end - -function M.set_cloexec(fd) - local flags = assert(M.fcntl(fd, M.F_GETFD)) - assert(M.fcntl(fd, M.F_SETFD, bor(flags, M.FD_CLOEXEC))) -end - -function M.monotime() - local time = M.clock_gettime(M.CLOCK_MONOTONIC) - return time.tv_sec + time.tv_nsec / 1e9, time.tv_sec, time.tv_nsec -end - -function M.realtime() - local time = M.clock_gettime(M.CLOCK_REALTIME) - return time.tv_sec + time.tv_nsec / 1e9, time.tv_sec, time.tv_nsec -end - -function M.floatsleep(t) - local sec = t - t % 1 - local nsec = t % 1 * 1e9 - local _, _, _, remaining = p_time.nanosleep({ tv_sec = sec, tv_nsec = nsec }) - while remaining do - p_time.nanosleep(remaining) - end -end - -local function wrap_error(retval) - if retval >= 0 then - return retval - else - local errno = ffi.errno() - return nil, M.strerror(errno), errno - end -end - ------------------------------------- --- epoll - -if ARCH == "x64" or ARCH == "x86" then - ffi.cdef [[ - typedef struct epoll_event { - uint8_t raw[12]; // 4 bytes for events + 8 bytes for data - } epoll_event; - ]] -elseif ARCH == "mips" or ARCH == "mipsel" or ARCH == "arm64" then - ffi.cdef [[ - typedef struct epoll_event { - uint32_t events; - uint64_t data; - } epoll_event; - ]] -else - error(ARCH .. " architecture not specified") -end - -ffi.cdef [[ - int epoll_create(int size); - int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); - int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); - - int fcntl(int fd, int cmd, ...); - int close(int fd); - char *strerror(int errnum); - - int prctl(int option, unsigned long arg2, unsigned long arg3, unsigned long arg4, unsigned long arg5); -]] - -M.EPOLLIN = 0x00000001 -M.EPOLLPRI = 0x00000002 -M.EPOLLOUT = 0x00000004 -M.EPOLLERR = 0x00000008 -M.EPOLLHUP = 0x00000010 -M.EPOLLNVAL = 0x00000020 -M.EPOLLRDNORM = 0x00000040 -M.EPOLLRDBAND = 0x00000080 -M.EPOLLWRNORM = 0x00000100 -M.EPOLLWRBAND = 0x00000200 -M.EPOLLMSG = 0x00000400 -M.EPOLLRDHUP = 0x00002000 - -M.EPOLLEXCLUSIVE = bit.lshift(1, 28) -M.EPOLLWAKEUP = bit.lshift(1, 29) -M.EPOLLONESHOT = bit.lshift(1, 30) -M.EPOLLET = bit.lshift(1, 31) - -local EPOLL_CTL_ADD = 1 -local EPOLL_CTL_DEL = 2 -local EPOLL_CTL_MOD = 3 - - --- Adjust helper functions based on the architecture: -local get_event -local set_event -local get_data -local set_data - -if ARCH == 'x64' or ARCH == 'x86' then - get_event = function(ev) - return ffi.cast("uint32_t*", ev.raw)[0] - end - set_event = function(ev, value) - ffi.cast("uint32_t*", ev.raw)[0] = value - end - get_data = function(ev) - return ffi.cast("uint64_t*", ev.raw + 4)[0] - end - set_data = function(ev, value) - ffi.cast("uint64_t*", ev.raw + 4)[0] = value - end -elseif ARCH == 'mips' or ARCH == 'arm64' or ARCH == 'mipsel' then - get_event = function(ev) - return ev.events - end - set_event = function(ev, value) - ev.events = value - end - get_data = function(ev) - return ev.data - end - set_data = function(ev, value) - ev.data = value - end -else - error(ARCH .. " architecture not specified") -end - --- Returns an epoll file descriptor. -function M.epoll_create() - return wrap_error(ffi.C.epoll_create(1)) -end - --- Register eventmask of a file descriptor onto epoll file descriptor. -function M.epoll_register(epfd, fd, eventmask) - local event = ffi.new("struct epoll_event") - set_event(event, eventmask) - set_data(event, fd) - return wrap_error(ffi.C.epoll_ctl(epfd, EPOLL_CTL_ADD, fd, event)) -end - --- Modify eventmask of a file descriptor. -function M.epoll_modify(epfd, fd, eventmask) - local event = ffi.new("struct epoll_event") - set_event(event, eventmask) - set_data(event, fd) - return wrap_error(ffi.C.epoll_ctl(epfd, EPOLL_CTL_MOD, fd, event)) -end - --- Remove a registered file descriptor from the epoll file descriptor. -function M.epoll_unregister(epfd, fd) - return wrap_error(ffi.C.epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nil)) -end - --- Wait for events. -function M.epoll_wait(epfd, timeout, max_events) - local events = ffi.new("struct epoll_event[?]", max_events) - local num_events = ffi.C.epoll_wait(epfd, events, max_events, timeout) - if num_events == -1 then - return nil, ffi.string(ffi.C.strerror(ffi.errno())) - end - - -- Create a table to hold the resulting events - local res = {} - - -- Loop over the events, inserting them into the table with their fd as the key - for i = 0, num_events - 1 do - local fd = assert(ffi.tonumber(get_data(events[i]))) - local event = assert(ffi.tonumber(get_event(events[i]))) - res[fd] = event - end - - return res, num_events -end - --- Close epoll file descriptor. -function M.epoll_close(epfd) - return wrap_error(ffi.C.close(epfd)) -end - -function M.prctl(option, arg2, arg3, arg4, arg5) - arg2 = arg2 or 0 - arg3 = arg3 or 0 - arg4 = arg4 or 0 - arg5 = arg5 or 0 - - return wrap_error(ffi.C.prctl(option, arg2, arg3, arg4, arg5)) -end - -------------------------------------------------------------------------------- --- FFI C structure functions (for efficiency) - -M.ffi.typeof = ffi.typeof -M.ffi.sizeof = ffi.sizeof - -ffi.cdef [[ - ssize_t write(int fildes, const void *buf, size_t nbytes); - ssize_t read(int fildes, void *buf, size_t nbytes); - int memcmp(const void *s1, const void *s2, size_t n); -]] - -function M.ffi.write(fildes, buf, nbytes) - return wrap_error(ffi.tonumber(ffi.C.write(fildes, buf, nbytes))) -end - -function M.ffi.read(fildes, buf, nbytes) - return wrap_error(ffi.tonumber(ffi.C.read(fildes, buf, nbytes))) -end - -function M.ffi.memcmp(obj1, obj2, nbytes) - return ffi.tonumber(ffi.C.memcmp(obj1, obj2, nbytes)) -end - --- Explicitly load the pthread library - -local pthread_names = { - "pthread", - "libpthread.so.0" -} - -local libpthread = nil - -for _, v in ipairs(pthread_names) do - local success - success, libpthread = pcall(ffi.load, v) - if success then break end -end - -if not libpthread then error("libpthread not found") end - -ffi.cdef [[ -typedef struct { - uint32_t ssi_signo; /* Signal number */ - int32_t ssi_errno; /* Error number (unused) */ - int32_t ssi_code; /* Signal code */ - uint32_t ssi_pid; /* PID of sender */ - uint32_t ssi_uid; /* Real UID of sender */ - int32_t ssi_fd; /* File descriptor (SIGIO) */ - uint32_t ssi_tid; /* Kernel timer ID (POSIX timers) */ - uint32_t ssi_band; /* Band event (SIGIO) */ - uint32_t ssi_overrun; /* POSIX timer overrun count */ - uint32_t ssi_trapno; /* Trap number that caused signal */ - int32_t ssi_status; /* Exit status or signal (SIGCHLD) */ - int32_t ssi_int; /* Integer sent by sigqueue(3) */ - uint64_t ssi_ptr; /* Pointer sent by sigqueue(3) */ - uint64_t ssi_utime; /* User CPU time consumed (SIGCHLD) */ - uint64_t ssi_stime; /* System CPU time consumed (SIGCHLD) */ - uint64_t ssi_addr; /* Address that generated signal (for hardware-generated signals) */ - uint16_t ssi_addr_lsb; /* Least significant bit of address (SIGBUS; since Linux 2.6.37) */ - uint16_t __pad2; - int32_t ssi_syscall; - uint64_t ssi_call_addr; - uint32_t ssi_arch; - uint8_t pad[28]; /* Pad size to 128 bytes */ -} signalfd_siginfo; - -typedef struct { - unsigned long int __val[1024 / (8 * sizeof (unsigned long int))]; -} __sigset_t; - -typedef __sigset_t sigset_t; - -int pthread_sigmask(int how, const sigset_t *set, sigset_t *oldset); -int sigemptyset(sigset_t *set); -int sigaddset(sigset_t *set, int signum); -int signalfd(int fd, const sigset_t *mask, int flags); -]] - -if ARCH == "mips" or ARCH == "mipsel" then - M.SIG_BLOCK = 1 - M.SIG_UNBLOCK = 2 - M.SIG_SETMASK = 3 -elseif ARCH == "x64" or ARCH == "arm64" or ARCH == "x86" then - M.SIG_BLOCK = 0 - M.SIG_UNBLOCK = 1 - M.SIG_SETMASK = 2 -end - -function M.sigemptyset(set) return wrap_error(ffi.C.sigemptyset(set)) end - -function M.sigaddset(set, signum) return wrap_error(ffi.C.sigaddset(set, signum)) end - -function M.signalfd(fd, mask, flags) return wrap_error(ffi.C.signalfd(fd, mask, flags)) end - -function M.pthread_sigmask(how, set, oldset) return wrap_error(libpthread.pthread_sigmask(how, set, oldset)) end - -function M.new_sigset() return ffi.new("sigset_t") end - -function M.new_fdsi() return ffi.new("signalfd_siginfo"), ffi.sizeof("signalfd_siginfo") end - --- Define syscall and pid_t -ffi.cdef [[ -long syscall(long number, ...); -typedef int pid_t; -typedef unsigned int uint; -]] - -local SYS_pidfd_open = 434 -- Good for (almost) all our platforms -if ARCH == "mips" or ARCH == "mipsel" then - SYS_pidfd_open = 4000 + 434 -- See https://www.linux-mips.org/wiki/Syscall -end - --- Function to open a pidfd -function M.pidfd_open(pid, flags) - pid = ffi.new("pid_t", pid) -- Explicitly cast pid to pid_t - flags = ffi.new("uint", flags) -- Explicitly cast flgas to uint - return wrap_error(ffi.tonumber(ffi.C.syscall(SYS_pidfd_open, pid, flags))) -end - --- Termios constants and baudrate support -M.TCSANOW = 0 -- Make changes now without waiting for data to complete -M.TCSADRAIN = 1 -- Wait until all output written to fildes is transmitted -M.TCSAFLUSH = 2 -- Flush input/output buffers and make the change - --- Baudrate constants -M.BAUDRATES = { - [1200] = 9, - [2400] = 11, - [4800] = 12, - [9600] = 13, - [19200] = 14, - [38400] = 15, - [57600] = 4097, - [115200] = 4098, - [230400] = 4099, - [460800] = 4100, - [500000] = 4101, - [576000] = 4102, - [921600] = 4103, - [1000000] = 4104, - [1152000] = 4105, - [1500000] = 4106, - [2000000] = 4107, - [2500000] = 4108, - [3000000] = 4109, - [3500000] = 4110, - [4000000] = 4111 -} - --- Define termios structs/functions for direct baudrate control -ffi.cdef [[ -typedef unsigned int speed_t; -typedef unsigned char cc_t; -typedef unsigned int tcflag_t; - -struct termios { - tcflag_t c_iflag; - tcflag_t c_oflag; - tcflag_t c_cflag; - tcflag_t c_lflag; - cc_t c_line; - cc_t c_cc[32]; - speed_t c_ispeed; - speed_t c_ospeed; -}; - -int tcgetattr(int fd, struct termios *termios_p); -int tcsetattr(int fd, int optional_actions, const struct termios *termios_p); -speed_t cfgetospeed(const struct termios *termios_p); -speed_t cfgetispeed(const struct termios *termios_p); -int cfsetospeed(struct termios *termios_p, speed_t speed); -int cfsetispeed(struct termios *termios_p, speed_t speed); -]] - -function M.new_termios() return ffi.new("struct termios") end - -function M.tcgetattr(fd, termios_p) return wrap_error(ffi.C.tcgetattr(fd, termios_p)) end - -function M.tcsetattr(fd, optional_actions, termios_p) - return wrap_error(ffi.C.tcsetattr(fd, optional_actions, termios_p)) -end - -function M.cfgetospeed(termios_p) return ffi.tonumber(ffi.C.cfgetospeed(termios_p)) end - -function M.cfgetispeed(termios_p) return ffi.tonumber(ffi.C.cfgetispeed(termios_p)) end - -function M.cfsetospeed(termios_p, speed) return wrap_error(ffi.C.cfsetospeed(termios_p, speed)) end - -function M.cfsetispeed(termios_p, speed) return wrap_error(ffi.C.cfsetispeed(termios_p, speed)) end - -return M diff --git a/fibers/waitgroup.lua b/fibers/waitgroup.lua deleted file mode 100644 index bec5bb2..0000000 --- a/fibers/waitgroup.lua +++ /dev/null @@ -1,45 +0,0 @@ --- waitgroup.lua -local op = require 'fibers.op' -local cond = require 'fibers.cond' - -local Waitgroup = {} -Waitgroup.__index = Waitgroup - -local function new() - local wg = setmetatable({ _counter = 0, _cond = cond.new() }, Waitgroup) - return wg -end - -function Waitgroup:add(count) - self._counter = self._counter + count - if self._counter < 0 then - error("waitgroup counter goes negative") - elseif self._counter == 0 then - self._cond:signal() - end -end - -function Waitgroup:done() - self:add(-1) -end - -function Waitgroup:wait_op() - local function try() - return self._counter == 0 - end - local function block(suspension, wrap_fn) - if self._counter > 0 then - -- Add suspension to the condition variable's wait queue. - self._cond.waitq[#self._cond.waitq + 1] = { suspension = suspension, wrap = wrap_fn } - end - end - return op.new_base_op(nil, try, block) -end - -function Waitgroup:wait() - self:wait_op():perform() -end - -return { - new = new -} diff --git a/src/coxpcall.lua b/src/coxpcall.lua new file mode 100644 index 0000000..2b09945 --- /dev/null +++ b/src/coxpcall.lua @@ -0,0 +1,199 @@ +-- 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 = {} + +------------------------------------------------------------------------------- +-- Checks if (x)pcall function is coroutine safe +------------------------------------------------------------------------------- +local function isCoroutineSafe(func) + local co = coroutine.create(function () + return func(coroutine.yield, function () end) + end) + + coroutine.resume(co) + return coroutine.resume(co) +end + +-- Fast path: environment already has coroutine-safe pcall/xpcall +if isCoroutineSafe(pcall) and isCoroutineSafe(xpcall) then + M.pcall = pcall + M.xpcall = xpcall + M.running = coroutine.running + return M +end + +------------------------------------------------------------------------------- +-- Implements xpcall with coroutines +------------------------------------------------------------------------------- + +local performResume, handleReturnValue +local oldpcall, oldxpcall = pcall, xpcall +local unpack = rawget(table, 'unpack') or _G.unpack +local pack = rawget(table, 'pack') or function (...) + return { n = select('#', ...), ... } +end +local running = coroutine.running +local coromap = setmetatable({}, { __mode = 'k' }) + +local 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 + 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 + 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 + return performResume(err, co, coroutine.yield(...)) + else + return true, ... + end +end + +function performResume(err, co, ...) + return handleReturnValue(err, co, coroutine.resume(co, ...)) +end + +local function coxpcall(f, err, ...) + local current = running() + if not current then + -- Not in a coroutine: fall back to normal pcall/xpcall + if err == id then + return oldpcall(f, ...) + else + if select('#', ...) > 0 then + local oldf, params = f, pack(...) + f = function () return oldf(unpack(params, 1, params.n)) end + end + return oldxpcall(f, err) + end + else + local res, co = oldpcall(coroutine.create, f) + if not res then + local newf = function (...) return f(...) end + co = coroutine.create(newf) + end + coromap[co] = current + return performResume(err, co, ...) + end +end + +local function corunning(coro) + if coro ~= nil then + assert(type(coro) == 'thread', + 'Bad argument; expected thread, got: ' .. type(coro)) + else + coro = running() + end + while coromap[coro] do + coro = coromap[coro] + end + if coro == 'mainthread' then return nil end + return coro +end + +------------------------------------------------------------------------------- +-- Implements pcall with coroutines +------------------------------------------------------------------------------- + +local function copcall(f, ...) + return coxpcall(f, id, ...) +end + +M.pcall = copcall +M.xpcall = coxpcall +M.running = corunning + +return M diff --git a/src/fibers.lua b/src/fibers.lua new file mode 100644 index 0000000..5172d7b --- /dev/null +++ b/src/fibers.lua @@ -0,0 +1,137 @@ +-- fibers.lua +---@module 'fibers' + +local Op = require 'fibers.op' +local Runtime = require 'fibers.runtime' +local Scope = require 'fibers.scope' +local Performer = require 'fibers.performer' + +local unpack = rawget(table, 'unpack') or _G.unpack +local pack = rawget(table, 'pack') or function (...) + return { n = select('#', ...), ... } +end + +local function raise_string(err) + if type(err) == 'string' or type(err) == 'number' then + error(err, 0) + end + error(tostring(err), 0) +end + +---------------------------------------------------------------------- +-- Core entry point +---------------------------------------------------------------------- + +--- Run a main function under the scheduler's root scope. +--- +--- main_fn is called as main_fn(scope, ...). +--- +--- On success: +--- returns ...results... from main_fn directly. +--- +--- On failure or cancellation: +--- raises a string/number (never a table). +--- +---@param main_fn fun(s: any, ...): any +---@param ... any +---@return any ... +local function run(main_fn, ...) + if Runtime.current_fiber() then error('fibers.run must not be called from inside a fiber', 2) end + if type(main_fn) ~= 'function' then error('fibers.run expects a function', 2) end + + local root = Scope.root() + local args = pack(...) + + local box = { + status = nil, -- 'ok'|'cancelled'|'failed' + primary = nil, -- primary/reason (for non-ok) + results = nil, -- packed results (for ok) + -- report = nil, -- optional: ScopeReport + } + + root:spawn(function () + -- 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 + -- 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.primary = r[3] + end + + Runtime.stop() + end) + + Runtime.main() + + 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 + + raise_string(box.primary or box.status or 'fibers.run: missing status') +end + +---------------------------------------------------------------------- +-- Spawn +---------------------------------------------------------------------- + +--- Spawn a fiber 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, ...) + if type(fn) ~= 'function' then error('fibers.spawn expects a function', 2) end + + local s = Scope.current() + local args = { ... } + + local function shim(_, ...) + return fn(...) + end + + return s:spawn(shim, unpack(args)) +end + +return { + spawn = spawn, + run = run, + + perform = Performer.perform, + + now = Runtime.now, + + choice = Op.choice, + guard = Op.guard, + with_nack = Op.with_nack, + always = Op.always, + never = Op.never, + bracket = Op.bracket, + + race = Op.race, + first_ready = Op.first_ready, + named_choice = Op.named_choice, + boolean_choice = Op.boolean_choice, + + -- Scope utilities re-exported + run_scope = Scope.run, -- 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/alarm.lua b/src/fibers/alarm.lua new file mode 100644 index 0000000..b956927 --- /dev/null +++ b/src/fibers/alarm.lua @@ -0,0 +1,254 @@ +-- fibers/alarm.lua +-- +-- Wall-clock based alarms integrated with the fibers runtime. +-- +-- Each alarm is driven by a recurrence function: +-- next_time(last_fired :: epoch|nil, now :: epoch) -> next_epoch|nil +-- +-- Semantics: +-- * When next_time returns nil, the alarm becomes exhausted. +-- * When the alarm fires, callers receive: +-- true, alarm, last_fired_epoch, ... +-- * Once the alarm is exhausted, callers receive exactly once: +-- false, "no_more_recurrences", alarm, last_fired_epoch|nil +-- after which further wait_op() calls never fire. +-- * Multiple alarms scheduled for the same wall time will fire +-- in successive synchronisations (via CML choice and per-alarm state). +-- +-- Time initialisation: +-- * alarm.new() and alarm:wait_op() are safe before real time is known. +-- * No wait_op() will complete until set_time_source(...) has been called. +-- * A wait_op() started before set_time_source will first wait for time +-- to become ready, then for the next recurrence. +-- +-- Clock / civil time changes: +-- * alarm.time_changed() notifies all waiting alarms that the mapping +-- from monotonic time to civil time (UTC/zone) has changed. +-- * A wait_op() that is currently sleeping for the next recurrence +-- will be pre-empted, recompute its next firing time, and sleep again. +-- * This uses best-effort choice semantics: if a timer and a clock +-- change become ready together, either may win the choice. + +local op = require 'fibers.op' +local sleep_mod = require 'fibers.sleep' +local perform = require 'fibers.performer'.perform +local cond_mod = require 'fibers.cond' +local time = require 'fibers.utils.time' + +---@class Alarm +---@field _next_time fun(last: number|nil, now: number): number|nil +---@field _policy any +---@field _label string +---@field _last number|nil +---@field _next_wall number|nil +---@field _state "active"|"exhausted_pending"|"exhausted_done" +local Alarm = {} +Alarm.__index = Alarm + +---------------------------------------------------------------------- +-- Wall-clock source, readiness, and clock-change signalling +---------------------------------------------------------------------- + +-- Wall-clock "now" function (epoch seconds); replaced once real time is known. +local wall_now = time.realtime +local time_ready = false + +-- One-shot condition for “time is ready”. +local time_ready_cond = cond_mod.new() + +-- Multi-shot condition for “clock / civil time has changed”. +-- This is recreated on every change so that each wait sees at most +-- one wake-up per change generation. +local clock_change_cond = cond_mod.new() + +--- Install the wall-clock time source. +--- May be called once, when real time is known (RTC, NTP, GNSS, etc.). +---@param now_fn fun(): number +local function set_time_source(now_fn) + assert(type(now_fn) == 'function', 'set_time_source expects a function') + assert(not time_ready, 'set_time_source may only be called once') + + wall_now = now_fn + time_ready = true + + -- Wake any fibers that were waiting for time to become ready. + time_ready_cond:signal() + + -- Treat "time became known" as a clock change for any alarms that + -- might start waiting after this point. + clock_change_cond:signal() + clock_change_cond = cond_mod.new() +end + +--- Notify alarms that the civil time mapping has changed. +--- Call this when: +--- * the system's wall clock is adjusted (e.g. after NTP sync), or +--- * the time zone used by recurrence functions has changed. +local function time_changed() + if not time_ready then + -- Before time_ready, no alarm has gone past the readiness gate, + -- so there is nothing meaningful to reschedule. + return + end + + clock_change_cond:signal() + clock_change_cond = cond_mod.new() +end + +---------------------------------------------------------------------- +-- Alarm object API +-- +-- Internal state: +-- _state : "active" | "exhausted_pending" | "exhausted_done" +-- _last : last fired wall-clock epoch (or nil) +-- _next_wall : next scheduled wall-clock epoch (or nil) +---------------------------------------------------------------------- + +function Alarm:is_active() + return self._state == 'active' +end + +--- Cancel the alarm permanently. +--- No further firings or exhaustion notification will be delivered. +function Alarm:cancel() + self._state = 'exhausted_done' + self._next_wall = nil +end + +-- Internal: ensure _next_wall is populated or update state on exhaustion. +---@param now number +---@return number|nil +function Alarm:_ensure_next(now) + if self._next_wall or self._state ~= 'active' then + return self._next_wall + end + + local t = self._next_time(self._last, now) + if not t then + -- No further recurrences: schedule exhaustion notification. + self._state = 'exhausted_pending' + return nil + end + + self._next_wall = t + return t +end + +--- Main CML-style operation: wait for the alarm to fire once. +-- +-- Returns an Op which, when performed, yields either: +-- +-- * On successful firing: +-- true, alarm, last_fired_epoch, ... +-- +-- * Once, when the recurrence sequence is exhausted: +-- false, "no_more_recurrences", alarm, last_fired_epoch|nil +-- +-- After the exhaustion notification has been delivered, further +-- wait_op() calls return an Op that never fires. +-- +-- Before set_time_source is called, a wait_op() will first block +-- until time becomes ready, and then behave exactly as if wait_op() +-- had been called afterwards. +-- +-- If alarm.time_changed() is called while this wait_op() is sleeping +-- for its next firing time, the sleep will be pre-empted and the +-- next firing time will be recomputed from the updated civil time. +function Alarm:wait_op() + return op.guard(function () + -- Fully inert: no more results of any kind. + if self._state == 'exhausted_done' then + return op.never() + end + + -- Time not yet initialised: wait once for readiness, then recurse. + if not time_ready then + local ev = time_ready_cond:wait_op() + return ev:wrap(function () + -- At this point, time_ready is true; perform a fresh wait. + return perform(self:wait_op()) + end) + end + + -- Normal path: real time is available. + local now = wall_now() + self:_ensure_next(now) + + -- If the recurrence has just been exhausted, deliver the + -- one-off exhaustion notification and then become inert. + if self._state == 'exhausted_pending' then + self._state = 'exhausted_done' + return op.always(false, 'no_more_recurrences', self, self._last) + end + + -- We have a valid next_wall at this point. + local next_wall = assert(self._next_wall, 'alarm internal error: missing next_wall') + local dt = next_wall - now + if dt < 0 then dt = 0 end + + -- Build a race between: + -- * sleeping until the scheduled time; and + -- * a clock/civil-time change. + -- + -- sleep_op(dt) yields no user-level values on success. + local sleep_ev = sleep_mod.sleep_op(dt) + local change_ev = clock_change_cond:wait_op() + + local choice_ev = op.boolean_choice(sleep_ev, change_ev) + + return choice_ev:wrap(function (is_sleep) + if is_sleep then + -- Timer completed: commit this firing. + self._last = next_wall + self._next_wall = nil + return true, self, self._last + else + -- Clock or time zone changed before the timer fired: + -- clear the stale schedule and recompute under new civil time. + self._next_wall = nil + return perform(self:wait_op()) + end + end) + end) +end + +-- Convenience alias: treat the alarm itself as an Event factory. +Alarm.event = Alarm.wait_op + +---------------------------------------------------------------------- +-- Constructors +---------------------------------------------------------------------- + +---@class AlarmNewParams +---@field next_time fun(last: number|nil, now: number): number|nil +---@field policy any? +---@field label string? + +--- Create a new alarm. +--- +---@param params AlarmNewParams +---@return Alarm +local function new(params) + assert(type(params) == 'table', 'alarm.new expects a parameter table') + local next_time = params.next_time + assert(type(next_time) == 'function', 'alarm.new: next_time function required') + + local self = setmetatable({ + _next_time = next_time, + _policy = params.policy, + _label = params.label or '', + + _last = nil, -- last fired wall-clock epoch + _next_wall = nil, -- next scheduled wall-clock epoch + _state = 'active', -- lifecycle state + }, Alarm) + + return self +end + +return { + Alarm = Alarm, + new = new, + set_time_source = set_time_source, + time_changed = time_changed, +} diff --git a/src/fibers/channel.lua b/src/fibers/channel.lua new file mode 100644 index 0000000..2568f53 --- /dev/null +++ b/src/fibers/channel.lua @@ -0,0 +1,193 @@ +-- fibers.channel +-- Concurrent ML style channels for communication between fibers. +---@module 'fibers.channel' + +local op = require 'fibers.op' +local fifo = require 'fibers.utils.fifo' +local dlist = require 'fibers.utils.dlist' +local perform = require 'fibers.performer'.perform + +--- Bidirectional communication channel between fibers. +---@class Channel +---@field buffer table|nil # optional FIFO buffer (nil for unbuffered) +---@field buffer_size integer +---@field getq table # cancellable wait-list of waiting receivers +---@field putq table # cancellable wait-list of waiting senders +local Channel = {} +Channel.__index = Channel + +--- Create a new channel. +---@param buffer_size? integer # buffered capacity (0 or nil for unbuffered) +---@return Channel +local function new(buffer_size) + buffer_size = buffer_size or 0 + + local buffer = nil + if buffer_size > 0 then + buffer = fifo.new() + end + + return setmetatable({ + buffer = buffer, + buffer_size = buffer_size, + getq = dlist.new(), -- waiting receivers + putq = dlist.new(), -- waiting senders + }, Channel) +end + +---------------------------------------------------------------------- +-- Helpers: cancellable wait-list entries +---------------------------------------------------------------------- + +--- Pop the next entry whose suspension is still waiting, if any. +---@param q any +---@return table|nil +local function pop_active(q) + while not q:empty() do + local entry = q:pop_head() + if not entry.suspension or entry.suspension:waiting() then + return entry + end + end + return nil +end + +---@param entry table +local function cleanup_get_entry(entry) + entry.suspension = nil + entry.wrap = nil +end + +---@param entry table +local function cleanup_put_entry(entry) + entry.val = nil + entry.suspension = nil + entry.wrap = nil +end + +--- Op that sends val on the channel. +--- For unbuffered channels, this synchronises with a receiver; for buffered +--- channels, it may complete when space is available in the buffer. +---@param val any +---@return Op +function Channel:put_op(val) + local getq, putq = self.getq, self.putq + local buffer, buffer_size = self.buffer, self.buffer_size + + local function try() + -- Case 1: rendezvous with a waiting receiver. + local recv = pop_active(getq) + if recv then + recv.suspension:complete(recv.wrap, val) + cleanup_get_entry(recv) + return true + end + -- Case 2: buffered channel with available space. + if buffer and buffer:length() < buffer_size then + buffer:push(val) + return true + end + -- Case 3: no receiver and no buffer space. + return false + end + + --- Enqueue as a waiting sender when the put cannot complete immediately. + ---@param suspension Suspension + ---@param wrap_fn WrapFn + local function block(suspension, wrap_fn) + ---@class ChannelPutEntry + ---@field val any + ---@field suspension Suspension|nil + ---@field wrap WrapFn|nil + local entry = { + val = val, + suspension = suspension, + wrap = wrap_fn, + } + local node = putq:push_tail(entry) + suspension:add_cleanup(function () + if node:remove() then + cleanup_put_entry(entry) + end + end) + end + + return op.new_primitive(nil, try, block) +end + +--- Op that receives a value from the channel. +--- May take from the buffer or rendezvous directly with a sender. +---@return Op +function Channel:get_op() + local getq, putq = self.getq, self.putq + local buffer = self.buffer + + local function pop_sender() + local sender = pop_active(putq) + if not sender then + return nil + end + -- Having chosen this sender, complete its suspension immediately. + sender.suspension:complete(sender.wrap) + return sender + end + + local function try() + local remote = pop_sender() + -- Case 1: take from buffer if there is a buffered value. + if buffer and buffer:length() > 0 then + local v = buffer:pop() + -- If there was a sender waiting, refill the buffer with its value. + if remote then + buffer:push(remote.val) + cleanup_put_entry(remote) + end + return true, v + end + -- Case 2: no buffered value; take directly from a sender. + if remote then + local v = remote.val + cleanup_put_entry(remote) + return true, v + end + -- Case 3: nothing available. + return false + end + + --- Enqueue as a waiting receiver when no value is immediately available. + ---@param suspension Suspension + ---@param wrap_fn WrapFn + local function block(suspension, wrap_fn) + ---@class ChannelGetEntry + ---@field suspension Suspension|nil + ---@field wrap WrapFn|nil + local entry = { + suspension = suspension, + wrap = wrap_fn, + } + local node = getq:push_tail(entry) + suspension:add_cleanup(function () + if node:remove() then + cleanup_get_entry(entry) + end + end) + end + + return op.new_primitive(nil, try, block) +end + +--- Synchronously send message on the channel. +---@param message any +function Channel:put(message) + return perform(self:put_op(message)) +end + +--- Synchronously receive a message from the channel. +---@return any +function Channel:get() + return perform(self:get_op()) +end + +return { + new = new, +} diff --git a/src/fibers/cond.lua b/src/fibers/cond.lua new file mode 100644 index 0000000..c6b259d --- /dev/null +++ b/src/fibers/cond.lua @@ -0,0 +1,63 @@ +-- fibers/cond.lua +--- +-- Generic condition operations built on top of oneshot and op. +-- A condition can be waited on via an Op or by blocking in the current fiber. +---@module 'fibers.cond' + +local op = require 'fibers.op' +local oneshot = require 'fibers.oneshot' +local perform = require 'fibers.performer'.perform + +--- Condition variable backed by a one-shot. +---@class Cond +---@field _os Oneshot +local Cond = {} +Cond.__index = Cond + +--- Build an Op that becomes ready when the condition fires. +---@return Op +function Cond:wait_op() + local os = self._os + + return op.new_primitive( + nil, + function () + return os:is_triggered() + end, + --- Arrange to complete this suspension when the condition fires. + ---@param resumer Suspension + ---@param wrap_fn WrapFn + function (resumer, wrap_fn) + local cancel = os:add_waiter(function () + if resumer:waiting() then + resumer:complete(wrap_fn) + end + end) + -- ensure the waiter closure does not remain referenced if the wait is cancelled + resumer:add_cleanup(cancel) + end + ) +end + +--- Block the current fiber until the condition fires. +---@return any ... +function Cond:wait() + return perform(self:wait_op()) +end + +--- Signal the condition (idempotent). +function Cond:signal() + return self._os:signal() +end + +--- Create a new condition. +---@return Cond +local function new() + return setmetatable({ + _os = oneshot.new(), -- no extra callback + }, Cond) +end + +return { + new = new, +} diff --git a/src/fibers/io/exec.lua b/src/fibers/io/exec.lua new file mode 100644 index 0000000..230a101 --- /dev/null +++ b/src/fibers/io/exec.lua @@ -0,0 +1,722 @@ +-- fibers/io/exec.lua - Structured process execution bound to scopes +---@module 'fibers.io.exec' + +local Runtime = require 'fibers.runtime' +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 programme to exec; argv[2..n] are its arguments. +---@class ExecSpec +---@field [integer] string # argv elements (1..n) +---@field cwd string|nil +---@field env table|nil +---@field flags table|nil +---@field stdin ExecStdin|nil +---@field stdout ExecStdout|nil +---@field stderr ExecStderr|nil +---@field shutdown_grace number|nil + +--- Normalised stream configuration passed through to the backend. +---@class ExecStreamConfig +---@field mode "inherit"|"null"|"pipe"|"stdout"|"stream" +---@field stream Stream|nil +---@field owned boolean # whether the Command owns and will close the stream + +---@alias CommandStatus "pending"|"running"|"exited"|"signalled"|"failed" + +--- Process handle returned by the backend. +---@class ProcHandle +---@field backend ExecBackend +---@field stdin Stream|nil +---@field stdout Stream|nil +---@field stderr Stream|nil + +--- Structured process command bound to a scope. +---@class Command +---@field _scope Scope +---@field _argv string[] +---@field _cwd string|nil +---@field _env table|nil +---@field _flags table +---@field _stdin ExecStreamConfig +---@field _stdout ExecStreamConfig +---@field _stderr ExecStreamConfig +---@field _shutdown_grace number +---@field _started boolean +---@field _done boolean +---@field _proc ProcHandle|nil +---@field _pid integer|nil +---@field _status CommandStatus +---@field _code integer|nil +---@field _signal integer|nil +---@field _err string|nil +---@field _finalizer_detach fun():boolean|nil +---@field _cleaned boolean +local Command = {} +Command.__index = Command + +---------------------------------------------------------------------- +-- Helpers +---------------------------------------------------------------------- + +--- Normalise a user-facing stdio configuration into an ExecStreamConfig. +---@param value ExecStdin|ExecStdout|ExecStderr|Stream|nil +---@param is_stderr boolean +---@return ExecStreamConfig +local function norm_stream(value, is_stderr) + if value == nil then + return { mode = 'inherit', stream = nil, owned = true } + end + + local t = type(value) + if t == 'string' then + if value == 'inherit' or value == 'null' or value == 'pipe' then + return { mode = value, stream = nil, owned = true } + end + if is_stderr and value == 'stdout' then + return { mode = 'stdout', stream = nil, owned = true } + end + error('invalid stdio mode: ' .. tostring(value)) + end + + if stream_mod.is_stream(value) then + -- A user-supplied stream is not owned by the Command. + return { mode = 'stream', stream = value, owned = false } + end + + error('invalid stdio configuration: ' .. tostring(value)) +end + +---@param self Command +local function assert_not_started(self) + if self._started then + error('command already started') + end +end + +--- 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 = 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 + +---------------------------------------------------------------------- +-- Internal: process lifecycle bookkeeping +---------------------------------------------------------------------- + +--- Record a final exit status for this command, if not already done. +---@param code integer|nil +---@param signal integer|nil +---@param err string|nil +function Command:_record_exit(code, signal, err) + if self._done then return end + self._done = true + + if err then + self._status, self._err = 'failed', err + elseif signal ~= nil then + self._status, self._signal = 'signalled', signal + elseif code ~= nil then + self._status, self._code = 'exited', code + else + self._status, self._err = 'failed', 'unknown process status' + end + + self._code, self._signal = code, signal +end + +--- Detach the scope finaliser once command-owned resources have been retired. +--- +--- Long-lived service scopes may create many short-lived Command objects. +--- The finaliser closure captures the Command, so leaving it installed until +--- scope shutdown retains every completed command. Terminal commands therefore +--- detach their finaliser once their status has been recorded. +function Command:_detach_finalizer() + local detach = self._finalizer_detach + if detach then + self._finalizer_detach = nil + pcall(detach) + end +end + +--- Release command-owned resources after the backend has reached a terminal +--- state. This path is deliberately immediate and non-yielding so it is safe +--- from Op wrap callbacks and from scope finalisers. +--- +--- The terminal status/code/signal/error fields are left intact. run_op() and +--- shutdown_op() check _done before touching the backend, so repeated waits +--- remain idempotent after cleanup. +function Command:_cleanup_terminal() + if self._cleaned then return end + if not self._done then return end + + self:_detach_finalizer() + + for _, name in ipairs { 'stdin', 'stdout', 'stderr' } do + local cfg = self['_' .. name] + if cfg and cfg.stream and cfg.owned then + pcall(function () cfg.stream:terminate('exec_terminal') end) + cfg.stream = nil + end + end + + if self._proc and self._proc.backend then + pcall(function () self._proc.backend:close() end) + self._proc.backend = nil + end + self._proc = nil + self._cleaned = true +end + +--- Ensure the process has been started and a ProcHandle exists. +---@return boolean ok +---@return ProcHandle|nil proc +---@return string|nil err +function Command:_ensure_started() + if self._started then + if self._proc then + return true, self._proc, nil + end + if self._status == 'failed' then + return false, nil, self._err + end + return false, nil, 'exec: command started without backend' + end + self._started = true + + -- High-level process spec passed to the backend. + local spec = { + argv = self._argv, + cwd = self._cwd, + env = self._env, + flags = self._flags, + stdin = self._stdin, + stdout = self._stdout, + stderr = self._stderr, + } + + -- Backend returns a ProcHandle: + -- { backend = ExecBackend, stdin = Stream|nil, stdout = Stream|nil, stderr = Stream|nil } + local proc_handle, start_err = proc_mod.start(spec) + if not proc_handle then + self._status = 'failed' + self._done = true + self._err = start_err + self:_cleanup_terminal() + return false, nil, start_err + end + + self._proc = proc_handle + self._pid = proc_handle.backend and proc_handle.backend.pid or nil + self._status = 'running' + + -- If the backend created pipe streams for us, record them and mark them as owned. + if proc_handle.stdin then + self._stdin.stream = proc_handle.stdin + self._stdin.owned = true + end + if proc_handle.stdout then + self._stdout.stream = proc_handle.stdout + self._stdout.owned = true + end + if proc_handle.stderr then + self._stderr.stream = proc_handle.stderr + self._stderr.owned = true + end + + return true, proc_handle, nil +end + +---------------------------------------------------------------------- +-- Configuration setters +---------------------------------------------------------------------- + +function Command:set_stdin(v) + assert_not_started(self) + self._stdin = norm_stream(v, false) + return self +end + +function Command:set_stdout(v) + assert_not_started(self) + self._stdout = norm_stream(v, false) + return self +end + +function Command:set_stderr(v) + assert_not_started(self) + self._stderr = norm_stream(v, true) + return self +end + +function Command:set_cwd(v) + assert_not_started(self) + self._cwd = v + return self +end + +function Command:set_env(v) + assert_not_started(self) + self._env = v + return self +end + +function Command:set_flags(v) + assert_not_started(self) + self._flags = v or {} + return self +end + +function Command:set_shutdown_grace(v) + assert_not_started(self) + self._shutdown_grace = v + return self +end + +---------------------------------------------------------------------- +-- Introspection +---------------------------------------------------------------------- + +function Command:status() + local st = self._status + + if st == 'exited' then + return st, self._code, self._err + elseif st == 'signalled' then + return st, self._signal, self._err + elseif st == 'failed' then + return st, nil, self._err + elseif st == 'pending' or st == 'running' then + return st, nil, nil + end + + return st, nil, self._err +end + +function Command:pid() + return self._pid +end + +function Command:argv() + local out = {} + for i, v in ipairs(self._argv) do + out[i] = v + end + return out +end + +---------------------------------------------------------------------- +-- Signalling +---------------------------------------------------------------------- + +function Command:kill(sig) + if self._done then + return true, nil + end + if not self._started then + return false, 'command not started' + end + if self._status == 'failed' then + return false, self._err or 'command failed to start' + end + + local backend = self._proc and self._proc.backend or nil + if not backend then + return false, 'no backend available' + end + + if sig ~= nil and backend.send_signal then + return backend:send_signal(sig) + end + + if backend.kill then + return backend:kill() + elseif backend.terminate then + return backend:terminate() + elseif backend.send_signal then + return backend:send_signal() + end + + return false, 'backend does not support signalling' +end + +---------------------------------------------------------------------- +-- Stream accessors +---------------------------------------------------------------------- + +function Command:stdin_stream() + local cfg = self._stdin + if cfg.mode == 'inherit' or cfg.mode == 'null' then + return nil + end + if cfg.mode == 'stream' then + return cfg.stream + end + if cfg.mode == 'pipe' then + local ok, _, err = self:_ensure_started() + return ok and self._stdin.stream or nil, err + end + return nil +end + +function Command:stdout_stream() + local cfg = self._stdout + if cfg.mode == 'inherit' or cfg.mode == 'null' then + return nil + end + if cfg.mode == 'stream' then + return cfg.stream + end + if cfg.mode == 'pipe' then + local ok, _, err = self:_ensure_started() + return ok and self._stdout.stream or nil, err + end + return nil +end + +function Command:stderr_stream() + local cfg = self._stderr + if cfg.mode == 'inherit' or cfg.mode == 'null' then + return nil + end + if cfg.mode == 'stream' then + return cfg.stream + end + if cfg.mode == 'pipe' then + local ok, _, err = self:_ensure_started() + return ok and self._stderr.stream or nil, err + end + if cfg.mode == 'stdout' then + return self:stdout_stream() + end + return nil +end + +---------------------------------------------------------------------- +-- Ops: wait/run/shutdown/output +---------------------------------------------------------------------- + +function Command:run_op() + return op.guard(function () + -- Repeated waits must remain idempotent even after terminal cleanup has + -- detached the finaliser and released the backend handle. + if self._done then + return op.always(self._status, self._code, self._signal, self._err) + end + + local ok, proc, err = self:_ensure_started() + if not ok or not proc then + return op.always('failed', nil, nil, err) + end + + return proc.backend:wait_op():wrap(function (...) + self:_record_exit(...) + local status, code, signal, wait_err = self._status, self._code, self._signal, self._err + self:_cleanup_terminal() + return status, code, signal, wait_err + end) + end) +end + +function Command:shutdown_op(grace) + return op.guard(function () + -- Repeated shutdown/wait calls after terminal cleanup should report the + -- cached terminal status without requiring a backend handle. + if self._done then + return op.always(self._status, self._code, self._signal, self._err) + end + + local ok, proc, err = self:_ensure_started() + if not (ok and proc) then + return op.always('failed', nil, nil, err) + end + + local g = grace or self._shutdown_grace or DEFAULT_SHUTDOWN_GRACE + + -- Polite termination: delegate behaviour to backend. + if proc.backend and proc.backend.terminate then + proc.backend:terminate() + elseif proc.backend and proc.backend.send_signal then + proc.backend:send_signal() + end + + local choice_ev = op.boolean_choice( + -- boolean_choice already prefixes the winning arm with a boolean. + -- Do not add another boolean here, otherwise shutdown_op returns + -- true, , , when the run arm wins. + self:run_op(), + sleep.sleep_op(g) + ) + + return choice_ev:wrap(function (is_exit, status, code, signal, e) + if is_exit then + return status, code, signal, e + end + + -- Grace period elapsed. Try a forceful kill. + local kill_err + if proc.backend then + if proc.backend.kill then + local ok2, err2 = proc.backend:kill() + if not ok2 and err2 then + kill_err = err2 + end + elseif proc.backend.send_signal then + local ok2, err2 = proc.backend:send_signal() + if not ok2 and err2 then + kill_err = err2 + end + end + end + + -- Wait for 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 status, code, signal = self._status, self._code, self._signal + local err_final = kill_err or err2 + self:_cleanup_terminal() + return status, code, signal, err_final + end) + end) +end + +function Command:output_op() + return op.guard(function () + -- If stdout is currently inherited, default to piping for this helper. + if not self._started and (self._stdout.mode == 'inherit' or self._stdout.mode == nil) then + self._stdout = norm_stream('pipe', false) + end + + local ok, _, err = self:_ensure_started() + if not ok then + return op.always('', 'failed', nil, nil, err) + end + + local stream, serr = self:stdout_stream() + if not stream then + return op.always('', 'failed', nil, nil, serr or 'no stdout stream available') + end + + return stream:read_all_op():wrap(function (out, io_err) + local status, code, signal, perr = perform_with_scope_or_raw(self:run_op()) + local err_final = io_err or perr + return out or '', status, code, signal, err_final + end) + end) +end + +function Command:combined_output_op() + if self._stderr.mode == 'pipe' or self._stderr.mode == 'stream' then + error('combined_output_op: stderr must not already be a pipe or stream') + end + if not self._started and (self._stderr.mode == 'inherit' or self._stderr.mode == nil) then + self._stderr = norm_stream('stdout', true) + end + return self:output_op() +end + +---------------------------------------------------------------------- +-- 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(), + sleep.sleep_op(g) + ) + ) + + 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) + self:_cleanup_terminal() + + -- 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 +---------------------------------------------------------------------- + +function Command:_on_scope_exit() + if self._cleaned then return end + + if self._started and not self._done then + -- Non-interruptible best-effort shutdown. + self:_shutdown_uninterruptible(self._shutdown_grace) + end + + for _, name in ipairs { 'stdin', 'stdout', 'stderr' } do + local cfg = self['_' .. name] + if cfg.stream and cfg.owned then + local ok, err = op.perform_raw(cfg.stream:close_op()) + if not ok then + error(err or ('failed to close ' .. name .. ' stream')) + end + cfg.stream = nil + end + end + + if self._proc and self._proc.backend then + local ok, err = self._proc.backend:close() + if not ok then + error(err or 'failed to close process backend') + end + self._proc.backend = nil + end + self._proc = nil + self._cleaned = true +end + +---------------------------------------------------------------------- +-- Command construction +---------------------------------------------------------------------- + +---@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 = ScopeMod.current() + + local argv = {} + local i = 1 + while spec[i] ~= nil do + argv[i] = assert(spec[i], 'argv must not contain nil') + i = i + 1 + end + assert(argv[1], 'exec.command: argv[1] must be non-nil') + + local cmd = setmetatable({ + _scope = scope, + _argv = argv, + _cwd = spec.cwd, + _env = spec.env, + _flags = spec.flags or {}, + _stdin = norm_stream(spec.stdin, false), + _stdout = norm_stream(spec.stdout, false), + _stderr = norm_stream(spec.stderr, true), + _shutdown_grace = spec.shutdown_grace or DEFAULT_SHUTDOWN_GRACE, + _started = false, + _done = false, + _proc = nil, + _pid = nil, + _status = 'pending', + _code = nil, + _signal = nil, + _err = nil, + _finalizer_detach = nil, + _cleaned = false, + }, Command) + + cmd._finalizer_detach = scope:finally(function () + cmd:_on_scope_exit() + end) + + return cmd +end + +---------------------------------------------------------------------- +-- Public API +---------------------------------------------------------------------- + +local exec = {} + +---@class ExecBackendModule +---@field start fun(spec: ExecSpec): ProcHandle|nil, string|nil + +---@overload fun(spec: ExecSpec): Command +---@param ... any +---@return Command +function exec.command(...) + local n = select('#', ...) + if n == 1 and type((...)) == 'table' then + return command_from_spec((...)) + end + + assert(n > 0, 'exec.command: at least one argv element required') + local spec = {} + for i = 1, n do + spec[i] = assert(select(i, ...), 'argv must not contain nil') + end + return command_from_spec(spec) +end + +exec.Command = Command + +return exec diff --git a/src/fibers/io/exec_backend.lua b/src/fibers/io/exec_backend.lua new file mode 100644 index 0000000..20f32ed --- /dev/null +++ b/src/fibers/io/exec_backend.lua @@ -0,0 +1,46 @@ +-- +-- Backend selector for process management. +-- Prefers pidfd where available, then pure POSIX process backends, then nixio. +-- +---@module 'fibers.io.exec_backend' + +---@class ExecProcSpec +---@field argv string[] +---@field env table|nil +---@field cwd string|nil +---@field flags table|nil +---@field stdin ExecStreamConfig +---@field stdout ExecStreamConfig +---@field stderr ExecStreamConfig + +--- Backend module interface. +---@class ExecBackendModule +---@field is_supported fun(): boolean +---@field start fun(spec: ExecProcSpec): ProcHandle|nil, string|nil + +---@type string[] +local candidates = { + 'fibers.io.exec_backend.pidfd', -- Linux pidfd backend + 'fibers.io.exec_backend.sigchld', -- Portable SIGCHLD + self-pipe backend (luaposix) + 'fibers.io.exec_backend.posix_reaper', -- luaposix reaper/sentinel backend (LuaJIT-safe) + 'fibers.io.exec_backend.nixio', -- nixio reaper/sentinel backend +} + +---@type ExecBackendModule|nil +local chosen + +for _, name in ipairs(candidates) do + local ok, mod = pcall(require, name) + if ok and type(mod) == 'table' and mod.is_supported and mod.is_supported() then + ---@cast mod ExecBackendModule + chosen = mod + break + end +end + +if not chosen then + error('fibers.io.exec_backend: no suitable process backend available on this platform') +end + +---@return ExecBackendModule +return chosen diff --git a/src/fibers/io/exec_backend/core.lua b/src/fibers/io/exec_backend/core.lua new file mode 100644 index 0000000..73740e3 --- /dev/null +++ b/src/fibers/io/exec_backend/core.lua @@ -0,0 +1,175 @@ +-- fibers/io/exec_backend/core.lua +-- +-- Core glue for exec backends. +-- +-- This owns the public ExecBackend shape and semantics. +-- Backend modules provide only low-level primitives; build_backend +-- wires those into a concrete { start, ExecBackend, is_supported } module. +-- +---@module 'fibers.io.exec_backend.core' + +local waitmod = require 'fibers.wait' + +---@class ExecBackend +---@field pid integer|nil +---@field exited boolean +---@field status integer|nil +---@field code integer|nil +---@field signal integer|nil +---@field err string|nil +---@field _state any +---@field _ops table +local ExecBackend = {} +ExecBackend.__index = ExecBackend + +--- Wait for the process to complete, returning (code, signal, err). +---@return Op +function ExecBackend:wait_op() + local ops = self._ops + local state = self._state + + local function step() + if self.exited then + return true, self.code, self.signal, self.err + end + + local done, code, signal, err = ops.poll(state) + if not done then + return false + end + + self.exited = true + self.code = code + self.signal = signal + self.err = err + return true, self.code, self.signal, self.err + end + + ---@param task Task + ---@param suspension Suspension + ---@param leaf_wrap WrapFn + local function register(task, suspension, leaf_wrap) + return ops.register_wait(state, task, suspension, leaf_wrap) + end + + local function wrap(code, signal, err) + return code, signal, err + end + + return waitmod.waitable(register, step, wrap) +end + +function ExecBackend:send_signal(sig) + local ops = self._ops + if ops.send_signal then + return ops.send_signal(self._state, sig) + end + return false, 'backend does not support send_signal' +end + +function ExecBackend:terminate() + local ops = self._ops + if ops.terminate then + return ops.terminate(self._state) + elseif ops.send_signal then + return ops.send_signal(self._state, nil) + end + return false, 'backend does not support terminate' +end + +function ExecBackend:kill() + local ops = self._ops + if ops.kill then + return ops.kill(self._state) + elseif ops.send_signal then + return ops.send_signal(self._state, nil) + end + return false, 'backend does not support kill' +end + +function ExecBackend:close() + local ops = self._ops + if ops.close then + return ops.close(self._state) + end + return true, nil +end + +---------------------------------------------------------------------- +-- Backend builder +---------------------------------------------------------------------- + +--- Build a concrete exec backend module from low-level ops. +--- +--- Required ops: +--- spawn(spec) -> state, streams, err|nil +--- state : backend-private state (must at least contain state.pid) +--- streams : { stdin = Stream|nil, stdout = Stream|nil, stderr = Stream|nil } +--- +--- poll(state) -> done:boolean, code|nil, signal|nil, err|nil +--- Non-blocking; done=false means “still running”. +--- +--- register_wait(state, task, suspension, leaf_wrap) -> WaitToken +--- Register a Task to be run when progress may have been made. +--- +--- Optional ops: +--- send_signal(state, sig) -> ok:boolean, err|nil +--- terminate(state) -> ok:boolean, err|nil +--- kill(state) -> ok:boolean, err|nil +--- close(state) -> ok:boolean, err|nil +--- is_supported() -> boolean +--- +---@param ops table +---@return table backend_module -- { start = fn, ExecBackend = ExecBackend, is_supported = fn } +local function build_backend(ops) + assert(type(ops) == 'table', 'exec_backend ops must be a table') + assert(type(ops.spawn) == 'function', 'ops.spawn must be a function') + assert(type(ops.poll) == 'function', 'ops.poll must be a function') + assert(type(ops.register_wait) == 'function', 'ops.register_wait must be a function') + + local function start(spec) + local state, streams, err = ops.spawn(spec) + if not state then + return nil, err + end + + local backend = setmetatable({ + _ops = ops, + _state = state, + + pid = state.pid, -- for introspection + exited = false, + status = nil, + code = nil, + signal = nil, + err = nil, + }, ExecBackend) + + local handle = { + backend = backend, + stdin = streams and streams.stdin or nil, + stdout = streams and streams.stdout or nil, + stderr = streams and streams.stderr or nil, + } + + return handle, nil + end + + local function is_supported() + if type(ops.is_supported) == 'function' then + return not not ops.is_supported() + end + return true + end + + return { + ExecBackend = ExecBackend, + start = start, + is_supported = is_supported, + } +end + +return { + ExecBackend = ExecBackend, + build_backend = build_backend, +} diff --git a/src/fibers/io/exec_backend/nixio.lua b/src/fibers/io/exec_backend/nixio.lua new file mode 100644 index 0000000..5e814d3 --- /dev/null +++ b/src/fibers/io/exec_backend/nixio.lua @@ -0,0 +1,482 @@ +-- fibers/io/exec_backend/nixio.lua +-- +-- Nixio-based exec backend using a per-command “reaper” process and +-- a sentinel pipe for completion notifications. +-- +-- Topology per command: +-- parent +-- ├─ reaper (Lua, this module) +-- │ └─ child (exec'ed programme) +-- └─ sentinel_r (read end of status pipe) +-- +-- Protocol on the sentinel pipe: +-- - reaper writes: "pid \n" +-- - later writes one of: +-- "exited \n" +-- "signaled \n" +-- "failed \n" +-- +-- Parent uses poller to wait for sentinel_r readability; when data +-- arrives, it parses lines and updates backend state. +-- +-- Uses nixio File objects as fds throughout. + +local core = require 'fibers.io.exec_backend.core' +local poller = require 'fibers.io.poller' +local runtime = require 'fibers.runtime' +local file_io = require 'fibers.io.file' +local stdio = require 'fibers.io.exec_backend.stdio' +local reaper_common = require 'fibers.io.exec_backend.reaper_common' + +local ok, nixio = pcall(require, 'nixio') +if not ok or not nixio then + return { is_supported = function () return false end } +end + +local const = nixio.const or {} + +local unpack = rawget(table, 'unpack') or _G.unpack + +local DEV_NULL = '/dev/null' + +---------------------------------------------------------------------- +-- Small helpers +---------------------------------------------------------------------- + +local function errno_msg(prefix) + local eno = nixio.errno() + local estr = (nixio.strerror and nixio.strerror(eno)) or ('errno ' .. tostring(eno)) + if prefix then + return ('%s: %s'):format(prefix, estr) + end + return estr +end + +local function close_fd(f) + if f and f.close then + pcall(function () f:close() end) + end +end + +---------------------------------------------------------------------- +-- Stdio integration for exec_backend.stdio +---------------------------------------------------------------------- + +--- Open /dev/null for input or output (child side). +---@param is_output boolean +---@return any fd, string|nil err +local function open_dev_null(is_output) + local mode = is_output and 'w' or 'r' + local f, err = nixio.open(DEV_NULL, mode) + if not f then + return nil, err or errno_msg('open ' .. DEV_NULL) + end + return f, nil +end + +--- Create a pipe (child <-> parent). +---@return any rd, any wr, string|nil err +local function make_pipe() + local rd, wr = nixio.pipe() + if not rd or not wr then + return nil, nil, errno_msg('pipe') + end + return rd, wr, nil +end + +--- We rely on explicit close() in child/reaper instead of close-on-exec. +---@param _ any +---@return boolean, string|nil +local function set_cloexec(_) + return true, nil +end + +--- Wrap a parent-side fd into a Stream. +---@param role "stdin"|"stdout"|"stderr" +---@param fd any -- nixio File +---@return Stream +local function open_stream(role, fd) + if role == 'stdin' then + return file_io.fdopen(fd, 'w') + else + return file_io.fdopen(fd, 'r') + end +end + +---------------------------------------------------------------------- +-- Child exec path (runs in the real child process) +---------------------------------------------------------------------- + +--- Duplicate src onto dest_fd (0/1/2) using nixio.dup with nixio.stdin/stdout/stderr. +---@param src any -- nixio File +---@param dest_fd integer +local function setup_child_fd(src, dest_fd) + if not src then + return + end + + local curfd = src:fileno() + if curfd == dest_fd then + return + end + + local dest + if dest_fd == 0 then + dest = nixio.stdin + elseif dest_fd == 1 then + dest = nixio.stdout + elseif dest_fd == 2 then + dest = nixio.stderr + else + -- exec_backend.stdio only uses 0/1/2. + os.exit(127) + end + + local dup, _ = nixio.dup(src, dest) + if not dup then + os.exit(127) + end +end + +local function apply_child_env(env) + for name, value in pairs(env) do + if value == nil then + nixio.setenv(name) -- unset + else + local ok1, _ = nixio.setenv(name, tostring(value)) + if not ok1 then + os.exit(127) + end + end + end +end + +---@param child_spec table -- child-facing spec with *fd fields +---@param child_only table|nil +---@param parent_fds table|nil +---@param sentinel_w any|nil -- nixio File, closed in child +local function child_exec(child_spec, child_only, parent_fds, sentinel_w) + if sentinel_w then + close_fd(sentinel_w) + end + + if child_spec.cwd then + local ok1, _ = nixio.chdir(child_spec.cwd) + if not ok1 then + os.exit(127) + end + end + + if child_spec.flags and child_spec.flags.setsid then + local sid, _ = nixio.setsid() + if not sid then + os.exit(127) + end + end + + if child_spec.env then + apply_child_env(child_spec.env) + end + + setup_child_fd(child_spec.stdin_fd, 0) + setup_child_fd(child_spec.stdout_fd, 1) + setup_child_fd(child_spec.stderr_fd, 2) + + stdio.close_child_only(child_only, close_fd) + stdio.close_parent_fds(parent_fds, close_fd) + + local argv = child_spec.argv + local prog = assert(argv[1], 'child_exec: argv[1] must be non-nil') + + -- execp(executable, ...) sets argv[0] automatically. + local n = #argv + if n == 1 then + nixio.execp(prog) + else + local args = {} + for i = 2, n do + args[#args + 1] = argv[i] + end + nixio.execp(prog, unpack(args)) + end + + os.exit(127) +end + +---------------------------------------------------------------------- +-- Parent-side reaper helpers +---------------------------------------------------------------------- + +---@class NixioExecState +---@field reaper_pid integer -- pid of the reaper process +---@field pid integer|nil -- for introspection; updated to child pid when known +---@field child_pid integer|nil -- pid of the real exec'ed child +---@field sentinel any -- nixio File (read end) +---@field exited boolean +---@field code integer|nil +---@field signal integer|nil +---@field err string|nil +---@field _buf string|nil +---@field _have_status boolean|nil +---@field _reaper_reaped boolean|nil + +local function reap_reaper(state, blocking) + if state._reaper_reaped or not state.reaper_pid then + return + end + + local pid + if blocking then + pid = nixio.waitpid(state.reaper_pid) + else + pid = nixio.waitpid(state.reaper_pid, 'nohang') + end + + if pid and pid ~= 0 then + state._reaper_reaped = true + end +end + +local function read_sentinel(fd) + local chunk, _ = fd:read(const.buffersize or 256) + if not chunk then + local eno = nixio.errno() + if eno == const.EAGAIN or eno == const.EWOULDBLOCK or eno == const.EINTR then + return nil, 'wait', nil + end + return nil, 'error', 'reaper sentinel closed' + end + if #chunk == 0 then + return nil, 'eof', nil + end + return chunk, 'data', nil +end + +local reaper_ops = { + read_sentinel = read_sentinel, + close_fd = close_fd, + reap_reaper = reap_reaper, + kill_pid = nil, -- filled after send_signal helpers are defined + default_term = const.SIGTERM or 15, +} + +---------------------------------------------------------------------- +-- Reaper process path +---------------------------------------------------------------------- + +--- Run in the per-command reaper process. +---@param child_spec table +---@param child_only table|nil +---@param parent_fds table|nil +---@param sentinel_r any -- nixio File (parent-side read end, close here) +---@param sentinel_w any -- nixio File (reaper-side writer) +local function reaper_main(child_spec, child_only, parent_fds, sentinel_r, sentinel_w) + -- Reaper does not need parent pipe ends or parent's sentinel read end. + stdio.close_parent_fds(parent_fds, close_fd) + close_fd(sentinel_r) + + -- Fork the real child. + local child_pid, err = nixio.fork() + if not child_pid then + if sentinel_w then + sentinel_w:write('failed ' .. (err or errno_msg('fork')) .. '\n') + close_fd(sentinel_w) + end + os.exit(127) + end + + if child_pid == 0 then + -- In the real child. + child_exec(child_spec, child_only, parent_fds, sentinel_w) + os.exit(127) + end + + -- In the reaper. + stdio.close_child_only(child_only, close_fd) + + -- Tell parent the real child pid. + if sentinel_w then + pcall(function () + sentinel_w:write(('pid %d\n'):format(child_pid)) + end) + end + + -- Wait for the real child to exit. + local pid, how, what + while true do + pid, how, what = nixio.waitpid(child_pid) + if pid ~= nil then + break + end + local eno = nixio.errno() + if eno ~= const.EINTR then + break + end + end + + local line + if not pid then + line = 'failed ' .. errno_msg('waitpid') .. '\n' + else + if how == 'exited' then + local code = tonumber(what) or 0 + line = ('exited %d\n'):format(code) + elseif how == 'signaled' or how == 'signalled' then + local sig = tonumber(what) or 0 + line = ('signaled %d\n'):format(sig) + else + line = ('failed unexpected %s %s\n'):format(tostring(how), tostring(what)) + end + end + + if sentinel_w then + pcall(function () + sentinel_w:write(line) + sentinel_w:close() + end) + end + + os.exit(0) +end + +---------------------------------------------------------------------- +-- exec_backend.core ops +---------------------------------------------------------------------- + +--- spawn(spec) -> state, streams, err +---@param spec ExecProcSpec +---@return NixioExecState|nil state,{stdin:Stream|nil,stdout:Stream|nil,stderr:Stream|nil}|nil streams,string|nil err +local function spawn(spec) + assert(type(spec) == 'table', 'ExecBackend.spawn: spec must be a table') + assert(type(spec.argv) == 'table' and spec.argv[1], + 'ExecBackend.spawn: spec.argv must be a non-empty array') + + local child_spec, child_only, parent_fds, cfg_err = + stdio.build_child_stdio(spec, open_dev_null, make_pipe, set_cloexec, close_fd) + if not child_spec then + return nil, nil, cfg_err + end + + -- Sentinel pipe: reaper writes, parent reads. + local sentinel_r, sentinel_w = nixio.pipe() + if not sentinel_r or not sentinel_w then + stdio.close_child_only(child_only, close_fd) + stdio.close_parent_fds(parent_fds, close_fd) + return nil, nil, errno_msg('pipe (sentinel)') + end + + -- We will use the sentinel in blocking mode temporarily for a + -- handshake to learn the real child pid, then switch to non-blocking. + sentinel_r:setblocking(true) + + -- Fork the per-command reaper. + local reaper_pid, ferr = nixio.fork() + if not reaper_pid then + stdio.close_child_only(child_only, close_fd) + stdio.close_parent_fds(parent_fds, close_fd) + close_fd(sentinel_r) + close_fd(sentinel_w) + return nil, nil, ferr or errno_msg('fork (reaper)') + end + + if reaper_pid == 0 then + reaper_main(child_spec, child_only, parent_fds, sentinel_r, sentinel_w) + os.exit(127) + end + + -- Parent. + stdio.close_child_only(child_only, close_fd) + close_fd(sentinel_w) + + local state = reaper_common.new_state(reaper_pid, sentinel_r) + + -- Handshake: read sentinel until we have seen a pid line and/or a + -- terminal status. This guarantees child_pid is known before any + -- external code can attempt to send signals. + local bufsize = const.buffersize or 256 + + while not state.child_pid and not state._have_status do + local chunk, rerr = sentinel_r:read(bufsize) + if not chunk then + close_fd(sentinel_r) + state.sentinel = nil + return nil, nil, rerr or errno_msg('sentinel handshake read') + end + if #chunk == 0 then + close_fd(sentinel_r) + state.sentinel = nil + return nil, nil, 'sentinel closed during handshake' + end + + reaper_common.feed_status_chunk(state, chunk) + end + + -- Switch sentinel to non-blocking for normal event-loop use. + sentinel_r:setblocking(false) + + local streams = stdio.build_parent_streams(parent_fds, open_stream) + + return state, streams, nil +end + +--- poll(state) -> done, code, signal, err +local function poll_backend(state) + return reaper_common.poll_state(state, reaper_ops) +end + +--- register_wait(state, task, suspension, leaf_wrap) -> WaitToken +local function register_wait(state, task, _, _) + return reaper_common.register_wait(state, task) +end + +--- Send a signal to the real child. +---@param pid integer +---@param sig integer +---@return boolean ok, string|nil err +local function kill_pid(pid, sig) + local ok1, err = nixio.kill(pid, sig) + if not ok1 then + return false, err or errno_msg('kill') + end + return true, nil +end + +reaper_ops.kill_pid = kill_pid + +local function send_signal(state, sig) + return reaper_common.send_signal(state, sig, reaper_ops) +end + +local function terminate(state) + return send_signal(state, const.SIGTERM or 15) +end + +local function kill_proc(state) + return send_signal(state, const.SIGKILL or 9) +end + +local function close_state(state) + return reaper_common.close_state(state, reaper_ops) +end + +local function is_supported() + return type(nixio) == 'table' + and type(nixio.fork) == 'function' + and type(nixio.waitpid) == 'function' + and type(nixio.execp) == 'function' + and type(nixio.pipe) == 'function' + and type(nixio.open) == 'function' +end + +local ops = { + spawn = spawn, + poll = poll_backend, + register_wait = register_wait, + send_signal = send_signal, + terminate = terminate, + kill = kill_proc, + close = close_state, + is_supported = is_supported, +} + +return core.build_backend(ops) diff --git a/src/fibers/io/exec_backend/pidfd.lua b/src/fibers/io/exec_backend/pidfd.lua new file mode 100644 index 0000000..b301be4 --- /dev/null +++ b/src/fibers/io/exec_backend/pidfd.lua @@ -0,0 +1,568 @@ +-- fibers/io/exec_backend/pidfd.lua +-- +-- Linux pidfd-based process backend. +-- Uses fork + execvp + raw pidfd_open syscall + non-blocking waitpid. +-- +---@module 'fibers.io.exec_backend.pidfd' + +local core = require 'fibers.io.exec_backend.core' +local poller = require 'fibers.io.poller' +local runtime = require 'fibers.runtime' +local ffi_c = require 'fibers.utils.ffi_compat' +local file_io = require 'fibers.io.file' +local stdio = require 'fibers.io.exec_backend.stdio' + +local ffi = ffi_c.ffi +local C = ffi_c.C +local toint = ffi_c.tonumber +local get_errno = ffi_c.errno +local DEV_NULL = '/dev/null' + +local bit = rawget(_G, 'bit') or require 'bit32' + +---------------------------------------------------------------------- +-- FFI / CFFI availability +---------------------------------------------------------------------- + +if not (ffi_c.is_supported and ffi_c.is_supported()) then + return { is_supported = function () return false end } +end + +---------------------------------------------------------------------- +-- FFI declarations and constants +---------------------------------------------------------------------- + +local jit = rawget(_G, "jit") +local ARCH = ffi.arch or ((jit and jit.arch) or 'x64') + +ffi.cdef [[ + typedef int pid_t; + typedef unsigned int uint; + + long syscall(long number, ...); + + pid_t fork(void); + void _exit(int status); + int chdir(const char *path); + int setenv(const char *name, const char *value, int overwrite); + pid_t setsid(void); + int execvp(const char *file, char *const argv[]); + + int kill(pid_t pid, int sig); + int close(int fd); + + int fcntl(int fd, int cmd, ...); + + pid_t waitpid(pid_t pid, int *wstatus, int options); + pid_t getpid(void); + + int dup2(int oldfd, int newfd); + + int pipe(int pipefd[2]); + int open(const char *pathname, int flags, int mode); + + char *strerror(int errnum); +]] + +-- Raw syscall number for pidfd_open. +local SYS_pidfd_open = 434 -- Linux generic +if ARCH == 'mips' or ARCH == 'mipsel' then + -- See https://www.linux-mips.org/wiki/Syscall + SYS_pidfd_open = 4000 + 434 +end + +-- fcntl constants (Linux) +local F_GETFL = 3 +local F_SETFL = 4 +local F_GETFD = 1 +local F_SETFD = 2 +local O_NONBLOCK = 0x00000800 +local FD_CLOEXEC = 1 + +-- Minimal open() flags we need here. +local O_RDONLY = 0 +local O_WRONLY = 1 + +-- wait/errno constants (Linux) +local WNOHANG = 1 + +local EINTR = 4 +local ESRCH = 3 +local ECHILD = 10 +local ENOSYS = 38 + +-- Signals (Linux values). +local SIGTERM = 15 +local SIGKILL = 9 + +---------------------------------------------------------------------- +-- Small helpers +---------------------------------------------------------------------- + +local function strerror(e) + local s = C.strerror(e) + if s == nil then + return 'errno ' .. tostring(e) + end + return ffi.string(s) +end + +local function errno_msg(prefix, err, eno) + if err and err ~= '' then + return err + end + if eno then + return ('%s (errno %d)'):format(prefix, eno) + end + return prefix +end + +-- In the child we must not return to Lua on fatal errors. +local function must_child(ok) + if not ok then + C._exit(127) + end +end + +---------------------------------------------------------------------- +-- Raw pidfd_open syscall (musl/glibc-independent) +---------------------------------------------------------------------- + +local function pidfd_open_raw(pid, flags) + pid = ffi.new('pid_t', pid) + flags = ffi.new('uint', flags or 0) + + local fd = toint(C.syscall(SYS_pidfd_open, pid, flags)) + if fd == -1 then + local e = get_errno() + return nil, strerror(e), e + end + return fd, nil, nil +end + +---------------------------------------------------------------------- +-- fcntl helpers: set_nonblock / set_cloexec +---------------------------------------------------------------------- + +local getfl_fp = ffi.cast('int (*)(int, int)', C.fcntl) +local setfl_fp = ffi.cast('int (*)(int, int, int)', C.fcntl) + +local function set_nonblock(fd) + local before = assert(toint(getfl_fp(fd, F_GETFL))) + if before < 0 then + local e = get_errno() + return false, ('F_GETFL failed: %s'):format(strerror(e)), e + end + + local new_flags = bit.bor(before, O_NONBLOCK) + local rc = toint(setfl_fp(fd, F_SETFL, new_flags)) + if rc < 0 then + local e = get_errno() + return false, ('F_SETFL failed: %s'):format(strerror(e)), e + end + + -- Optional sanity check. + local after = assert(toint(getfl_fp(fd, F_GETFL))) + if after < 0 then + local e = get_errno() + return false, ('F_GETFL (post) failed: %s'):format(strerror(e)), e + end + + if bit.band(after, O_NONBLOCK) == 0 then + return false, + ('set_nonblock: O_NONBLOCK not set after F_SETFL; before=0x%x after=0x%x') + :format(before, after), + nil + end + + return true, nil, nil +end + +local getfd_fp = ffi.cast('int (*)(int, int)', C.fcntl) +local setfd_fp = ffi.cast('int (*)(int, int, int)', C.fcntl) + +local function set_cloexec(fd) + local before = assert(toint(getfd_fp(fd, F_GETFD))) + if before < 0 then + local e = get_errno() + return false, ('F_GETFD failed: %s'):format(strerror(e)), e + end + + local new_flags = bit.bor(before, FD_CLOEXEC) + local rc = toint(setfd_fp(fd, F_SETFD, new_flags)) + if rc < 0 then + local e = get_errno() + return false, ('F_SETFD failed: %s'):format(strerror(e)), e + end + + return true, nil, nil +end + +---------------------------------------------------------------------- +-- waitpid helpers (status inspection) +---------------------------------------------------------------------- + +local function WIFEXITED(status) + return bit.band(status, 0x7f) == 0 +end + +local function WEXITSTATUS(status) + return bit.rshift(status, 8) +end + +local function WIFSIGNALED(status) + local term = bit.band(status, 0x7f) + return term ~= 0 and term ~= 0x7f +end + +local function WTERMSIG(status) + return bit.band(status, 0x7f) +end + +---------------------------------------------------------------------- +-- Child-side helpers: argv/env/fd setup +---------------------------------------------------------------------- + +local function build_argv_c(argv) + local n = #argv + local cargv = ffi.new('char *[?]', n + 1) + + for i = 1, n do + local s = assert(argv[i], 'argv must not contain nil') + local cs = ffi.new('char[?]', #s + 1) + ffi.copy(cs, s) + cargv[i - 1] = cs + end + cargv[n] = nil + + return cargv +end + +local function setup_child_fd(src_fd, dest_fd) + if not src_fd or src_fd == dest_fd then + return + end + local rc = toint(C.dup2(src_fd, dest_fd)) + if rc < 0 then + must_child(false) + end +end + +local function apply_child_env(env) + for name, value in pairs(env) do + local v = value and tostring(value) or nil + local rc = C.setenv(name, v, 1) -- value == nil clears the variable + if rc ~= 0 then + must_child(false) + end + end +end + +---@param spec table -- child-facing spec with *fd fields +local function child_exec(spec) + if spec.cwd then + local rc = C.chdir(spec.cwd) + must_child(rc == 0) + end + + if spec.flags and spec.flags.setsid then + local rc = toint(C.setsid()) + must_child(rc ~= -1) + end + + if spec.env then + apply_child_env(spec.env) + end + + setup_child_fd(spec.stdin_fd, 0) + setup_child_fd(spec.stdout_fd, 1) + setup_child_fd(spec.stderr_fd, 2) + + do + local seen = {} + for _, fd in ipairs { spec.stdin_fd, spec.stdout_fd, spec.stderr_fd } do + if fd and fd > 2 and not seen[fd] then + seen[fd] = true + C.close(fd) + end + end + end + + local argv = spec.argv + local cargv = build_argv_c(argv) + + C.execvp(argv[1], cargv) + + -- If we reach here, execvp failed. + C._exit(127) +end + +---------------------------------------------------------------------- +-- Backend state helpers +---------------------------------------------------------------------- + +---@class PidfdState +---@field pid integer +---@field pidfd integer|nil +---@field exited boolean +---@field status integer|nil +---@field code integer|nil +---@field signal integer|nil +---@field err string|nil + +local function finalise_state(st, status, code, signal, err) + if st.exited then + return + end + st.exited = true + st.status = status + st.code = code + st.signal = signal + st.err = err +end + +--- Blocking wait used only in the error path after a failed pidfd_open. +local function wait_blocking(pid) + local status_buf = ffi.new('int[1]') + while true do + local rpid = toint(C.waitpid(pid, status_buf, 0)) + if rpid == -1 then + local e = get_errno() + if e ~= EINTR then + return + end + else + -- Child reaped. + return + end + end +end + +--- Non-blocking wait on a single child. +---@param st PidfdState +---@return boolean done, integer|nil code, integer|nil signal, string|nil err +local function poll_state(st) + if st.exited then + return true, st.code, st.signal, st.err + end + + local status_buf = ffi.new('int[1]') + local rpid = toint(C.waitpid(st.pid, status_buf, WNOHANG)) + + if rpid == 0 then + -- Still running. + return false, nil, nil, nil + end + + if rpid == -1 then + local e = get_errno() + if e == ECHILD or e == ESRCH then + -- Child already gone or reaped elsewhere. + finalise_state(st, nil, nil, nil, nil) + return true, st.code, st.signal, st.err + end + finalise_state(st, nil, nil, nil, errno_msg('waitpid failed', nil, e)) + return true, st.code, st.signal, st.err + end + + local status = status_buf[0] + + if WIFEXITED(status) then + local code = WEXITSTATUS(status) + finalise_state(st, status, code, nil, nil) + elseif WIFSIGNALED(status) then + local sig = WTERMSIG(status) + finalise_state(st, status, nil, sig, nil) + else + -- Stopped/continued or other odd state; treat as “completed, detail unknown”. + finalise_state(st, status, nil, nil, nil) + end + + return true, st.code, st.signal, st.err +end + +---------------------------------------------------------------------- +-- Stream / stdio integration via exec_stdio +---------------------------------------------------------------------- + +local function open_dev_null(is_output) + local flags = is_output and O_WRONLY or O_RDONLY + local fd = toint(C.open(DEV_NULL, flags, 0)) + if fd < 0 then + local e = get_errno() + return nil, errno_msg('failed to open ' .. DEV_NULL, nil, e) + end + return fd, nil +end + +local function make_pipe() + local pipefd = ffi.new('int[2]') + local rc = toint(C.pipe(pipefd)) + if rc ~= 0 then + local e = get_errno() + return nil, nil, errno_msg('pipe() failed', nil, e) + end + return toint(pipefd[0]), toint(pipefd[1]), nil +end + +local function close_fd(fd) + C.close(fd) +end + +local function open_stream(role, fd) + if role == 'stdin' then + return file_io.fdopen(fd, O_WRONLY) + else + -- stdout / stderr + return file_io.fdopen(fd, O_RDONLY) + end +end + +---------------------------------------------------------------------- +-- Backend ops for exec_backend.core +---------------------------------------------------------------------- + +--- spawn(spec) -> state, streams, err +---@param spec ExecProcSpec +---@return PidfdState|nil state, {stdin:Stream|nil, stdout:Stream|nil, stderr:Stream|nil}|nil streams, string|nil err +local function spawn(spec) + assert(type(spec) == 'table', 'ExecBackend.spawn: spec must be a table') + assert(type(spec.argv) == 'table' and spec.argv[1], + 'ExecBackend.spawn: spec.argv must be a non-empty array') + + -- Common stdio wiring. + local child_spec, child_only, parent_fds, cfg_err = + stdio.build_child_stdio(spec, open_dev_null, make_pipe, set_cloexec, close_fd) + if not child_spec then + return nil, nil, cfg_err + end + + -- Fork. + local pid = toint(C.fork()) + if pid < 0 then + local e = get_errno() + stdio.close_child_only(child_only, close_fd) + stdio.close_parent_fds(parent_fds, close_fd) + return nil, nil, errno_msg('fork failed', nil, e) + end + + if pid == 0 then + child_exec(child_spec) -- never returns + end + + -- Parent: child-only fds no longer needed. + stdio.close_child_only(child_only, close_fd) + + -- Open pidfd. + local pidfd, perr, perrno = pidfd_open_raw(pid, 0) + if not pidfd then + C.kill(pid, SIGKILL) + wait_blocking(pid) + stdio.close_parent_fds(parent_fds, close_fd) + return nil, nil, errno_msg('pidfd_open failed', perr, perrno) + end + + local ok, e1 = set_nonblock(pidfd) + assert(ok, 'set_nonblock(pidfd) failed: ' .. tostring(e1)) + + ok, e1 = set_cloexec(pidfd) + assert(ok, 'set_cloexec(pidfd) failed: ' .. tostring(e1)) + + local state = { + pid = pid, + pidfd = pidfd, + exited = false, + status = nil, + code = nil, + signal = nil, + err = nil, + } + + -- Common mapping from parent_fds -> Streams. + local streams = stdio.build_parent_streams(parent_fds, open_stream) + + return state, streams, nil +end + +--- poll(state) -> done, code, signal, err +local function poll(state) + return poll_state(state) +end + +--- register_wait(state, task, suspension, leaf_wrap) -> WaitToken +local function register_wait(state, task, _, _) + if not state.pidfd then + -- No pidfd: best-effort reschedule. + runtime.current_scheduler:schedule(task) + return { unlink = function () return false end } + end + return poller.get():wait(state.pidfd, 'rd', task) +end + +local function send_signal(state, sig) + sig = sig or SIGTERM + + local rc = toint(C.kill(state.pid, sig)) + if rc == 0 then + return true, nil + end + + local e = get_errno() + if e == ESRCH then + return true, nil + end + + return false, errno_msg('kill failed', nil, e) +end + +local function terminate(state) + return send_signal(state, SIGTERM) +end + +local function kill_proc(state) + return send_signal(state, SIGKILL) +end + +local function close_state(state) + if state.pidfd then + local rc = toint(C.close(state.pidfd)) + state.pidfd = nil + if rc ~= 0 then + local e = get_errno() + return false, strerror(e) + end + end + return true, nil +end + +---------------------------------------------------------------------- +-- Capability probe +---------------------------------------------------------------------- + +local function is_supported() + local pid = C.getpid() + local fd, _, eno = pidfd_open_raw(pid, 0) + if fd then + C.close(fd) + return true + end + + if eno == ENOSYS then + return false + end + + return true +end + +local ops = { + spawn = spawn, + poll = poll, + register_wait = register_wait, + send_signal = send_signal, + terminate = terminate, + kill = kill_proc, + close = close_state, + is_supported = is_supported, +} + +return core.build_backend(ops) diff --git a/src/fibers/io/exec_backend/posix_reaper.lua b/src/fibers/io/exec_backend/posix_reaper.lua new file mode 100644 index 0000000..e085cc1 --- /dev/null +++ b/src/fibers/io/exec_backend/posix_reaper.lua @@ -0,0 +1,498 @@ +-- fibers/io/exec_backend/posix_reaper.lua +-- +-- luaposix-based exec backend using a per-command reaper process and +-- a sentinel pipe for completion notifications. +-- +-- This backend is intended for runtimes where a Lua SIGCHLD handler is not +-- safe or reliable, notably LuaJIT + luaposix. It preserves the evented +-- parent-side model: the scheduler waits for ordinary fd readability on a +-- sentinel pipe rather than polling time. +-- +-- Topology per command: +-- parent +-- ├─ reaper process (Lua, this module) +-- │ └─ real child (exec'ed programme) +-- └─ sentinel_r (read end of status pipe) +-- +-- Protocol on the sentinel pipe: +-- - reaper writes: "pid \n" +-- - later writes one of: +-- "exited \n" +-- "signaled \n" +-- "failed \n" +-- +-- Parent uses the Fibers poller to wait for sentinel_r readability. When +-- terminal status arrives, the parent also reaps the intermediate reaper. + +---@module 'fibers.io.exec_backend.posix_reaper' + +local core = require 'fibers.io.exec_backend.core' +local poller = require 'fibers.io.poller' +local runtime = require 'fibers.runtime' +local file_io = require 'fibers.io.file' +local stdio = require 'fibers.io.exec_backend.stdio' +local reaper_common = require 'fibers.io.exec_backend.reaper_common' + +local ok_unistd, unistd = pcall(require, 'posix.unistd') +local ok_wait, syswait = pcall(require, 'posix.sys.wait') +local ok_signal, psig = pcall(require, 'posix.signal') +local ok_fcntl, fcntl = pcall(require, 'posix.fcntl') +local ok_errno, errno = pcall(require, 'posix.errno') +local ok_stdlib, stdlib = pcall(require, 'posix.stdlib') + +if not (ok_unistd and ok_wait and ok_signal and ok_fcntl and ok_errno and ok_stdlib) then + return { is_supported = function () return false end } +end + +local bit = rawget(_G, 'bit') or require 'bit32' + +local DEV_NULL = '/dev/null' + +---------------------------------------------------------------------- +-- Small helpers +---------------------------------------------------------------------- + +local function errno_msg(prefix, err, eno) + if err and err ~= '' then + return err + end + if eno then + return ('%s (errno %s)'):format(prefix, tostring(eno)) + end + return prefix +end + +local function close_fd(fd) + if fd ~= nil then + pcall(unistd.close, fd) + end +end + +local function set_nonblock(fd) + local flags, err, eno = fcntl.fcntl(fd, fcntl.F_GETFL) + if flags == nil then + return nil, errno_msg('fcntl(F_GETFL)', err, eno) + end + local ok, err2, eno2 = fcntl.fcntl(fd, fcntl.F_SETFL, bit.bor(flags, fcntl.O_NONBLOCK)) + if ok == nil then + return nil, errno_msg('fcntl(F_SETFL)', err2, eno2) + end + return true, nil +end + +local function set_cloexec(fd) + if not (fcntl.F_GETFD and fcntl.F_SETFD) then + return true, nil + end + local flags, err, eno = fcntl.fcntl(fd, fcntl.F_GETFD) + if flags == nil then + return nil, errno_msg('fcntl(F_GETFD)', err, eno) + end + local ok, err2, eno2 = fcntl.fcntl(fd, fcntl.F_SETFD, bit.bor(flags, fcntl.FD_CLOEXEC or 0)) + if ok == nil then + return nil, errno_msg('fcntl(F_SETFD)', err2, eno2) + end + return true, nil +end + +local function write_all(fd, s) + local off = 1 + while off <= #s do + local n, err, eno = unistd.write(fd, s:sub(off)) + if n == nil then + if eno == errno.EINTR then + -- Retry. + else + return nil, errno_msg('write', err, eno) + end + else + off = off + n + end + end + return true, nil +end + +local function must_child(ok, _, _) + if not ok or ok == 0 then + unistd._exit(127) + end +end + +local function build_argt(argv) + local cmd = assert(argv[1], 'ProcSpec.argv[1] must be executable') + local argt = {} + argt[0] = cmd + for i = 2, #argv do + argt[i - 1] = argv[i] + end + return cmd, argt +end + +local function setup_child_fd(src_fd, dest_fd) + if src_fd == nil or src_fd == dest_fd then + return + end + local newfd, err, eno = unistd.dup2(src_fd, dest_fd) + if not newfd then + must_child(false, err, eno) + end +end + +local function apply_child_env(env) + for name, value in pairs(env) do + local ok, err, eno = stdlib.setenv(name, value and tostring(value) or nil) + if ok == nil then + must_child(false, err, eno) + end + end +end + +---------------------------------------------------------------------- +-- Stdio integration for exec_backend.stdio +---------------------------------------------------------------------- + +local function open_dev_null(is_output) + local flags = is_output and fcntl.O_WRONLY or fcntl.O_RDONLY + local fd, err, eno = fcntl.open(DEV_NULL, flags, 0) + if not fd then + return nil, errno_msg('failed to open ' .. DEV_NULL, err, eno) + end + return fd, nil +end + +local function make_pipe() + local rd, wr, err, eno = unistd.pipe() + if not rd then + return nil, nil, errno_msg('pipe() failed', err, eno) + end + return rd, wr, nil +end + +local function open_stream(role, fd) + if role == 'stdin' then + return file_io.fdopen(fd, fcntl.O_WRONLY) + else + return file_io.fdopen(fd, fcntl.O_RDONLY) + end +end + +---------------------------------------------------------------------- +-- Child exec path +---------------------------------------------------------------------- + +---@param spec table -- child-facing spec with *fd fields +---@param child_only table|nil +---@param parent_fds table|nil +---@param sentinel_w integer|nil +local function child_exec(spec, child_only, parent_fds, sentinel_w) + -- The real child must not keep the sentinel writer open across exec. + close_fd(sentinel_w) + + if spec.cwd then + local ok, err, eno = unistd.chdir(spec.cwd) + if not ok then + must_child(false, err, eno) + end + end + + if spec.flags and spec.flags.setsid then + local res, err, eno + if unistd.setsid then + res, err, eno = unistd.setsid() + elseif unistd.setpid then + res, err, eno = unistd.setpid('s', 0) + end + if res == nil then + must_child(false, err, eno) + end + end + + if spec.env then + apply_child_env(spec.env) + end + + setup_child_fd(spec.stdin_fd, 0) + setup_child_fd(spec.stdout_fd, 1) + setup_child_fd(spec.stderr_fd, 2) + + stdio.close_child_only(child_only, close_fd) + stdio.close_parent_fds(parent_fds, close_fd) + + local cmd, argt = build_argt(spec.argv) + unistd.execp(cmd, argt) + unistd._exit(127) +end + +---------------------------------------------------------------------- +-- Reaper process path +---------------------------------------------------------------------- + +local function wait_blocking(pid) + while true do + local rpid, how, value, err, eno = syswait.wait(pid) + if rpid ~= nil then + return rpid, how, value, nil, nil + end + if eno ~= errno.EINTR then + return nil, nil, nil, err, eno + end + end +end + +--- Run in the per-command reaper process. +---@param child_spec table +---@param child_only table|nil +---@param parent_fds table|nil +---@param sentinel_r integer +---@param sentinel_w integer +local function reaper_main(child_spec, child_only, parent_fds, sentinel_r, sentinel_w) + -- The reaper does not need parent pipe ends or the parent's sentinel read end. + stdio.close_parent_fds(parent_fds, close_fd) + close_fd(sentinel_r) + + local child_pid, err, eno = unistd.fork() + if not child_pid then + write_all(sentinel_w, 'failed ' .. errno_msg('fork child', err, eno) .. '\n') + close_fd(sentinel_w) + unistd._exit(127) + end + + if child_pid == 0 then + child_exec(child_spec, child_only, parent_fds, sentinel_w) + unistd._exit(127) + end + + -- In the reaper. + stdio.close_child_only(child_only, close_fd) + write_all(sentinel_w, ('pid %d\n'):format(child_pid)) + + local pid, how, what, werr, weno = wait_blocking(child_pid) + local line + if not pid then + line = 'failed ' .. errno_msg('wait child', werr, weno) .. '\n' + elseif how == 'exited' then + line = ('exited %d\n'):format(tonumber(what) or 0) + else + line = ('signaled %d\n'):format(tonumber(what) or 0) + end + + write_all(sentinel_w, line) + close_fd(sentinel_w) + unistd._exit(0) +end + +---------------------------------------------------------------------- +-- Parent-side reaper helpers +---------------------------------------------------------------------- + +---@class PosixReaperState +---@field reaper_pid integer +---@field pid integer|nil +---@field child_pid integer|nil +---@field sentinel integer|nil +---@field exited boolean +---@field code integer|nil +---@field signal integer|nil +---@field err string|nil +---@field _buf string|nil +---@field _have_status boolean|nil +---@field _reaper_reaped boolean|nil + +local function reap_reaper(state, blocking) + if state._reaper_reaped or not state.reaper_pid then + return + end + + while true do + local pid, how, _, err, eno + if blocking then + pid, how, _, err, eno = syswait.wait(state.reaper_pid) + else + pid, how, _, err, eno = syswait.wait(state.reaper_pid, syswait.WNOHANG) + end + + if pid == nil then + if eno == errno.EINTR then + -- Retry. + elseif eno == errno.ECHILD then + state._reaper_reaped = true + return + else + state.err = state.err or errno_msg('wait reaper', err, eno) + return + end + elseif pid == 0 or how == 'running' then + return + else + state._reaper_reaped = true + return + end + end +end + +local function read_sentinel(fd) + local chunk, err, eno = unistd.read(fd, 4096) + if chunk == nil then + if eno == errno.EAGAIN or eno == errno.EWOULDBLOCK or eno == errno.EINTR then + return nil, 'wait', nil + end + return nil, 'error', errno_msg('read sentinel', err, eno) + end + if #chunk == 0 then + return nil, 'eof', nil + end + return chunk, 'data', nil +end + +local reaper_ops = { + read_sentinel = read_sentinel, + close_fd = close_fd, + reap_reaper = reap_reaper, + kill_pid = nil, -- filled after send_signal helpers are defined + default_term = psig.SIGTERM or 15, +} + +---------------------------------------------------------------------- +-- exec_backend.core ops +---------------------------------------------------------------------- + +---@param spec ExecProcSpec +---@return PosixReaperState|nil state,{stdin:Stream|nil,stdout:Stream|nil,stderr:Stream|nil}|nil streams,string|nil err +local function spawn(spec) + assert(type(spec) == 'table', 'ExecBackend.spawn: spec must be a table') + assert(type(spec.argv) == 'table' and spec.argv[1], + 'ExecBackend.spawn: spec.argv must be a non-empty array') + + local child_spec, child_only, parent_fds, cfg_err = + stdio.build_child_stdio(spec, open_dev_null, make_pipe, set_cloexec, close_fd) + if not child_spec then + return nil, nil, cfg_err + end + + local sentinel_r, sentinel_w, serr = make_pipe() + if not sentinel_r then + stdio.close_child_only(child_only, close_fd) + stdio.close_parent_fds(parent_fds, close_fd) + return nil, nil, serr or 'pipe (sentinel) failed' + end + + set_cloexec(sentinel_r) + set_cloexec(sentinel_w) + + local reaper_pid, ferr, feno = unistd.fork() + if not reaper_pid then + stdio.close_child_only(child_only, close_fd) + stdio.close_parent_fds(parent_fds, close_fd) + close_fd(sentinel_r) + close_fd(sentinel_w) + return nil, nil, errno_msg('fork reaper', ferr, feno) + end + + if reaper_pid == 0 then + reaper_main(child_spec, child_only, parent_fds, sentinel_r, sentinel_w) + unistd._exit(127) + end + + -- Parent. + stdio.close_child_only(child_only, close_fd) + close_fd(sentinel_w) + + local state = reaper_common.new_state(reaper_pid, sentinel_r) + + -- Handshake: block only at spawn time until the reaper has reported the + -- real child pid or a terminal failure. This guarantees send_signal() + -- targets the exec child rather than the intermediate reaper. + while not state.child_pid and not state._have_status do + local chunk, rerr, reno = unistd.read(sentinel_r, 4096) + if chunk == nil then + close_fd(sentinel_r) + state.sentinel = nil + reap_reaper(state, false) + stdio.close_parent_fds(parent_fds, close_fd) + return nil, nil, errno_msg('sentinel handshake read', rerr, reno) + end + if #chunk == 0 then + close_fd(sentinel_r) + state.sentinel = nil + reap_reaper(state, false) + stdio.close_parent_fds(parent_fds, close_fd) + return nil, nil, 'sentinel closed during handshake' + end + + reaper_common.feed_status_chunk(state, chunk) + end + + local ok_nb, nb_err = set_nonblock(sentinel_r) + if not ok_nb then + close_fd(sentinel_r) + state.sentinel = nil + stdio.close_parent_fds(parent_fds, close_fd) + return nil, nil, nb_err + end + + local streams = stdio.build_parent_streams(parent_fds, open_stream) + return state, streams, nil +end + +local function poll_backend(state) + return reaper_common.poll_state(state, reaper_ops) +end + +local function register_wait(state, task, _, _) + return reaper_common.register_wait(state, task) +end + +local function kill_pid(pid, sig) + local rc, err, eno = psig.kill(pid, sig) + if rc == 0 then + return true, nil + end + if rc == nil and eno == errno.ESRCH then + return true, nil + end + return false, errno_msg('kill failed', err, eno) +end + +reaper_ops.kill_pid = kill_pid + +local function send_signal(state, sig) + return reaper_common.send_signal(state, sig, reaper_ops) +end + +local function terminate(state) + return send_signal(state, psig.SIGTERM or 15) +end + +local function kill_proc(state) + return send_signal(state, psig.SIGKILL or 9) +end + +local function close_state(state) + return reaper_common.close_state(state, reaper_ops) +end + +local function is_supported() + return type(unistd.fork) == 'function' + and type(unistd.execp) == 'function' + and type(unistd.pipe) == 'function' + and type(unistd.read) == 'function' + and type(unistd.write) == 'function' + and type(unistd._exit) == 'function' + and type(syswait.wait) == 'function' + and syswait.WNOHANG ~= nil + and type(psig.kill) == 'function' + and type(fcntl.open) == 'function' +end + +local ops = { + spawn = spawn, + poll = poll_backend, + register_wait = register_wait, + send_signal = send_signal, + terminate = terminate, + kill = kill_proc, + close = close_state, + is_supported = is_supported, +} + +return core.build_backend(ops) diff --git a/src/fibers/io/exec_backend/reaper_common.lua b/src/fibers/io/exec_backend/reaper_common.lua new file mode 100644 index 0000000..1ebe988 --- /dev/null +++ b/src/fibers/io/exec_backend/reaper_common.lua @@ -0,0 +1,232 @@ +-- fibers/io/exec_backend/reaper_common.lua +-- +-- Shared parent-side support for exec backends that use a per-command +-- reaper process and a sentinel pipe. Backend-specific modules still own +-- fork/exec, stdio setup and fd operations; this module owns only the common +-- sentinel protocol and parent-side state transitions. +-- +-- Sentinel protocol: +-- pid \n +-- exited \n +-- signaled \n +-- failed \n + +---@module 'fibers.io.exec_backend.reaper_common' + +local poller = require 'fibers.io.poller' +local runtime = require 'fibers.runtime' + +local M = {} + +---@param reaper_pid integer +---@param sentinel any +---@return table +function M.new_state(reaper_pid, sentinel) + return { + reaper_pid = reaper_pid, + pid = reaper_pid, -- updated to child pid by pid line + child_pid = nil, + sentinel = sentinel, + exited = false, + code = nil, + signal = nil, + err = nil, + _buf = '', + _have_status = false, + _reaper_reaped = false, + } +end + +---@param line string +---@param state table +function M.parse_status_line_into_state(line, state) + line = tostring(line or ''):gsub('\r', '') + local tag, rest = line:match('^(%S+)%s*(.*)$') + + if tag == 'pid' then + local cpid = tonumber(rest) + if cpid then + state.child_pid = cpid + state.pid = cpid + end + return + elseif tag == 'exited' then + state.code = tonumber(rest) or 0 + state.signal = nil + state.err = state.err or nil + state.exited = true + state._have_status = true + return + elseif tag == 'signaled' or tag == 'signalled' then + state.code = nil + state.signal = tonumber(rest) or 0 + state.err = state.err or nil + state.exited = true + state._have_status = true + return + elseif tag == 'failed' then + state.code = nil + state.signal = nil + state.err = rest ~= '' and rest or 'exec backend failed' + state.exited = true + state._have_status = true + return + end + + state.code = nil + state.signal = nil + state.err = 'unknown status line from reaper: ' .. tostring(line) + state.exited = true + state._have_status = true +end + +---@param state table +---@param chunk string +function M.feed_status_chunk(state, chunk) + if not chunk or chunk == '' then + return + end + + state._buf = (state._buf or '') .. chunk + + while true do + local line, rest = state._buf:match('^(.-)\n(.*)$') + if not line then + break + end + state._buf = rest + M.parse_status_line_into_state(line, state) + end +end + +local function close_sentinel(state, ops) + if state.sentinel ~= nil then + ops.close_fd(state.sentinel) + state.sentinel = nil + end +end + +---@param state table +---@param ops table backend operations +local function mark_sentinel_gone(state, ops, err) + close_sentinel(state, ops) + if not state._have_status then + state.exited = true + state.err = state.err or err or 'reaper sentinel closed' + end + ops.reap_reaper(state, true) +end + +---@param state table +---@param ops table backend operations +function M.drain_sentinel(state, ops) + local sentinel = state.sentinel + if not sentinel then + return + end + + while true do + local chunk, status, err = ops.read_sentinel(sentinel) + + if chunk ~= nil then + if #chunk == 0 then + mark_sentinel_gone(state, ops, 'reaper sentinel closed') + break + end + + M.feed_status_chunk(state, chunk) + + if state.exited then + close_sentinel(state, ops) + ops.reap_reaper(state, true) + break + end + elseif status == 'wait' then + break + elseif status == 'eof' then + mark_sentinel_gone(state, ops, 'reaper sentinel closed') + break + else + mark_sentinel_gone(state, ops, err or 'read sentinel failed') + break + end + end +end + +---@param state table +---@param ops table backend operations +---@return boolean done, integer|nil code, integer|nil signal, string|nil err +function M.poll_state(state, ops) + if state.exited then + return true, state.code, state.signal, state.err + end + + if not state.sentinel then + if not state._have_status then + state.exited = true + state.err = state.err or 'reaper sentinel closed' + end + ops.reap_reaper(state, true) + return true, state.code, state.signal, state.err + end + + M.drain_sentinel(state, ops) + + if state.exited then + return true, state.code, state.signal, state.err + end + return false, nil, nil, nil +end + +---@param state table +---@param task table +---@return table token +function M.register_wait(state, task) + if not state.sentinel then + local sched = runtime.current_scheduler + if sched and sched.schedule then + sched:schedule(task) + end + return { unlink = function () return false end } + end + + return poller.get():wait(state.sentinel, 'rd', task) +end + +---@param state table +---@param sig integer|nil +---@param ops table backend operations +---@return boolean ok, string|nil err +function M.send_signal(state, sig, ops) + sig = sig or ops.default_term or 15 + + if state.exited then + return true, nil + end + + M.poll_state(state, ops) + if state.exited then + return true, nil + end + + local target = state.child_pid or state.pid or state.reaper_pid + if not target then + return false, 'no child or reaper pid available' + end + + return ops.kill_pid(target, sig) +end + +---@param state table +---@param ops table backend operations +---@return boolean ok, string|nil err +function M.close_state(state, ops) + close_sentinel(state, ops) + -- Terminal commands should reap the intermediate reaper synchronously so + -- completed commands do not accumulate as zombies. If close() is used on a + -- still-running backend, keep non-blocking behaviour. + ops.reap_reaper(state, state.exited or state._have_status) + return true, nil +end + +return M diff --git a/src/fibers/io/exec_backend/sigchld.lua b/src/fibers/io/exec_backend/sigchld.lua new file mode 100644 index 0000000..8325043 --- /dev/null +++ b/src/fibers/io/exec_backend/sigchld.lua @@ -0,0 +1,511 @@ +-- fibers/io/exec_backend/sigchld.lua +-- +-- SIGCHLD + self-pipe process backend (luaposix only). +-- +-- Portable POSIX backend for systems without pidfd_open. +-- Uses: +-- - SIGCHLD handler that writes to a non-blocking self-pipe +-- - poller watching the pipe +-- - wait(WNOHANG) per tracked child PID +-- - a Waitset to wake fibers blocked in wait_op(). +-- +---@module 'fibers.io.exec_backend.sigchld' + +local core = require 'fibers.io.exec_backend.core' +local unistd = require 'posix.unistd' +local syswait = require 'posix.sys.wait' +local psignal = require 'posix.signal' +local fcntl = require 'posix.fcntl' +local errno = require 'posix.errno' +local stdlib = require 'posix.stdlib' + +local poller = require 'fibers.io.poller' +local waitmod = require 'fibers.wait' +local runtime = require 'fibers.runtime' +local file_io = require 'fibers.io.file' +local stdio = require 'fibers.io.exec_backend.stdio' + +local bit = rawget(_G, 'bit') or require 'bit32' +local jit = rawget(_G, "jit") + +local DEV_NULL = '/dev/null' + +---------------------------------------------------------------------- +-- Global state for SIGCHLD handling +---------------------------------------------------------------------- + +--- All children we are responsible for: pid -> SigchldState +local children = {} + +--- Waiters keyed by pid: Waitset from fibers.wait. +local waiters = waitmod.new_waitset() + +--- Scheduler used to reschedule waiters; populated when the reaper starts. +---@type Scheduler|nil +local child_sched + +--- Self-pipe file descriptors. +---@type integer|nil +local sig_r +---@type integer|nil +local sig_w + +--- Reaper task flag. +local reaper_started = false + +local function errno_msg(prefix, err, eno) + if err and err ~= '' then + return err + end + if eno then + return ('%s (errno %d)'):format(prefix, eno) + end + return prefix +end + +---------------------------------------------------------------------- +-- Small helpers +---------------------------------------------------------------------- + +local function set_nonblock(fd) + local flags, err, eno = fcntl.fcntl(fd, fcntl.F_GETFL) + if flags == nil then + return nil, errno_msg('fcntl(F_GETFL)', err, eno) + end + local newflags = bit.bor(flags, fcntl.O_NONBLOCK) + local ok, err2, eno2 = fcntl.fcntl(fd, fcntl.F_SETFL, newflags) + if ok == nil then + return nil, errno_msg('fcntl(F_SETFL)', err2, eno2) + end + return true +end + +local function set_cloexec(fd) + local flags, err, eno = fcntl.fcntl(fd, fcntl.F_GETFD) + if flags == nil then + return nil, errno_msg('fcntl(F_GETFD)', err, eno) + end + local newflags = bit.bor(flags, fcntl.FD_CLOEXEC or 0) + local ok, err2, eno2 = fcntl.fcntl(fd, fcntl.F_SETFD, newflags) + if ok == nil then + return nil, errno_msg('fcntl(F_SETFD)', err2, eno2) + end + return true +end + +local function must_child(ok, _, _) + if not ok or ok == 0 then + unistd._exit(127) + end +end + +local function build_argt(argv) + local cmd = assert(argv[1], 'ProcSpec.argv[1] must be executable') + local argt = {} + argt[0] = cmd + for i = 2, #argv do + argt[i - 1] = argv[i] + end + return cmd, argt +end + +local function setup_child_fd(src_fd, dest_fd) + if src_fd == nil or src_fd == dest_fd then + return + end + local newfd, err, eno = unistd.dup2(src_fd, dest_fd) + if not newfd then + must_child(false, err, eno) + end +end + +local function apply_child_env(env) + for name, value in pairs(env) do + local ok, err, eno = stdlib.setenv(name, value and tostring(value) or nil) + if ok == nil then + must_child(false, err, eno) + end + end +end + +---------------------------------------------------------------------- +-- SIGCHLD self-pipe and reaper +---------------------------------------------------------------------- + +local function install_self_pipe_and_handler() + if sig_r ~= nil then + return + end + + local r, w, err, eno = unistd.pipe() + if not r then + error('exec_backend.sigchld: pipe() failed: ' .. errno_msg('pipe', err, eno)) + end + + local ok1, e1 = set_nonblock(r) + local ok2, e2 = set_nonblock(w) + local ok3, e3 = set_cloexec(r) + local ok4, e4 = set_cloexec(w) + if not (ok1 and ok2 and ok3 and ok4) then + if r then unistd.close(r) end + if w then unistd.close(w) end + error('exec_backend.sigchld: failed to configure self-pipe: ' + .. tostring(e1 or e2 or e3 or e4)) + end + + sig_r, sig_w = r, w + + local function handler() + -- LuaJIT can be unstable when a signal handler calls back into + -- luaposix while another luaposix C function, such as poll(), is + -- active. Under LuaJIT, rely on poll() being interrupted with + -- EINTR; the posix poller wakes all waiters on EINTR so wait_op() + -- can reap with waitpid(WNOHANG). Under PUC Lua, keep the + -- traditional self-pipe write. + if not jit then + unistd.write(sig_w, 'x') + end + end + + if jit and jit.off then + jit.off(handler, true) + end + + local flags = jit and nil or psignal.SA_RESTART + local old, serr, seno + if flags ~= nil then + old, serr, seno = psignal.signal(psignal.SIGCHLD, handler, flags) + else + old, serr, seno = psignal.signal(psignal.SIGCHLD, handler) + end + if not old and serr then + error('exec_backend.sigchld: signal(SIGCHLD) failed: ' + .. errno_msg('signal', serr, seno)) + end +end + +---------------------------------------------------------------------- +-- Backend state helpers +---------------------------------------------------------------------- + +---@class SigchldState +---@field pid integer +---@field exited boolean +---@field status integer|nil +---@field code integer|nil +---@field signal integer|nil +---@field err string|nil + +local function finalise_state(st, status, code, signal, err) + if st.exited then + return + end + st.exited = true + st.status = status + st.code = code + st.signal = signal + st.err = err +end + +local function poll_state(st) + if st.exited then + return true, st.code, st.signal, st.err + end + return false, nil, nil, nil +end + +local function drain_self_pipe() + if not sig_r then return end + + while true do + local s, _, eno = unistd.read(sig_r, 4096) + if s == nil then + if eno == errno.EAGAIN or eno == errno.EWOULDBLOCK then + break + end + break + end + if #s < 4096 then + break + end + end +end + +local function reap_known_children() + local to_remove = {} + + for pid, st in pairs(children) do + if st and not st.exited then + local rpid, how, v3, err, eno = syswait.wait(pid, syswait.WNOHANG) + if rpid == nil then + if eno == errno.ECHILD then + finalise_state(st, nil, nil, nil, nil) + to_remove[#to_remove + 1] = pid + elseif eno ~= errno.EINTR then + finalise_state(st, nil, nil, nil, errno_msg('wait failed', err, eno)) + to_remove[#to_remove + 1] = pid + end + elseif how ~= 'running' then + if how == 'exited' then + local code = v3 + finalise_state(st, v3, code, nil, nil) + else + local sig = v3 + finalise_state(st, v3, nil, sig, nil) + end + to_remove[#to_remove + 1] = pid + end + end + end + + if child_sched then + for i = 1, #to_remove do + local pid = to_remove[i] + children[pid] = nil + waiters:notify_all(pid, child_sched) + end + else + for i = 1, #to_remove do + children[to_remove[i]] = nil + end + end +end + +---@class ReaperTask : Task +local ReaperTask = {} +ReaperTask.__index = ReaperTask + +function ReaperTask:run() + if not sig_r or not child_sched then + return + end + + self.armed = false + drain_self_pipe() + reap_known_children() + + if sig_r then + self.armed = true + poller.get():wait(sig_r, 'rd', self) + end +end + +local function start_reaper() + if reaper_started then + return + end + + install_self_pipe_and_handler() + + reaper_started = true + child_sched = runtime.current_scheduler + + local task = setmetatable({}, ReaperTask) + if not task.armed then + task.armed = true + poller.get():wait(sig_r, 'rd', task) + end +end + +---------------------------------------------------------------------- +-- Child side exec +---------------------------------------------------------------------- + +---@param spec table -- child-facing spec with *fd fields +local function child_exec(spec) + if spec.cwd then + local ok, err, eno = unistd.chdir(spec.cwd) + if not ok then + must_child(false, err, eno) + end + end + + if spec.flags and spec.flags.setsid then + local res, err, eno + if unistd.setsid then + res, err, eno = unistd.setsid() + elseif unistd.setpid then + res, err, eno = unistd.setpid('s', 0) + end + if res == nil then + must_child(false, err, eno) + end + end + + if spec.env then + apply_child_env(spec.env) + end + + setup_child_fd(spec.stdin_fd, 0) + setup_child_fd(spec.stdout_fd, 1) + setup_child_fd(spec.stderr_fd, 2) + + do + local seen = {} + for _, fd in ipairs { spec.stdin_fd, spec.stdout_fd, spec.stderr_fd } do + if fd and fd > 2 and not seen[fd] then + seen[fd] = true + unistd.close(fd) + end + end + end + + local cmd, argt = build_argt(spec.argv) + unistd.execp(cmd, argt) + unistd._exit(127) +end + +---------------------------------------------------------------------- +-- Stream / stdio integration via exec_stdio +---------------------------------------------------------------------- + +local function open_dev_null(is_output) + local flags = is_output and fcntl.O_WRONLY or fcntl.O_RDONLY + local fd, err, eno = fcntl.open(DEV_NULL, flags, 0) + if not fd then + return nil, errno_msg('failed to open ' .. DEV_NULL, err, eno) + end + return fd, nil +end + +local function make_pipe() + local rd, wr, err, eno = unistd.pipe() + if not rd then + return nil, nil, errno_msg('pipe() failed', err, eno) + end + return rd, wr, nil +end + +local function close_fd(fd) + unistd.close(fd) +end + +local function open_stream(role, fd) + if role == 'stdin' then + return file_io.fdopen(fd, fcntl.O_WRONLY) + else + return file_io.fdopen(fd, fcntl.O_RDONLY) + end +end + +---------------------------------------------------------------------- +-- Backend ops for exec_backend.core +---------------------------------------------------------------------- + +--- spawn(spec) -> state, streams, err +---@param spec ExecProcSpec +---@return SigchldState|nil state, {stdin:Stream|nil, stdout:Stream|nil, stderr:Stream|nil}|nil streams, string|nil err +local function spawn(spec) + assert(type(spec) == 'table', 'ExecBackend.spawn: spec must be a table') + assert(type(spec.argv) == 'table' and spec.argv[1], + 'ExecBackend.spawn: spec.argv must be a non-empty array') + + install_self_pipe_and_handler() + start_reaper() + + -- Common stdio wiring. + local child_spec, child_only, parent_fds, cfg_err = + stdio.build_child_stdio(spec, open_dev_null, make_pipe, set_cloexec, close_fd) + if not child_spec then + return nil, nil, cfg_err + end + + local pid, err, eno = unistd.fork() + if not pid then + stdio.close_child_only(child_only, close_fd) + stdio.close_parent_fds(parent_fds, close_fd) + return nil, nil, errno_msg('fork failed', err, eno) + end + + if pid == 0 then + child_exec(child_spec) + end + + -- Parent: child-only fds no longer needed. + stdio.close_child_only(child_only, close_fd) + + local state = { + pid = pid, + exited = false, + status = nil, + code = nil, + signal = nil, + err = nil, + } + + children[pid] = state + + local streams = stdio.build_parent_streams(parent_fds, open_stream) + + return state, streams, nil +end + +local function poll(state) + -- Fast path: if the child is already marked as exited, just report it. + if state.exited then + return true, state.code, state.signal, state.err + end + -- Ensure progress even when no scheduler is running + reap_known_children() + return poll_state(state) +end + + +local function register_wait(state, task, _, _) + start_reaper() + return waiters:add(state.pid, task) +end + +local function send_signal(state, sig) + sig = sig or psignal.SIGTERM + local rc, err, eno = psignal.kill(state.pid, sig) + if rc == 0 then + return true, nil + end + if rc == nil and eno == errno.ESRCH then + return true, nil + end + return false, errno_msg('kill failed', err, eno) +end + +local function terminate(state) + return send_signal(state, psignal.SIGTERM) +end + +local function kill_proc(state) + return send_signal(state, psignal.SIGKILL) +end + +local function close_state(state) + waiters:clear_key(state.pid) + return true, nil +end + +local function is_supported() + -- LuaJIT + luaposix signal callbacks are not reliable in exec stress paths. + -- The selector falls through to exec_backend.posix_reaper for that runtime, + -- preserving evented completion via sentinel pipes without a Lua SIGCHLD + -- handler. Keep this SIGCHLD self-pipe backend for ordinary Lua. + if rawget(_G, 'jit') then + return false + end + return psignal.SIGCHLD ~= nil + and type(unistd.fork) == 'function' + and type(unistd.execp) == 'function' + and type(unistd.pipe) == 'function' + and type(syswait.wait) == 'function' + and type(psignal.kill) == 'function' +end + +local ops = { + spawn = spawn, + poll = poll, + register_wait = register_wait, + send_signal = send_signal, + terminate = terminate, + kill = kill_proc, + close = close_state, + is_supported = is_supported, +} + +return core.build_backend(ops) diff --git a/src/fibers/io/exec_backend/stdio.lua b/src/fibers/io/exec_backend/stdio.lua new file mode 100644 index 0000000..7e22c70 --- /dev/null +++ b/src/fibers/io/exec_backend/stdio.lua @@ -0,0 +1,233 @@ +-- fibers/io/exec_backend/stdio.lua +-- +-- Shared stdio wiring for exec backends. +-- +-- Takes ExecStreamConfig values (as constructed by fibers.exec) +-- and turns them into: +-- * child_spec.{stdin_fd, stdout_fd, stderr_fd} +-- * child_only : set of fds only used in the child +-- * parent_fds : { stdin = fd?, stdout = fd?, stderr = fd? } for pipes +-- +---@module 'fibers.io.exec_backend.stdio' + +local M = {} + +---@param s any +---@return integer|nil fd, string|nil err +local function stream_fileno(s) + if type(s) ~= 'table' then + return nil, 'stream is not a table' + end + local io_backend = s.io + if type(io_backend) ~= 'table' or type(io_backend.fileno) ~= 'function' then + return nil, 'stream backend does not support fileno()' + end + return io_backend:fileno() +end + +--- Build child stdio fd mapping and parent pipe ends from a high-level spec. +--- +--- Callbacks: +--- open_dev_null(is_output:boolean) -> fd|nil, err|nil +--- make_pipe() -> rd_fd|nil, wr_fd|nil, err|nil +--- set_cloexec(fd) -> ok:boolean, err|nil +--- close_fd(fd) -> () +--- +---@param spec ExecProcSpec +---@param open_dev_null fun(is_output: boolean): integer|nil, string|nil +---@param make_pipe fun(): integer|nil, integer|nil, string|nil +---@param set_cloexec fun(fd: integer): boolean, string|nil +---@param close_fd fun(fd: integer) +---@return table|nil child_spec, table|nil child_only, table|nil parent_fds, string|nil err +function M.build_child_stdio(spec, open_dev_null, make_pipe, set_cloexec, close_fd) + assert(type(spec) == 'table', 'build_child_stdio: spec must be a table') + assert(type(open_dev_null) == 'function', 'build_child_stdio: open_dev_null must be a function') + assert(type(make_pipe) == 'function', 'build_child_stdio: make_pipe must be a function') + assert(type(set_cloexec) == 'function', 'build_child_stdio: set_cloexec must be a function') + assert(type(close_fd) == 'function', 'build_child_stdio: close_fd must be a function') + + local child_only = {} -- [fd] = true (used only in child) + local parent_fds = {} -- stdin/stdout/stderr -> parent end for pipes + + local child_spec = { + argv = spec.argv, + cwd = spec.cwd, + env = spec.env, + flags = spec.flags, + stdin_fd = nil, + stdout_fd = nil, + stderr_fd = nil, + } + + local function fail(msg) + for fd in pairs(child_only) do + close_fd(fd) + end + for _, fd in pairs(parent_fds) do + if fd then + close_fd(fd) + end + end + return nil, nil, nil, msg + end + + --- Configure a single stdio stream. + --- kind: "stdin" | "stdout" | "stderr" + --- cfg : ExecStreamConfig|nil + local function configure_stream(kind, cfg) + -- Allow callers to omit stdin/stdout/stderr in the spec; treat as inherit. + cfg = cfg or { mode = 'inherit' } + + local is_output = (kind ~= 'stdin') + local field = kind .. '_fd' + local mode = cfg.mode or 'inherit' + + -- inherit + if mode == 'inherit' then + child_spec[field] = nil + return true + + -- /dev/null + elseif mode == 'null' then + local fd, err = open_dev_null(is_output) + if not fd then + return false, err + end + child_only[fd] = true + child_spec[field] = fd + return true + + -- pipe (child ↔ parent) + elseif mode == 'pipe' then + local rd, wr, err = make_pipe() + if not (rd and wr) then + return false, err + end + + local child_fd, parent_fd + if is_output then + -- child writes, parent reads + child_fd, parent_fd = wr, rd + else + -- child reads, parent writes + child_fd, parent_fd = rd, wr + end + + child_only[child_fd] = true + child_spec[field] = child_fd + parent_fds[kind] = parent_fd + + local ok, cerr = set_cloexec(parent_fd) + if not ok then + return false, cerr or ('set_cloexec(' .. kind .. ' parent fd) failed') + end + return true + + -- user-supplied stream: just borrow the underlying fd + elseif mode == 'stream' then + local fd, err = stream_fileno(cfg.stream) + if not fd then + return false, err + end + child_spec[field] = fd + return true + + -- stderr = "stdout" + elseif kind == 'stderr' and mode == 'stdout' then + -- stdout config may itself be missing; default to inherit in that case. + local out_cfg = spec.stdout or { mode = 'inherit' } + local out_mode = out_cfg.mode or 'inherit' + + if out_mode == 'inherit' and child_spec.stdout_fd == nil then + -- Share inherited stdout (fd 1). + child_spec.stderr_fd = 1 + elseif child_spec.stdout_fd ~= nil then + -- Share whatever stdout was configured to use. + child_spec.stderr_fd = child_spec.stdout_fd + else + return false, "stderr='stdout' but stdout not configured" + end + return true + + else + return false, 'invalid ' .. kind .. ' mode: ' .. tostring(mode) + end + end + + + do + local ok, err = configure_stream('stdin', spec.stdin) + if not ok then + return fail(err) + end + end + + do + local ok, err = configure_stream('stdout', spec.stdout) + if not ok then + return fail(err) + end + end + + do + local ok, err = configure_stream('stderr', spec.stderr) + if not ok then + return fail(err) + end + end + + return child_spec, child_only, parent_fds, nil +end + +--- Best-effort close of fds that are only needed in the child. +---@param child_only table|nil +---@param close_fd fun(fd: integer) +function M.close_child_only(child_only, close_fd) + if not child_only then return end + for fd in pairs(child_only) do + close_fd(fd) + end +end + +--- Best-effort close of parent pipe ends (used on error paths). +---@param parent_fds table|nil +---@param close_fd fun(fd: integer) +function M.close_parent_fds(parent_fds, close_fd) + if not parent_fds then return end + for _, fd in pairs(parent_fds) do + if fd then + close_fd(fd) + end + end +end + +--- Map parent pipe fds back into Streams, given an open_stream callback. +--- +--- open_stream(role, fd) -> Stream +--- +---@param parent_fds table|nil +---@param open_stream fun(role: '"stdin"'|'"stdout"'|'"stderr"', fd: integer): Stream +---@return { stdin: Stream|nil, stdout: Stream|nil, stderr: Stream|nil } +function M.build_parent_streams(parent_fds, open_stream) + parent_fds = parent_fds or {} + + local stdin, stdout, stderr + + if parent_fds.stdin then + stdin = open_stream('stdin', parent_fds.stdin) + end + if parent_fds.stdout then + stdout = open_stream('stdout', parent_fds.stdout) + end + if parent_fds.stderr then + stderr = open_stream('stderr', parent_fds.stderr) + end + + return { + stdin = stdin, + stdout = stdout, + stderr = stderr, + } +end + +return M diff --git a/src/fibers/io/fd_backend.lua b/src/fibers/io/fd_backend.lua new file mode 100644 index 0000000..d7c34c7 --- /dev/null +++ b/src/fibers/io/fd_backend.lua @@ -0,0 +1,23 @@ +-- fibers/io/fd_backend.lua +-- +-- FD-backed backend shim. +-- Chooses the best available implementation: +-- 1. FFI-based (no luaposix dependency), +-- 2. luaposix-based (no FFI dependency). +-- +---@module 'fibers.io.fd_backend' + +local candidates = { + 'fibers.io.fd_backend.ffi', -- FFI / libc + 'fibers.io.fd_backend.posix', -- luaposix + 'fibers.io.fd_backend.nixio', -- nixio +} + +for _, name in ipairs(candidates) do + local ok, mod = pcall(require, name) + if ok and type(mod) == 'table' and mod.is_supported and mod.is_supported() then + return mod + end +end + +error('fibers.io.fd_backend: no suitable fd backend available on this platform') diff --git a/src/fibers/io/fd_backend/core.lua b/src/fibers/io/fd_backend/core.lua new file mode 100644 index 0000000..b908ffc --- /dev/null +++ b/src/fibers/io/fd_backend/core.lua @@ -0,0 +1,304 @@ +-- fibers/io/fd_backend/core.lua + +local poller = require 'fibers.io.poller' + +---@class FdBackend +---@field filename string|nil +---@field _fd integer|nil +---@field _ops table +local FdBackend = {} +FdBackend.__index = FdBackend + +function FdBackend:kind() + return 'fd' +end + +function FdBackend:fileno() + return self._fd +end + +function FdBackend:read_string(max) + if not self._fd then + return nil, 'closed' + end + + max = max or 4096 + if max <= 0 then + return '', nil + end + + return self._ops.read(self._fd, max) +end + +function FdBackend:write_string(str) + if not self._fd then + return nil, 'closed' + end + + local len = #str + if len == 0 then + return 0, nil + end + + return self._ops.write(self._fd, str, len) +end + +function FdBackend:seek(whence, off) + if not self._fd then + return nil, 'closed' + end + return self._ops.seek(self._fd, whence, off) +end + +function FdBackend:on_readable(task) + return poller.get():wait(assert(self._fd, 'closed fd'), 'rd', task) +end + +function FdBackend:on_writable(task) + return poller.get():wait(assert(self._fd, 'closed fd'), 'wr', task) +end + +function FdBackend:close() + if self._fd == nil then + return true, nil + end + + local fd = self._fd + self._fd = nil + + return self._ops.close(fd) +end + +---------------------------------------------------------------------- +-- Backend builder +---------------------------------------------------------------------- + +--- Build a concrete fd backend module from low-level ops. +--- +--- Required ops: +--- mkdir(path[, perms]) -> ok:boolean, err|nil +--- set_nonblock(fd) -> ok:boolean, err|nil +--- read(fd, max) -> s|nil, err|nil, want? +--- write(fd, s, len)-> n|nil, err|nil, want? +--- seek(fd, whence, off) -> pos|nil, err|nil +--- close(fd) -> ok:boolean, err|nil +--- +--- Optional file ops (used by fibers.io.file): +--- open_file(path, mode, perms) -> fd|nil, err|nil +--- pipe() -> rd_fd|nil, wr_fd|nil, err|nil +--- mktemp(prefix, perms) -> fd|nil, tmpname_or_err +--- fsync(fd) -> ok:boolean, err|nil +--- rename(old, new) -> ok:boolean, err|nil +--- unlink(path) -> ok:boolean, err|nil +--- decode_access(flags) -> readable:boolean, writable:boolean +--- ignore_sigpipe() -> ok:boolean, err|nil +--- +--- Optional socket ops (used by fibers.io.socket): +--- socket(domain, stype, protocol) -> fd|nil, err|nil +--- bind(fd, sa) -> ok:boolean, err|nil +--- listen(fd) -> ok:boolean, err|nil +--- accept(fd) -> newfd|nil, err|nil, again:boolean +--- connect_start(fd, sa) -> ok:boolean|nil, err|nil, inprogress:boolean +--- connect_finish(fd) -> ok:boolean, err|nil +--- +--- Optional metadata: +--- modes : table +--- permissions : table +--- AF_UNIX : integer +--- SOCK_STREAM : integer +--- is_supported() -> boolean +--- +---@param ops table +---@return table backend_module +local function build_backend(ops) + local required = { 'set_nonblock', 'read', 'write', 'seek', 'close' } + for _, k in ipairs(required) do + assert(type(ops[k]) == 'function', + 'fd_backend ops.' .. k .. ' must be a function') + end + + local function new(fd, opts) + opts = opts or {} + + if fd ~= nil then + local ok, err = ops.set_nonblock(fd) + if not ok then + error('fd_backend: set_nonblock(' .. tostring(fd) .. ') failed: ' + .. tostring(err)) + end + end + + local self = { + _fd = fd, + _ops = ops, + filename = opts.filename, + } + return setmetatable(self, FdBackend) + end + + local function is_supported() + if type(ops.is_supported) == 'function' then + return not not ops.is_supported() + end + return true + end + + -------------------------------------------------------------------- + -- File-level helpers + -------------------------------------------------------------------- + + local function mkdir(path, perms) + assert(type(ops.mkdir) == 'function', + 'fd_backend backend does not implement mkdir') + return ops.mkdir(path, perms) + end + + local function open_file(path, mode, perms) + assert(type(ops.open_file) == 'function', + 'fd_backend backend does not implement open_file') + return ops.open_file(path, mode, perms) + end + + local function pipe() + assert(type(ops.pipe) == 'function', + 'fd_backend backend does not implement pipe') + return ops.pipe() + end + + local function mktemp(prefix, perms) + assert(type(ops.mktemp) == 'function', + 'fd_backend backend does not implement mktemp') + return ops.mktemp(prefix, perms) + end + + local function fsync(fd) + if not ops.fsync then + return true, nil + end + return ops.fsync(fd) + end + + local function rename(oldpath, newpath) + assert(type(ops.rename) == 'function', + 'fd_backend backend does not implement rename') + return ops.rename(oldpath, newpath) + end + + local function unlink(path) + assert(type(ops.unlink) == 'function', + 'fd_backend backend does not implement unlink') + return ops.unlink(path) + end + + local function decode_access(flags) + if not ops.decode_access then + error('fd_backend backend does not implement decode_access') + end + return ops.decode_access(flags) + end + + local function ignore_sigpipe() + if ops.ignore_sigpipe then + return ops.ignore_sigpipe() + end + return true, nil + end + + local function init_nonblocking(fd) + return ops.set_nonblock(fd) + end + + local function close_fd(fd) + return ops.close(fd) + end + + -------------------------------------------------------------------- + -- Socket-level helpers (optional) + -------------------------------------------------------------------- + + local function socket(domain, stype, protocol) + if not ops.socket then + error('fd_backend backend does not implement socket()') + end + return ops.socket(domain, stype, protocol or 0) + end + + local function bind(fd, sa) + if not ops.bind then + error('fd_backend backend does not implement bind()') + end + return ops.bind(fd, sa) + end + + local function listen(fd) + if not ops.listen then + error('fd_backend backend does not implement listen()') + end + return ops.listen(fd) + end + + --- accept(fd) -> newfd|nil, err|nil, again:boolean + local function accept(fd) + if not ops.accept then + error('fd_backend backend does not implement accept()') + end + local newfd, err, again = ops.accept(fd) + return newfd, err, again + end + + --- connect_start(fd, sa) -> ok|nil, err|nil, inprogress:boolean + local function connect_start(fd, sa) + if not ops.connect_start then + error('fd_backend backend does not implement connect_start()') + end + local ok, err, inprogress = ops.connect_start(fd, sa) + return ok, err, inprogress + end + + --- connect_finish(fd) -> ok:boolean, err|nil + local function connect_finish(fd) + if not ops.connect_finish then + error('fd_backend backend does not implement connect_finish()') + end + return ops.connect_finish(fd) + end + + return { + new = new, + is_supported = is_supported, + + -- low-level helper + set_nonblock = init_nonblocking, + close_fd = close_fd, + + -- file-level helpers + mkdir = mkdir, + open_file = open_file, + pipe = pipe, + mktemp = mktemp, + fsync = fsync, + rename = rename, + unlink = unlink, + decode_access = decode_access, + ignore_sigpipe = ignore_sigpipe, + + -- socket-level helpers + socket = socket, + bind = bind, + listen = listen, + accept = accept, + connect_start = connect_start, + connect_finish = connect_finish, + + -- metadata (if provided) + modes = ops.modes or {}, + permissions = ops.permissions or {}, + AF_UNIX = ops.AF_UNIX, + AF_INET = ops.AF_INET, + SOCK_STREAM = ops.SOCK_STREAM, + } +end + +return { + build_backend = build_backend, +} diff --git a/src/fibers/io/fd_backend/ffi.lua b/src/fibers/io/fd_backend/ffi.lua new file mode 100644 index 0000000..546e652 --- /dev/null +++ b/src/fibers/io/fd_backend/ffi.lua @@ -0,0 +1,631 @@ +-- fibers/io/fd_backend/ffi.lua +-- +-- FFI-based FD backend (no luaposix / syscall dependency). +-- Intended to be selected via fibers.io.fd_backend. +-- +---@module 'fibers.io.fd_backend.ffi' + +local core = require 'fibers.io.fd_backend.core' +local ffi_c = require 'fibers.utils.ffi_compat' + +if not ffi_c.is_supported() then + return { is_supported = function () return false end } +end + +local ffi = ffi_c.ffi +local C = ffi_c.C +local toint = ffi_c.tonumber +local get_errno = ffi_c.errno + +local ok_bit, bit_mod = pcall(function () + return rawget(_G, 'bit') or require 'bit32' +end) +if not ok_bit or not bit_mod then + return { is_supported = function () return false end } +end +local bit = bit_mod + +---@class sockaddr_un_cdata +---@field sun_family integer +---@field sun_path string|integer[] + +ffi.cdef [[ + typedef long ssize_t; + typedef long off_t; + + ssize_t read(int fd, void *buf, size_t count); + ssize_t write(int fd, const void *buf, size_t count); + off_t lseek(int fd, off_t offset, int whence); + int close(int fd); + int fcntl(int fd, int cmd, ...); + char *strerror(int errnum); + + int open(const char *pathname, int flags, int mode); + int pipe(int pipefd[2]); + int fsync(int fd); + int rename(const char *oldpath, const char *newpath); + int unlink(const char *pathname); + int mkdir(const char *pathname, int mode); + + typedef void (*sighandler_t)(int); + sighandler_t signal(int signum, sighandler_t handler); + + /* socket API */ + typedef unsigned short sa_family_t; + typedef unsigned int socklen_t; + + struct sockaddr { + sa_family_t sa_family; + char sa_data[14]; + }; + + struct sockaddr_un { + sa_family_t sun_family; + char sun_path[108]; + }; + + int socket(int domain, int type, int protocol); + int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); + int listen(int sockfd, int backlog); + int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); + int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen); + int getsockopt(int sockfd, int level, int optname, void *optval, socklen_t *optlen); + + typedef unsigned short in_port_t; + typedef unsigned int in_addr_t; + + struct in_addr { + in_addr_t s_addr; + }; + + struct sockaddr_in { + sa_family_t sin_family; + in_port_t sin_port; + struct in_addr sin_addr; + unsigned char sin_zero[8]; + }; + + unsigned short htons(unsigned short hostshort); + int inet_pton(int af, const char *src, void *dst); +]] + +-- POSIX fcntl command numbers on Linux. +local F_GETFL = 3 +local F_SETFL = 4 + +-- Linux O_* constants (values as on glibc/Linux; adjust if you support other ABIs). +local O_RDONLY = 0x0000 +local O_WRONLY = 0x0001 +local O_RDWR = 0x0002 +local O_ACCMODE = 0x0003 +local O_CREAT = 0x0040 +local O_EXCL = 0x0080 +local O_TRUNC = 0x0200 +local O_APPEND = 0x0400 +local O_NONBLOCK = 0x00000800 + +-- Permission bits (standard POSIX values). +local S_IRUSR = 0x0100 +local S_IWUSR = 0x0080 +local S_IRGRP = 0x0020 +local S_IROTH = 0x0004 +local S_IWGRP = 0x0010 +local S_IWOTH = 0x0002 +local S_IXUSR = 0x0040 +local S_IXGRP = 0x0008 +local S_IXOTH = 0x0001 + +-- Errno values (Linux). +local EAGAIN = 11 +local EWOULDBLOCK = 11 +local EINPROGRESS = 115 + +-- Socket constants (Linux ABI). +local AF_UNIX = 1 +local AF_INET = 2 +local SOCK_STREAM = 1 +local SOL_SOCKET = 1 +local SO_ERROR = 4 +local SOMAXCONN = 128 + +local SIGPIPE = 13 + +local function strerror(e) + local s = C.strerror(e) + if s == nil then + return 'errno ' .. tostring(e) + end + return ffi.string(s) +end + +---------------------------------------------------------------------- +-- fcntl helpers (casted to avoid varargs issues) +---------------------------------------------------------------------- + +local getfl_fp = ffi.cast('int (*)(int, int)', C.fcntl) +local setfl_fp = ffi.cast('int (*)(int, int, int)', C.fcntl) + +local function set_nonblock(fd) + local before = assert(toint(getfl_fp(fd, F_GETFL))) + if before < 0 then + local e = get_errno() + return false, ('F_GETFL failed: %s'):format(strerror(e)), e + end + + local new_flags = bit.bor(before, O_NONBLOCK) + local rc = toint(setfl_fp(fd, F_SETFL, new_flags)) + if rc < 0 then + local e = get_errno() + return false, ('F_SETFL failed: %s'):format(strerror(e)), e + end + + -- Optional sanity check. + local after = assert(toint(getfl_fp(fd, F_GETFL))) + if after < 0 then + local e = get_errno() + return false, ('F_GETFL (post) failed: %s'):format(strerror(e)), e + end + + if bit.band(after, O_NONBLOCK) == 0 then + return false, + ('set_nonblock: O_NONBLOCK not set after F_SETFL; before=0x%x after=0x%x') + :format(before, after), + nil + end + + return true, nil, nil +end + +---------------------------------------------------------------------- +-- Low-level ops implementing the core contract +---------------------------------------------------------------------- + +local SEEK = { set = 0, cur = 1, ['end'] = 2 } + +local function read_fd(fd, max) + local buf = ffi.new('char[?]', max) + local n = toint(C.read(fd, buf, max)) + + if n < 0 then + local e = get_errno() + if e == EAGAIN or e == EWOULDBLOCK then + return nil, nil, 'rd' -- would block + end + return nil, strerror(e) + end + + if n == 0 then + return '', nil -- EOF + end + + if n > max then + return nil, 'read returned ' .. tostring(n) .. ' bytes (max ' .. tostring(max) .. ')' + end + + return ffi.string(buf, n), nil +end + +local function write_fd(fd, str, len) + local buf = ffi.new('char[?]', len) + ffi.copy(buf, str, len) + + local n = toint(C.write(fd, buf, len)) + if n < 0 then + local e = get_errno() + if e == EAGAIN or e == EWOULDBLOCK then + return nil, nil, 'wr' -- would block + end + return nil, strerror(e) + end + + return n, nil +end + +local function seek_fd(fd, whence, off) + local w = SEEK[whence] + if not w then + return nil, 'bad whence: ' .. tostring(whence) + end + + local res = toint(C.lseek(fd, off, w)) + if res < 0 then + return nil, strerror(get_errno()) + end + + return res, nil +end + +local function close_fd(fd) + local rc = toint(C.close(fd)) + if rc ~= 0 then + return false, strerror(get_errno()) + end + return true, nil +end + +---------------------------------------------------------------------- +-- File-level helpers +---------------------------------------------------------------------- + +local function open_fd(path, flags, perms) + local c_path = ffi.new('char[?]', #path + 1) + ffi.copy(c_path, path) + local fd = toint(C.open(c_path, flags, perms or 0)) + if fd < 0 then + local e = get_errno() + return nil, strerror(e) + end + return fd, nil +end + +local function pipe_fd() + local fds = ffi.new('int[2]') + local rc = toint(C.pipe(fds)) + if rc ~= 0 then + local e = get_errno() + return nil, nil, strerror(e) + end + return toint(fds[0]), toint(fds[1]), nil +end + +local function fsync_fd(fd) + local rc = toint(C.fsync(fd)) + if rc ~= 0 then + local e = get_errno() + return false, strerror(e) + end + return true, nil +end + +local function rename_file(oldpath, newpath) + local c_old = ffi.new('char[?]', #oldpath + 1) + ffi.copy(c_old, oldpath) + local c_new = ffi.new('char[?]', #newpath + 1) + ffi.copy(c_new, newpath) + + local rc = toint(C.rename(c_old, c_new)) + if rc ~= 0 then + local e = get_errno() + return false, strerror(e) + end + return true, nil +end + +local function unlink_file(path) + local c_path = ffi.new('char[?]', #path + 1) + ffi.copy(c_path, path) + + local rc = toint(C.unlink(c_path)) + if rc ~= 0 then + local e = get_errno() + return false, strerror(e) + end + return true, nil +end + +-- Mode and permission tables mirror the old file.lua behaviour, +-- but live in the backend. + +---@type table +local modes = { + r = O_RDONLY, + w = bit.bor(O_WRONLY, O_CREAT, O_TRUNC), + a = bit.bor(O_WRONLY, O_CREAT, O_APPEND), + ['r+'] = O_RDWR, + ['w+'] = bit.bor(O_RDWR, O_CREAT, O_TRUNC), + ['a+'] = bit.bor(O_RDWR, O_CREAT, O_APPEND), +} + +do + local binary_modes = {} + for k, v in pairs(modes) do + binary_modes[k .. 'b'] = v + end + for k, v in pairs(binary_modes) do + modes[k] = v + end +end + +---@type table +local permissions = {} +permissions['rw-r--r--'] = bit.bor(S_IRUSR, S_IWUSR, S_IRGRP, S_IROTH) +permissions['rw-rw-rw-'] = bit.bor(permissions['rw-r--r--'], S_IWGRP, S_IWOTH) +permissions['rwxr-xr-x'] = bit.bor( + S_IRUSR, S_IWUSR, S_IXUSR, + S_IRGRP, S_IXGRP, + S_IROTH, S_IXOTH +) +permissions['rwx------'] = bit.bor(S_IRUSR, S_IWUSR, S_IXUSR) + +local function mkdir_path(path, perms) + -- Normalise perms: nil -> sensible default for dirs, string -> lookup, number -> passthrough. + local p + if perms == nil then + p = permissions['rwxr-xr-x'] + elseif type(perms) == 'string' then + p = permissions[perms] or perms + else + p = perms + end + + local c_path = ffi.new('char[?]', #path + 1) + ffi.copy(c_path, path) + + local rc = toint(C.mkdir(c_path, p)) + if rc ~= 0 then + local e = get_errno() + return false, strerror(e) + end + return true, nil +end + +local function open_file(path, mode, perms) + mode = mode or 'r' + local flags = modes[mode] + if not flags then + return nil, 'invalid mode: ' .. tostring(mode) + end + + local p + if perms == nil then + p = permissions['rw-rw-rw-'] + elseif type(perms) == 'string' then + p = permissions[perms] or perms + else + p = perms + end + + return open_fd(path, flags, p) +end + +local function mktemp(prefix, perms) + -- Normalise perms: nil -> default, string -> lookup in permissions table. + if perms == nil then + perms = permissions['rw-r--r--'] + elseif type(perms) == 'string' then + perms = permissions[perms] or perms + end + + -- Caller is responsible for seeding math.random appropriately. + local start = math.random(1e7) + local tmpnam, fd, err + + for i = start, start + 10 do + tmpnam = prefix .. '.' .. i + fd, err = open_fd(tmpnam, bit.bor(O_CREAT, O_RDWR, O_EXCL), perms) + if fd then + return fd, tmpnam + end + end + + return nil, ('failed to create temporary file %s: %s'):format( + tostring(tmpnam), + tostring(err) + ) +end + +local function decode_access(flags) + local acc = bit.band(flags, O_ACCMODE) + if acc == O_RDONLY then + return true, false + elseif acc == O_WRONLY then + return false, true + elseif acc == O_RDWR then + return true, true + end + -- Fallback: if we cannot interpret, assume read/write. + return true, true +end + +local function ignore_sigpipe() + -- Best-effort ignore of SIGPIPE. If this fails, we treat it as non-fatal. + local handler_t = ffi.typeof('sighandler_t') + local SIG_IGN = ffi.cast(handler_t, 1) + + local old = C.signal(SIGPIPE, SIG_IGN) + if old == nil then + local e = get_errno() + return false, strerror(e) + end + return true, nil +end + +---------------------------------------------------------------------- +-- Socket helpers (AF_UNIX, SOCK_STREAM, path string sockaddr) +---------------------------------------------------------------------- + +local function make_sockaddr_un(path) + local sa = ffi.new('struct sockaddr_un') + ---@cast sa sockaddr_un_cdata + sa.sun_family = AF_UNIX + + local maxlen = 108 - 1 + local p = path + if #p > maxlen then + p = p:sub(1, maxlen) + end + ffi.fill(sa.sun_path, 108) + ffi.copy(sa.sun_path, p) + + -- Full struct size is fine for bind/connect. + local len = ffi.sizeof('struct sockaddr_un') + return sa, len +end + +local function make_sockaddr_in(host, port) + if type(host) ~= 'string' or host == '' then + return nil, nil, 'host must be a non-empty string' + end + + port = tonumber(port) + if not port or port < 0 or port > 65535 then + return nil, nil, 'port must be 0..65535' + end + + local sa = ffi.new('struct sockaddr_in') + sa.sin_family = AF_INET + sa.sin_port = C.htons(tonumber(port)) + + local c_host = ffi.new('char[?]', #host + 1) + ffi.copy(c_host, host) + + local addr = ffi.new('struct in_addr[1]') + local rc = toint(C.inet_pton(AF_INET, c_host, addr)) + if rc ~= 1 then + if rc == 0 then + return nil, nil, 'invalid IPv4 address: ' .. tostring(host) + end + local e = get_errno() + return nil, nil, strerror(e) + end + + sa.sin_addr = addr[0] + ffi.fill(sa.sin_zero, 8) + + return sa, ffi.sizeof('struct sockaddr_in'), nil +end + +local function socket_fd(domain, stype, protocol) + local fd = toint(C.socket(domain, stype, protocol or 0)) + if fd < 0 then + local e = get_errno() + return nil, strerror(e), e + end + return fd, nil, nil +end + +local function bind_fd(fd, sa) + local c_sa, len, serr + + if type(sa) == 'string' then + -- AF_UNIX path + c_sa, len = make_sockaddr_un(sa) + elseif type(sa) == 'table' and sa.family == 'inet' then + -- AF_INET token: { family = 'inet', host = '1.2.3.4', port = 1234 } + c_sa, len, serr = make_sockaddr_in(sa.host, sa.port) + if not c_sa then + return false, serr, nil + end + else + return false, 'unsupported sockaddr representation', nil + end + + local rc = toint(C.bind(fd, ffi.cast('struct sockaddr *', c_sa), len)) + if rc ~= 0 then + local e = get_errno() + return false, strerror(e), e + end + return true, nil, nil +end + +local function listen_fd(fd) + local rc = toint(C.listen(fd, SOMAXCONN)) + if rc ~= 0 then + local e = get_errno() + return false, strerror(e), e + end + return true, nil, nil +end + +--- accept(fd) -> newfd|nil, err|nil, again:boolean +local function accept_fd(fd) + local new_fd = toint(C.accept(fd, nil, nil)) + if new_fd < 0 then + local e = get_errno() + if e == EAGAIN or e == EWOULDBLOCK then + return nil, nil, true + end + return nil, strerror(e), false + end + return new_fd, nil, false +end + +--- connect_start(fd, sa) -> ok|nil, err|nil, inprogress:boolean +local function connect_start_fd(fd, sa) + local c_sa, len, serr + + if type(sa) == 'string' then + -- AF_UNIX path + c_sa, len = make_sockaddr_un(sa) + elseif type(sa) == 'table' and sa.family == 'inet' then + -- AF_INET token + c_sa, len, serr = make_sockaddr_in(sa.host, sa.port) + if not c_sa then + return nil, serr, false + end + else + return nil, 'unsupported sockaddr representation', false + end + + local rc = toint(C.connect(fd, ffi.cast('struct sockaddr *', c_sa), len)) + if rc == 0 then + return true, nil, false + end + + local e = get_errno() + if e == EINPROGRESS then + return nil, nil, true + end + return nil, strerror(e), false +end + +--- connect_finish(fd) -> ok:boolean, err|nil +local function connect_finish_fd(fd) + local errval = ffi.new('int[1]') + local sz = ffi.new('socklen_t[1]', ffi.sizeof('int')) + local rc = toint(C.getsockopt(fd, SOL_SOCKET, SO_ERROR, errval, sz)) + if rc ~= 0 then + local e = get_errno() + return false, strerror(e) + end + local soerr = errval[0] + if soerr == 0 then + return true, nil + end + return false, strerror(soerr) +end + +---------------------------------------------------------------------- +-- Capability probe +---------------------------------------------------------------------- + +local function is_supported() + return true +end + +local ops = { + set_nonblock = set_nonblock, + read = read_fd, + write = write_fd, + seek = seek_fd, + close = close_fd, + + mkdir = mkdir_path, + open_file = open_file, + pipe = pipe_fd, + mktemp = mktemp, + fsync = fsync_fd, + rename = rename_file, + unlink = unlink_file, + decode_access = decode_access, + ignore_sigpipe = ignore_sigpipe, + + -- socket ops + socket = socket_fd, + bind = bind_fd, + listen = listen_fd, + accept = accept_fd, + connect_start = connect_start_fd, + connect_finish = connect_finish_fd, + + modes = modes, + permissions = permissions, + + AF_UNIX = AF_UNIX, + AF_INET = AF_INET, + SOCK_STREAM = SOCK_STREAM, + + is_supported = is_supported, +} + +return core.build_backend(ops) diff --git a/src/fibers/io/fd_backend/nixio.lua b/src/fibers/io/fd_backend/nixio.lua new file mode 100644 index 0000000..8692c65 --- /dev/null +++ b/src/fibers/io/fd_backend/nixio.lua @@ -0,0 +1,666 @@ +-- fibers/io/fd_backend/nixio.lua +-- +-- nixio-based FD backend (no FFI / luaposix dependency). +-- Intended to be selected via fibers.io.fd_backend. +-- +---@module 'fibers.io.fd_backend.nixio' + +local core = require 'fibers.io.fd_backend.core' +local nixio = require 'nixio' +local fs = require 'nixio.fs' + +local const = nixio.const or {} + +local EAGAIN = const.EAGAIN or 11 +local EWOULDBLOCK = const.EWOULDBLOCK or EAGAIN +local EINPROGRESS = const.EINPROGRESS or 115 +local EALREADY = const.EALREADY or 114 + +-- Where available, reuse nixio’s numeric constants so callers see +-- sensible AF_* / SOCK_* values. Fall back to standard Linux values. +local AF_UNIX = const.AF_UNIX or 1 +local AF_INET = const.AF_INET or 2 +local AF_INET6 = const.AF_INET6 or 10 +local SOCK_STREAM = const.SOCK_STREAM or 1 +local SOCK_DGRAM = const.SOCK_DGRAM or 2 + +local function errno_msg(default, eno) + if eno == nil or eno == 0 then + return default + end + + if type(eno) ~= 'number' then + local n = tonumber(eno) + if n then + eno = n + else + eno = nixio.errno() + if eno == nil or eno == 0 then + return default + end + end + end + + local s = nixio.strerror(eno) + if not s or s == '' then + return default .. ' (errno ' .. tostring(eno) .. ')' + end + return s +end + +-- nixio APIs vary by build/version in whether they return: +-- nil, msg, errno +-- or +-- nil, errno, msg +-- This helper normalises the trailing two values. +local function norm_msg_eno(a, b) + local ta, tb = type(a), type(b) + + if ta == 'number' and tb == 'string' then + return b, a + end + if ta == 'string' and tb == 'number' then + return a, b + end + if ta == 'number' and b == nil then + return nil, a + end + if ta == 'string' and b == nil then + return a, nil + end + if ta == 'number' then + return nil, a + end + if tb == 'number' then + return nil, b + end + if ta == 'string' then + return a, nil + end + if tb == 'string' then + return b, nil + end + return nil, nil +end + +-- nixio.open expects perms as a mode string (e.g. "0644" or "rw-r--r--") +local DEFAULT_CREATE_PERMS = '0666' -- subject to umask + +local function norm_perms(perms) + if perms == nil then + return nil + end + local t = type(perms) + if t == 'string' then + return perms + end + if t == 'number' then + -- Caller may pass decimal 420 (0644) etc; convert to octal string. + return string.format('%04o', perms) + end + return perms +end + +local function is_create_mode(mode) + mode = mode or 'r' + local c = mode:sub(1, 1) + return (c == 'w' or c == 'a') +end + +---------------------------------------------------------------------- +-- Core ops: set_nonblock / read / write / seek / close +---------------------------------------------------------------------- + +-- fd here is a nixio.File or nixio.Socket +local function set_nonblock(fd) + if fd and fd.setblocking then + local ok, a, b = fd:setblocking(false) + if ok ~= nil and ok ~= false then + return true, nil, nil + end + local msg, eno = norm_msg_eno(a, b) + eno = eno or nixio.errno() + return false, errno_msg(msg or 'setblocking(false) failed', eno), eno + end + -- If there is no setblocking, treat as already non-blocking. + return true, nil, nil +end + +local function read_fd(fd, max) + if not fd then + return nil, 'closed' + end + + max = max or const.buffersize or 8192 + if max <= 0 then + return '', nil + end + + -- nixio.File:read / Socket:read both generally return: + -- data + -- nil, msg, errno OR nil, errno, msg + local data, a, b = fd:read(max) + + if type(data) == 'string' then + -- data may be "" at EOF; that is acceptable to callers. + return data, nil + end + + local msg, eno = norm_msg_eno(a, b) + eno = eno or nixio.errno() + + if eno == EAGAIN or eno == EWOULDBLOCK then + -- Would block, signal “not ready yet”. + return nil, nil, 'rd' + end + + if not eno or eno == 0 then + -- Treat as EOF. + return '', nil + end + + return nil, errno_msg(msg or 'read failed', eno) +end + +local function write_fd(fd, str, len) + if not fd then + return nil, 'closed' + end + + len = len or #str + if len == 0 then + return 0, nil + end + + -- For files: File.write(buf, offset, length) + -- For sockets: Socket.send / write(buf, offset, length) – same shape. + local n, a, b = fd:write(str, 0, len) + + if type(n) == 'number' then + return n, nil + end + + local msg, eno = norm_msg_eno(a, b) + eno = eno or nixio.errno() + + if eno == EAGAIN or eno == EWOULDBLOCK then + -- Would block. + return nil, nil, 'wr' + end + + return nil, errno_msg(msg or 'write failed', eno) +end + +local SEEK_MAP = { + set = 'set', + cur = 'cur', + ['end'] = 'end', +} + +local function seek_fd(fd, whence, off) + if not fd then + return nil, 'closed' + end + + whence = SEEK_MAP[whence] or whence or 'cur' + off = off or 0 + + if not fd.seek then + return nil, 'seek not supported on this descriptor' + end + + local pos, a, b = fd:seek(off, whence) + if pos == nil then + local msg, eno = norm_msg_eno(a, b) + eno = eno or nixio.errno() + return nil, errno_msg(msg or 'seek failed', eno) + end + return pos, nil +end + +local function close_fd(fd) + if not fd then + return true, nil + end + + local ok, a, b = fd:close() + if ok == nil or ok == false then + local msg, eno = norm_msg_eno(a, b) + eno = eno or nixio.errno() + return false, errno_msg(msg or 'close failed', eno) + end + return true, nil +end + +---------------------------------------------------------------------- +-- File-level helpers: mkdir / open_file / pipe / mktemp / fsync / rename / unlink +---------------------------------------------------------------------- + +-- Basic symbolic permission presets for mkdir and file creation. +local function oct(s) + return tonumber(s, 8) +end + +local permissions = { + ['rw-r--r--'] = oct('644'), + ['rw-rw-rw-'] = oct('666'), + + -- Directories (execute bits are required for traversal). + ['rwxr-xr-x'] = oct('755'), + ['rwx------'] = oct('700'), +} + +-- nixio.open tolerates mode strings like "0644" and sometimes symbolic modes. +local function norm_open_perms(perms) + if perms == nil then return nil end + local t = type(perms) + if t == 'number' then + return string.format('%04o', perms) + end + if t == 'string' then + -- If a symbolic string matches our presets, convert to octal string. + local m = permissions[perms] + if m then + return string.format('%04o', m) + end + return perms + end + return perms +end + +-- nixio.fs.mkdir expects a mode string on some builds ("0755"), not a raw number. +local function norm_mkdir_mode(perms) + if perms == nil then + return '0755' + end + + local t = type(perms) + + if t == 'number' then + -- Decimal 511 -> "0777" + return string.format('%04o', perms) + end + + if t == 'string' then + local m = permissions[perms] + if m then + return string.format('%04o', m) + end + + -- Accept already-octal strings like "0755" + -- (and leave other strings untouched for nixio to interpret) + return perms + end + + return '0755' +end + +local function mkdir_path(path, perms) + local mode = norm_mkdir_mode(perms) + + local ok, a, b = fs.mkdir(path, mode) + if ok == nil or ok == false then + local msg, eno = norm_msg_eno(a, b) + return false, errno_msg(msg or 'mkdir failed', eno) + end + + return true, nil +end + +local function open_file(path, mode, perms) + mode = mode or 'r' + + local p = norm_open_perms(perms) + + -- If this is a creating mode and perms is nil, provide a default. + if p == nil and is_create_mode(mode) then + p = DEFAULT_CREATE_PERMS + end + + local f, a, b = nixio.open(path, mode, p) + if not f then + local msg, eno = norm_msg_eno(a, b) + return nil, errno_msg(msg or 'open failed', eno) + end + return f, nil +end + +local function pipe_fds() + local r, w, a, b = nixio.pipe() + if not r then + local msg, eno = norm_msg_eno(a, b) + return nil, nil, errno_msg(msg or 'pipe failed', eno) + end + return r, w, nil +end + +local function mktemp(prefix, perms) + local start = math.random(1e7) + local last_err + + local p = norm_open_perms(perms) or '0644' + + for i = start, start + 10 do + local tmpnam = prefix .. '.' .. i + local f, a, b = nixio.open(tmpnam, 'w+', p) + if f then + return f, tmpnam + end + local msg, eno = norm_msg_eno(a, b) + last_err = errno_msg(msg or 'mktemp open failed', eno) + end + + return nil, last_err or 'mktemp: failed to create temporary file' +end + +local function fsync_fd(fd) + if not fd or not fd.sync then + return true, nil + end + local ok, a, b = fd:sync(false) + if ok == nil or ok == false then + local msg, eno = norm_msg_eno(a, b) + eno = eno or nixio.errno() + return false, errno_msg(msg or 'fsync failed', eno) + end + return true, nil +end + +local function rename_file(oldpath, newpath) + local ok, a, b = fs.rename(oldpath, newpath) + if ok == nil or ok == false then + local msg, eno = norm_msg_eno(a, b) + return false, errno_msg(msg or 'rename failed', eno) + end + return true, nil +end + +local function unlink_file(path) + local ok, a, b = fs.unlink(path) + if ok == nil or ok == false then + local msg, eno = norm_msg_eno(a, b) + return false, errno_msg(msg or 'unlink failed', eno) + end + return true, nil +end + +-- For this backend, integer open flags are not used; when decode_access +-- is called we can conservatively assume read/write. +local function decode_access(_) + return true, true +end + +local function ignore_sigpipe() + -- Best-effort ignore of SIGPIPE. + if nixio.signal and nixio.SIGPIPE then + local ok, a, b = nixio.signal(nixio.SIGPIPE, 'ign') + if ok == nil or ok == false then + local msg, eno = norm_msg_eno(a, b) + return false, errno_msg(msg or 'signal(SIGPIPE) failed', eno) + end + end + return true, nil +end + +---------------------------------------------------------------------- +-- Socket helpers +---------------------------------------------------------------------- + +local function domain_to_str(domain) + if domain == AF_UNIX then + return 'unix' + end + if AF_INET and domain == AF_INET then + return 'inet' + end + if AF_INET6 and domain == AF_INET6 then + return 'inet6' + end + error('fd_backend.nixio: unsupported address family: ' .. tostring(domain)) +end + +local function stype_to_str(stype) + if stype == SOCK_STREAM then + return 'stream' + end + if SOCK_DGRAM and stype == SOCK_DGRAM then + return 'dgram' + end + error('fd_backend.nixio: unsupported socket type: ' .. tostring(stype)) +end + +-- Normalise sockaddr tokens used by fibers.io.socket: +-- UNIX: +-- "/tmp/sock" +-- { family = "unix", path = "/tmp/sock" } +-- INET: +-- { family = "inet", host = "127.0.0.1", port = 1234 } +-- INET6: +-- { family = "inet6", host = "::1", port = 1234 } +local function norm_sockaddr(sa) + if type(sa) == 'string' then + return 'unix', sa, 0 + end + + if type(sa) ~= 'table' then + return nil, nil, nil, 'unsupported sockaddr representation' + end + + local fam = sa.family or sa.af + if fam == AF_UNIX then fam = 'unix' end + if fam == AF_INET then fam = 'inet' end + if fam == AF_INET6 then fam = 'inet6' end + + if fam == nil then + -- Reasonable fallback: table with port implies inet. + if sa.port ~= nil then + fam = 'inet' + elseif sa.path then + fam = 'unix' + end + end + + if fam == 'unix' then + local path = sa.path or sa.host + if type(path) ~= 'string' or path == '' then + return nil, nil, nil, 'invalid unix sockaddr' + end + return 'unix', path, 0 + end + + if fam == 'inet' or fam == 'inet6' then + local host = sa.host + local port = sa.port + if host ~= nil and type(host) ~= 'string' then + return nil, nil, nil, 'invalid ' .. fam .. ' host' + end + port = tonumber(port) + if port == nil then + return nil, nil, nil, 'invalid ' .. fam .. ' port' + end + return fam, host, port + end + + return nil, nil, nil, 'unsupported sockaddr family' +end + +--- socket(domain, stype, protocol) -> fd|nil, err|nil, eno|nil +local function socket_fd(domain, stype, _) + local d = domain_to_str(domain) + local t = stype_to_str(stype) + + local s, a, b = nixio.socket(d, t) + if not s then + local msg, eno = norm_msg_eno(a, b) + return nil, errno_msg(msg or 'socket failed', eno), eno + end + -- Returned “fd” is a nixio.Socket object. + return s, nil, nil +end + +--- bind(fd, sa) where fd is nixio.Socket +local function bind_fd(fd, sa) + if not fd then + return false, 'closed socket', nil + end + + local fam, host, port, nerr = norm_sockaddr(sa) + if not fam then + return false, nerr, nil + end + + local ok, a, b + ok, a, b = fd:bind(host, port) + + if ok == nil or ok == false then + local msg, eno = norm_msg_eno(a, b) + eno = eno or nixio.errno() + return false, errno_msg(msg or 'bind failed', eno), eno + end + + return true, nil, nil +end + +local function listen_fd(fd) + if not fd then + return false, 'closed socket', nil + end + + local backlog = const.SOMAXCONN or 128 + local ok, a, b = fd:listen(backlog) + if ok == nil or ok == false then + local msg, eno = norm_msg_eno(a, b) + eno = eno or nixio.errno() + return false, errno_msg(msg or 'listen failed', eno), eno + end + return true, nil, nil +end + +--- accept(fd) -> newfd|nil, err|nil, again:boolean +local function accept_fd(fd) + if not fd then + return nil, 'closed socket', false + end + + -- nixio.Socket.accept() -> newsock, host, port | nil, msg, errno + -- Some builds may swap msg/errno on error; normalise. + local newsock, x, y = fd:accept() + if newsock then + return newsock, nil, false + end + + local msg, eno = norm_msg_eno(x, y) + eno = eno or nixio.errno() + if eno == EAGAIN or eno == EWOULDBLOCK then + return nil, nil, true + end + + return nil, errno_msg(msg or 'accept failed', eno), false +end + +--- connect_start(fd, sa) -> ok|nil, err|nil, inprogress:boolean +local function connect_start_fd(fd, sa) + if not fd then + return nil, 'closed socket', false + end + + local fam, host, port, nerr = norm_sockaddr(sa) + if not fam then + return nil, nerr, false + end + + local ok, a, b = fd:connect(host, port) + if ok then + return true, nil, false + end + + local msg, eno = norm_msg_eno(a, b) + eno = eno or nixio.errno() + if eno == EINPROGRESS or eno == EALREADY or eno == EAGAIN then + -- Non-blocking connect in progress. + return nil, nil, true + end + + return nil, errno_msg(msg or 'connect failed', eno), false +end + +--- connect_finish(fd) -> ok:boolean, err|nil +local function connect_finish_fd(fd) + if not fd then + return false, 'closed socket' + end + + if not fd.getopt then + -- Fallback: if we cannot inspect SO_ERROR, assume success. + return true, nil + end + + local soerr, a, b = fd:getopt('socket', 'error') + if soerr == nil then + local msg, eno = norm_msg_eno(a, b) + eno = eno or nixio.errno() + return false, errno_msg(msg or 'getsockopt(SO_ERROR) failed', eno) + end + + soerr = tonumber(soerr) or 0 + if soerr == 0 then + return true, nil + end + + return false, errno_msg('connect error', soerr) +end + +---------------------------------------------------------------------- +-- Capability probe +---------------------------------------------------------------------- + +local function is_supported() + -- If this module loaded, nixio was already required successfully. + return true +end + +---------------------------------------------------------------------- +-- Assemble ops and build backend +---------------------------------------------------------------------- + +local ops = { + -- Core file/socket descriptor ops + set_nonblock = set_nonblock, + read = read_fd, + write = write_fd, + seek = seek_fd, + close = close_fd, + + -- File-level helpers + open_file = open_file, + pipe = pipe_fds, + mktemp = mktemp, + fsync = fsync_fd, + rename = rename_file, + unlink = unlink_file, + mkdir = mkdir_path, + decode_access = decode_access, + ignore_sigpipe = ignore_sigpipe, + + -- Socket-level helpers + socket = socket_fd, + bind = bind_fd, + listen = listen_fd, + accept = accept_fd, + connect_start = connect_start_fd, + connect_finish = connect_finish_fd, + + -- Metadata for callers + modes = {}, -- nixio uses mode strings for open() + permissions = permissions, + + AF_UNIX = AF_UNIX, + AF_INET = AF_INET, + AF_INET6 = AF_INET6, + SOCK_STREAM = SOCK_STREAM, + SOCK_DGRAM = SOCK_DGRAM, + + is_supported = is_supported, +} + +return core.build_backend(ops) diff --git a/src/fibers/io/fd_backend/posix.lua b/src/fibers/io/fd_backend/posix.lua new file mode 100644 index 0000000..4d48534 --- /dev/null +++ b/src/fibers/io/fd_backend/posix.lua @@ -0,0 +1,485 @@ +-- fibers/io/fd_backend/posix.lua +-- +-- luaposix-based FD backend (no FFI). +-- Intended to be selected via fibers.io.fd_backend. +-- +---@module 'fibers.io.fd_backend.posix' + +local core = require 'fibers.io.fd_backend.core' + +local unistd = require 'posix.unistd' +local stdio = require 'posix.stdio' +local fcntl = require 'posix.fcntl' +local pstat = require 'posix.sys.stat' +local errno = require 'posix.errno' +local psig = require 'posix.signal' +local socket_mod = require 'posix.sys.socket' + +local bit = rawget(_G, 'bit') or require 'bit32' + +local function errno_msg(prefix, err, eno) + if err and err ~= '' then + return err + end + if eno then + return ('%s (errno %d)'):format(prefix, eno) + end + return prefix +end + +---------------------------------------------------------------------- +-- set_nonblock / basic ops +---------------------------------------------------------------------- + +local function set_nonblock(fd) + local flags, err, eno = fcntl.fcntl(fd, fcntl.F_GETFL) + if flags == nil then + return false, errno_msg('fcntl(F_GETFL)', err, eno), eno + end + local newflags = bit.bor(flags, fcntl.O_NONBLOCK) + local ok, err2, eno2 = fcntl.fcntl(fd, fcntl.F_SETFL, newflags) + if ok == nil then + return false, errno_msg('fcntl(F_SETFL)', err2, eno2), eno2 + end + return true, nil, nil +end + +local function read_fd(fd, max) + local s, err, eno = unistd.read(fd, max) + if s == nil then + if eno == errno.EAGAIN or eno == errno.EWOULDBLOCK then + return nil, nil, 'rd' + end + return nil, errno_msg('read failed', err, eno) + end + return s, nil +end + +local function write_fd(fd, str, _) + local n, err, eno = unistd.write(fd, str) + if n == nil then + if eno == errno.EAGAIN or eno == errno.EWOULDBLOCK then + return nil, nil, 'wr' + end + return nil, errno_msg('write failed', err, eno) + end + return n, nil +end + +local SEEK = { set = unistd.SEEK_SET, cur = unistd.SEEK_CUR, ['end'] = unistd.SEEK_END } + +local function seek_fd(fd, whence, off) + local w = SEEK[whence] + if not w then + return nil, 'bad whence: ' .. tostring(whence) + end + local pos, err, eno = unistd.lseek(fd, off, w) + if pos == nil then + return nil, errno_msg('lseek failed', err, eno) + end + return pos, nil +end + +local function close_fd(fd) + local ok, err, eno = unistd.close(fd) + if ok == nil then + return false, errno_msg('close failed', err, eno) + end + return true, nil +end + +---------------------------------------------------------------------- +-- File-level helpers +---------------------------------------------------------------------- + +local modes = { + r = fcntl.O_RDONLY, + w = bit.bor(fcntl.O_WRONLY, fcntl.O_CREAT, fcntl.O_TRUNC), + a = bit.bor(fcntl.O_WRONLY, fcntl.O_CREAT, fcntl.O_APPEND), + ['r+'] = fcntl.O_RDWR, + ['w+'] = bit.bor(fcntl.O_RDWR, fcntl.O_CREAT, fcntl.O_TRUNC), + ['a+'] = bit.bor(fcntl.O_RDWR, fcntl.O_CREAT, fcntl.O_APPEND), +} + +do + local binary_modes = {} + for k, v in pairs(modes) do + binary_modes[k .. 'b'] = v + end + for k, v in pairs(binary_modes) do + modes[k] = v + end +end + +local permissions = {} +permissions['rw-r--r--'] = bit.bor(pstat.S_IRUSR, pstat.S_IWUSR, pstat.S_IRGRP, pstat.S_IROTH) +permissions['rw-rw-rw-'] = bit.bor(permissions['rw-r--r--'], pstat.S_IWGRP, pstat.S_IWOTH) + +permissions['rwxr-xr-x'] = bit.bor( + pstat.S_IRUSR, pstat.S_IWUSR, pstat.S_IXUSR, + pstat.S_IRGRP, pstat.S_IXGRP, + pstat.S_IROTH, pstat.S_IXOTH +) +permissions['rwx------'] = bit.bor(pstat.S_IRUSR, pstat.S_IWUSR, pstat.S_IXUSR) + +local function mkdir_path(path, perms) + local p + if perms == nil then + p = permissions['rwxr-xr-x'] + elseif type(perms) == 'string' then + p = permissions[perms] or perms + else + p = perms + end + + local ok, err, eno = pstat.mkdir(path, p) + if ok == nil then + return false, errno_msg('mkdir failed', err, eno) + end + return true, nil +end + +local function open_file(path, mode, perms) + mode = mode or 'r' + local flags = modes[mode] + if not flags then + return nil, 'invalid mode: ' .. tostring(mode) + end + + local p + if perms == nil then + p = permissions['rw-rw-rw-'] + elseif type(perms) == 'string' then + p = permissions[perms] or perms + else + p = perms + end + + local fd, err, eno = fcntl.open(path, flags, p) + if not fd then + return nil, errno_msg('open failed', err, eno) + end + return fd, nil +end + +local function pipe_fds() + local rd, wr, err, eno = unistd.pipe() + if not rd then + return nil, nil, errno_msg('pipe failed', err, eno) + end + return rd, wr, nil +end + +local function mktemp(prefix, perms) + if perms == nil then + perms = permissions['rw-r--r--'] + elseif type(perms) == 'string' then + perms = permissions[perms] or perms + end + + local start = math.random(1e7) + local tmpnam, fd, err, eno + + for i = start, start + 10 do + tmpnam = prefix .. '.' .. i + fd, err, eno = fcntl.open( + tmpnam, + bit.bor(fcntl.O_CREAT, fcntl.O_RDWR, fcntl.O_EXCL), + perms + ) + if fd then + return fd, tmpnam + end + end + + return nil, ('failed to create temporary file %s: %s'):format( + tostring(tmpnam), + tostring(errno_msg('open', err, eno)) + ) +end + +local function fsync_fd(fd) + local ok, err, eno = unistd.fsync(fd) + if ok == nil then + return false, errno_msg('fsync failed', err, eno) + end + return true, nil +end + +local function rename_file(oldpath, newpath) + local ok, err, eno = stdio.rename(oldpath, newpath) + if ok == nil then + return false, errno_msg('rename failed', err, eno) + end + return true, nil +end + +local function unlink_file(path) + local ok, err, eno = unistd.unlink(path) + if ok == nil then + return false, errno_msg('unlink failed', err, eno) + end + return true, nil +end + +local function decode_access(flags) + local o_wr = fcntl.O_WRONLY or 0 + local o_rdwr = fcntl.O_RDWR or 0 + local readable + if o_wr ~= 0 then + readable = (bit.band(flags, o_wr) ~= o_wr) + else + readable = true + end + + local writable = false + if o_wr ~= 0 and bit.band(flags, o_wr) ~= 0 then + writable = true + end + if o_rdwr ~= 0 and bit.band(flags, o_rdwr) ~= 0 then + writable = true + end + + if not readable and not writable then + readable = true + end + + return readable, writable +end + +local function ignore_sigpipe() + local ok, err, eno = psig.signal(psig.SIGPIPE, psig.SIG_IGN) + if ok == nil then + return false, errno_msg('signal(SIGPIPE)', err, eno) + end + return true, nil +end + +---------------------------------------------------------------------- +-- Socket helpers on top of posix.sys.socket +---------------------------------------------------------------------- + +local AF_UNIX = socket_mod.AF_UNIX +local AF_INET = socket_mod.AF_INET +local AF_INET6 = socket_mod.AF_INET6 +local SOCK_STREAM = socket_mod.SOCK_STREAM +local SOCK_DGRAM = socket_mod.SOCK_DGRAM + +-- Normalise sockaddr tokens used by higher layers into luaposix sockaddr tables. +-- Accepted inputs: +-- UNIX: +-- "/tmp/sock" +-- { family = "unix", path = "/tmp/sock" } +-- INET: +-- { family = "inet", host = "127.0.0.1", port = 1234 } +-- { family = "inet", addr = "127.0.0.1", port = 1234 } +-- INET6: +-- { family = "inet6", host = "::1", port = 1234 } +-- Raw luaposix sockaddr table: +-- { family = socket_mod.AF_INET, addr = "...", port = ... } +local function norm_sockaddr(sa) + if type(sa) == 'string' then + -- Convenience form: UNIX path + return { family = AF_UNIX, path = sa } + end + + if type(sa) ~= 'table' then + return nil, 'unsupported sockaddr representation' + end + + -- If this already looks like a luaposix sockaddr table, accept it. + if type(sa.family) == 'number' then + return sa + end + + local fam = sa.family or sa.af + if fam == 'unix' then + if not AF_UNIX then + return nil, 'AF_UNIX not supported' + end + local path = sa.path or sa.host + if type(path) ~= 'string' or path == '' then + return nil, 'invalid unix sockaddr path' + end + return { family = AF_UNIX, path = path } + end + + if fam == 'inet' then + if not AF_INET then + return nil, 'AF_INET not supported' + end + local addr = sa.addr or sa.host + local port = tonumber(sa.port) + if addr ~= nil and type(addr) ~= 'string' then + return nil, 'invalid inet sockaddr addr' + end + if port == nil then + return nil, 'invalid inet sockaddr port' + end + return { family = AF_INET, addr = addr, port = port } + end + + if fam == 'inet6' then + if not AF_INET6 then + return nil, 'AF_INET6 not supported' + end + local addr = sa.addr or sa.host + local port = tonumber(sa.port) + if addr ~= nil and type(addr) ~= 'string' then + return nil, 'invalid inet6 sockaddr addr' + end + if port == nil then + return nil, 'invalid inet6 sockaddr port' + end + return { family = AF_INET6, addr = addr, port = port } + end + + return nil, 'unsupported sockaddr family' +end + +--- Create a socket fd. +---@param domain integer +---@param stype integer +---@param protocol? integer +---@return integer|nil fd, string|nil err, integer|nil eno +local function socket_fd(domain, stype, protocol) + local fd, err, eno = socket_mod.socket(domain, stype, protocol or 0) + if fd == nil then + return nil, errno_msg('socket failed', err, eno), eno + end + return fd, nil, nil +end + +--- Bind a socket to an address token. +---@param fd integer +---@param sa any +---@return boolean ok, string|nil err, integer|nil eno +local function bind_fd(fd, sa) + local addr, aerr = norm_sockaddr(sa) + if not addr then + return false, aerr, nil + end + + local ok, err, eno = socket_mod.bind(fd, addr) + if ok == nil then + return false, errno_msg('bind failed', err, eno), eno + end + return true, nil, nil +end + +--- Put a listening socket into listen state. +---@param fd integer +---@return boolean ok, string|nil err, integer|nil eno +local function listen_fd(fd) + local backlog = socket_mod.SOMAXCONN or 128 + local ok, err, eno = socket_mod.listen(fd, backlog) + if ok == nil then + return false, errno_msg('listen failed', err, eno), eno + end + return true, nil, nil +end + +--- accept(fd) -> newfd|nil, err|nil, again:boolean +---@param fd integer +---@return integer|nil newfd, string|nil err, boolean again +local function accept_fd(fd) + local newfd, addr_or_err, errnum = socket_mod.accept(fd) + if newfd ~= nil then + return newfd, nil, false + end + + local eno = errnum + if eno == errno.EAGAIN or eno == errno.EWOULDBLOCK then + return nil, nil, true + end + + return nil, errno_msg('accept failed', addr_or_err, eno), false +end + +--- Start a non-blocking connect. +--- connect_start(fd, sa) -> ok|nil, err|nil, inprogress:boolean +---@param fd integer +---@param sa any +---@return boolean|nil ok, string|nil err, boolean inprogress +local function connect_start_fd(fd, sa) + local addr, aerr = norm_sockaddr(sa) + if not addr then + return nil, aerr, false + end + + local ok, err, eno = socket_mod.connect(fd, addr) + if ok ~= nil then + return true, nil, false + end + + if eno == errno.EINPROGRESS or eno == errno.EALREADY or eno == errno.EAGAIN then + return nil, nil, true + end + + return nil, errno_msg('connect failed', err, eno), false +end + +--- Complete a non-blocking connect using SO_ERROR. +---@param fd integer +---@return boolean ok, string|nil err +local function connect_finish_fd(fd) + local soerr, err, eno = socket_mod.getsockopt( + fd, socket_mod.SOL_SOCKET, socket_mod.SO_ERROR + ) + if soerr == nil then + return false, errno_msg('getsockopt(SO_ERROR) failed', err, eno) + end + + soerr = tonumber(soerr) or 0 + if soerr == 0 then + return true, nil + end + + return false, 'connect error errno ' .. tostring(soerr) +end + +local function is_supported() + return true +end + +---------------------------------------------------------------------- +-- Assemble ops and build backend +---------------------------------------------------------------------- + +local ops = { + set_nonblock = set_nonblock, + read = read_fd, + write = write_fd, + seek = seek_fd, + close = close_fd, + + open_file = open_file, + pipe = pipe_fds, + mktemp = mktemp, + fsync = fsync_fd, + rename = rename_file, + unlink = unlink_file, + mkdir = mkdir_path, + decode_access = decode_access, + ignore_sigpipe = ignore_sigpipe, + + socket = socket_fd, + bind = bind_fd, + listen = listen_fd, + accept = accept_fd, + connect_start = connect_start_fd, + connect_finish = connect_finish_fd, + + modes = modes, + permissions = permissions, + + AF_UNIX = AF_UNIX, + AF_INET = AF_INET, + AF_INET6 = AF_INET6, + SOCK_STREAM = SOCK_STREAM, + SOCK_DGRAM = SOCK_DGRAM, + + is_supported = is_supported, +} + +return core.build_backend(ops) diff --git a/src/fibers/io/file.lua b/src/fibers/io/file.lua new file mode 100644 index 0000000..194c1f1 --- /dev/null +++ b/src/fibers/io/file.lua @@ -0,0 +1,367 @@ +-- fibers/io/file.lua +-- +-- File-backed streams on top of fd_backend + stream. +-- +-- Exposes: +-- fdopen(fd, flags_or_mode[, filename]) -> Stream +-- open(filename[, mode[, perms]]) -> Stream | nil, err +-- pipe() -> read_stream, write_stream +-- mktemp(prefix[, perms]) -> fd, tmpname_or_err +-- tmpfile([perms[, tmpdir]]) -> Stream (auto-unlink on close) +-- init_nonblocking(fd) -> ok, err|nil +-- +---@module 'fibers.io.file' + +local stream = require 'fibers.io.stream' +local fd_back = require 'fibers.io.fd_backend' + +-- Best-effort: ignore SIGPIPE so write failures report via errno/return codes. +do + if fd_back.ignore_sigpipe then + fd_back.ignore_sigpipe() -- errors are treated as non-fatal + end +end + +---------------------------------------------------------------------- +-- Mode / permission policy (OS-agnostic) +---------------------------------------------------------------------- + +-- We keep the string conventions, but leave numeric mapping to the backend. +---@param mode string The file mode string (e.g., "r", "w", "a", "r+", "w+", "a+") +---@return boolean readable Returns true if the mode allows reading +---@return boolean writable Returns true if the mode allows writing +local function mode_access(mode) + assert(type(mode) == 'string', 'mode must be a string') + local plus = mode:find('+', 1, true) ~= nil + local c = mode:sub(1, 1) + + if c == 'r' then + if plus then + return true, true + else + return true, false + end + elseif c == 'w' or c == 'a' then + if plus then + return true, true + else + return false, true + end + else + error('invalid mode: ' .. tostring(mode)) + end +end + +---------------------------------------------------------------------- +-- Internal: wrap an fd as a Stream +---------------------------------------------------------------------- + +--- Wrap an fd in a Stream using fd_backend. +--- +--- flags_or_mode may be: +--- * number : backend-specific open flags (decode_access will be used) +--- * string : Lua-style mode string ("r", "w+", "rb", etc.) +--- * table : { readable = bool, writable = bool } +--- +---@param fd integer +---@param flags_or_mode any +---@param filename? string +---@return Stream +local function fdopen(fd, flags_or_mode, filename) + -- assert(type(fd) == "number", "fdopen: fd must be a number") + assert(fd ~= nil, 'fdopen: fd must be non-nil') + + local readable, writable + + local t = type(flags_or_mode) + if t == 'number' then + assert(fd_back.decode_access, 'backend does not implement decode_access') + readable, writable = fd_back.decode_access(flags_or_mode) + elseif t == 'string' then + readable, writable = mode_access(flags_or_mode) + elseif t == 'table' then + readable = not not flags_or_mode.readable + writable = not not flags_or_mode.writable + else + error('fdopen: invalid flags_or_mode: ' .. tostring(flags_or_mode)) + end + + local io = fd_back.new(fd, { filename = filename }) + + -- We no longer try to adjust buffer size based on fstat; stream.open + -- will apply its default buffer sizes. + return stream.open(io, readable, writable) +end + +---------------------------------------------------------------------- +-- Directories +---------------------------------------------------------------------- + +--- Create a directory. +--- +--- perms may be an integer mask or a symbolic string understood by the backend. +---@param path string +---@param perms? integer|string +---@return boolean|nil ok, string|nil err +local function mkdir(path, perms) + assert(type(path) == 'string' and path ~= '', 'mkdir: path must be a non-empty string') + + if not fd_back.mkdir then + return nil, 'backend does not implement mkdir' + end + + return fd_back.mkdir(path, perms) +end + +--- Best-effort classification of "already exists" errors across backends. +---@param err any +---@return boolean +local function is_eexist(err) + if err == nil then return false end + local s = tostring(err):lower() + -- Common shapes: "EEXIST", "file exists", "exists" + return s:find('eexist', 1, true) ~= nil + or s:find('file exists', 1, true) ~= nil + or s:find('exists', 1, true) ~= nil +end + +--- Create a directory path (mkdir -p). +--- +--- Semantics: +--- * creates parent components as required +--- * succeeds if the directory already exists +--- * returns nil,err on the first hard failure encountered +--- +--- perms may be an integer mask or a symbolic string understood by the backend. +---@param path string +---@param perms? integer|string +---@return boolean|nil ok, string|nil err +local function mkdir_p(path, perms) + assert(type(path) == 'string' and path ~= '', 'mkdir_p: path must be a non-empty string') + + if not fd_back.mkdir then + return nil, 'backend does not implement mkdir' + end + + -- Root is trivially present. + if path == '/' then + return true, nil + end + + -- Normalise repeated slashes; keep leading '/' if present. + local is_abs = path:sub(1, 1) == '/' + -- Collapse multiple slashes to single slash. + path = path:gsub('/+', '/') + + -- Strip trailing slash (except for "/"). + if #path > 1 and path:sub(-1) == '/' then + path = path:sub(1, -2) + end + + -- Split into components. + local parts = {} + for seg in path:gmatch('[^/]+') do + -- Skip "." segments; do not attempt to resolve ".." here. + if seg ~= '.' and seg ~= '' then + parts[#parts + 1] = seg + end + end + + -- Nothing to do (e.g. "." or "/."). + if #parts == 0 then + return true, nil + end + + local cur = is_abs and '' or nil + for i = 1, #parts do + local seg = parts[i] + if cur == nil then + cur = seg + elseif cur == '' then + cur = '/' .. seg + else + cur = cur .. '/' .. seg + end + + local ok, err = fd_back.mkdir(cur, perms) + if ok then + -- created + else + -- treat "already exists" as success; anything else is fatal + if not is_eexist(err) then + return nil, err + end + end + end + + return true, nil +end + +---------------------------------------------------------------------- +-- Open by filename +---------------------------------------------------------------------- + +--- Open a file by name as a Stream. +--- +--- mode : "r", "w", "a", "r+", "w+", "a+" (with optional "b" suffix) +--- perms : integer or symbolic string (e.g. "rw-rw-rw-"), backend-defined. +--- +---@param filename string +---@param mode? string +---@param perms? integer|string +---@return Stream|nil f, string|nil err +local function open_file(filename, mode, perms) + mode = mode or 'r' + + local fd, err = fd_back.open_file(filename, mode, perms) + if not fd then + return nil, err + end + + return fdopen(fd, mode, filename) +end + +---------------------------------------------------------------------- +-- Pipes +---------------------------------------------------------------------- + +--- Create a unidirectional pipe as two Streams (read, write). +---@return Stream r_stream, Stream w_stream +local function pipe() + local rd, wr, err = fd_back.pipe() + if not rd then + error(err or 'pipe() failed') + end + + local r_stream = fdopen(rd, 'r') + local w_stream = fdopen(wr, 'w') + return r_stream, w_stream +end + +---------------------------------------------------------------------- +-- mktemp / tmpfile +---------------------------------------------------------------------- + +--- Create a temporary file with a unique name (backend-level). +--- +--- perms may be an integer mask or a symbolic string understood by the backend. +---@param prefix string +---@param perms? integer|string +---@return integer|nil fd, string tmpname_or_err +local function mktemp(prefix, perms) + perms = perms or 'rw-r--r--' + local fd, tmpnam_or_err = fd_back.mktemp(prefix, perms) + if not fd then + return nil, tmpnam_or_err + end + return fd, tmpnam_or_err +end + +--- Create a temporary file wrapped as a Stream, with unlink-on-close semantics. +---@param perms? integer|string +---@param tmpdir? string +---@return Stream|nil f, string|nil err +local function tmpfile(perms, tmpdir) + perms = perms or 'rw-r--r--' + tmpdir = tmpdir or os.getenv('TMPDIR') or '/tmp' + ---@cast tmpdir string + + local fd, tmpnam_or_err = mktemp(tmpdir .. '/tmp', perms) + if not fd then + return nil, tmpnam_or_err + end + + ---@type Stream + local f = fdopen(fd, 'r+', tmpnam_or_err) + + -- We want unlink-on-close semantics by default, with a way to + -- disable that via :rename(). + local io = f.io + assert(io, 'tmpfile backend missing') + ---@cast io StreamBackend + + local old_close = assert(io.close, 'tmpfile backend missing close()') + + --- Rename the temporary file and disable unlink-on-close behaviour. + ---@param newname string + ---@return boolean|nil ok, string|nil err + function f:rename(newname) + -- Flush buffered data first. + self:flush() + + local real_fd = io.fileno and io:fileno() or fd + if real_fd then + fd_back.fsync(real_fd) + end + + local fname = assert(io.filename, 'tmpfile has no filename') + local ok, err = fd_back.rename(fname, newname) + if not ok then + return nil, ('failed to rename %s to %s: %s'):format( + tostring(fname), + tostring(newname), + tostring(err) + ) + end + + io.filename = newname + -- Disable remove-on-close: restore original close. + io.close = old_close + return true + end + + --- Close the fd and unlink the temporary file. + ---@return boolean ok, string|nil err + function io:close() + local ok, err = old_close(self) + if not ok then + return ok, err + end + + local fname = assert(self.filename, 'tmpfile has no filename') + local ok2, err2 = fd_back.unlink(fname) + if not ok2 then + return false, ('failed to remove %s: %s'):format( + tostring(fname), + tostring(err2) + ) + end + + return true, nil + end + + return f +end + +---------------------------------------------------------------------- +-- Compatibility helper +---------------------------------------------------------------------- + +--- Put an fd into non-blocking mode using the backend. +---@param fd integer +---@return boolean ok, string|nil err +local function init_nonblocking(fd) + return fd_back.set_nonblock(fd) +end + +---------------------------------------------------------------------- +-- Public API +---------------------------------------------------------------------- + +return { + fdopen = fdopen, + open = open_file, + pipe = pipe, + mktemp = mktemp, + tmpfile = tmpfile, + init_nonblocking = init_nonblocking, + mkdir = mkdir, + mkdir_p = mkdir_p, + rename = fd_back.rename, + unlink = fd_back.unlink, + + -- For callers that previously used file.modes / file.permissions, + -- re-export backend metadata if present. + modes = fd_back.modes or {}, + permissions = fd_back.permissions or {}, +} diff --git a/src/fibers/io/mem_backend.lua b/src/fibers/io/mem_backend.lua new file mode 100644 index 0000000..50dfb2f --- /dev/null +++ b/src/fibers/io/mem_backend.lua @@ -0,0 +1,158 @@ +-- fibers/io/mem_backend.lua +-- +-- In-memory duplex pair (pipe-like) backend. +-- +-- Backend contract towards fibers.io.stream: +-- * kind() -> "mem" +-- * fileno() -> nil +-- * read_string(max) -> str|nil, err|nil +-- - str == nil : would block +-- - str == "" : EOF +-- * write_string(str) -> n|nil, err|nil +-- - n == nil : would block +-- * on_readable(task) -> token{ unlink = fn } +-- * on_writable(task) -> token{ unlink = fn } +-- * close() -> ok, err|nil + +local runtime = require 'fibers.runtime' +local wait = require 'fibers.wait' +local bytes = require 'fibers.utils.bytes' + +local RingBuf = bytes.RingBuf + +local function new_half(bufsize) + local H = { + rx = RingBuf.new(bufsize or 4096), + r_wait = wait.new_waitset(), + w_wait = wait.new_waitset(), + peer = nil, + rx_closed = false, -- peer has closed its write side + closed = false, -- this half has been closed + } + + local function schedule_all(waitset, key) + local sched = runtime.current_scheduler + waitset:notify_all(key, sched) + end + + function H:kind() + return 'mem' + end + + function H:fileno() + return nil + end + + -------------------------------------------------------------------- + -- String-oriented I/O, for use by fibers.io.stream + -------------------------------------------------------------------- + + -- Read up to max bytes as a Lua string. + -- * returns "" when peer has closed and no more data → EOF. + -- * returns nil, nil when no data and not closed yet → would block. + function H:read_string(max) + local have = self.rx:read_avail() + if have == 0 then + if self.rx_closed then + -- EOF + return '', nil + end + -- Would block + return nil, nil + end + + local n = math.min(have, max or have) + local chunk = self.rx:take(n) + + -- Space freed → wake peer writers. + local peer = self.peer + if peer then + schedule_all(peer.w_wait, peer) + end + + return chunk, nil + end + + -- Write a Lua string into the peer's receive buffer. + -- * returns nil, "closed" if peer is gone. + -- * returns nil, nil when no space → would block. + function H:write_string(str) + local peer = self.peer + if not peer or peer.rx_closed then + return nil, 'closed' + end + + local len = #str + if len == 0 then + return 0, nil + end + + local room = peer.rx:write_avail() + if room == 0 then + if peer.rx_closed then + return nil, 'closed' + end + -- Would block. + return nil, nil + end + + local n = math.min(room, len) + peer.rx:put(str:sub(1, n)) + + -- Data available → wake peer readers. + schedule_all(peer.r_wait, peer) + + return n, nil + end + + -------------------------------------------------------------------- + -- Readiness registration + -------------------------------------------------------------------- + + function H:on_readable(task) + -- Key by this half; effectively one bucket. + return self.r_wait:add(self, task) + end + + function H:on_writable(task) + return self.w_wait:add(self, task) + end + + -------------------------------------------------------------------- + -- Lifecycle + -------------------------------------------------------------------- + + function H:close() + if self.closed then + return true + end + self.closed = true + + local peer = self.peer + self.peer = nil + + -- Wake any local waiters so they can observe closure. + schedule_all(self.r_wait, self) + schedule_all(self.w_wait, self) + + if peer then + -- Indicate EOF to the peer's read side and wake its waiters. + peer.rx_closed = true + schedule_all(peer.r_wait, peer) + schedule_all(peer.w_wait, peer) + peer.peer = nil + end + + return true + end + + return H +end + +local function pipe(bufsize) + local a, b = new_half(bufsize), new_half(bufsize) + a.peer, b.peer = b, a + return a, b +end + +return { pipe = pipe } diff --git a/src/fibers/io/poller.lua b/src/fibers/io/poller.lua new file mode 100644 index 0000000..16c22bc --- /dev/null +++ b/src/fibers/io/poller.lua @@ -0,0 +1,20 @@ +-- fibers/io/poller.lua +-- +-- Poller shim: chooses the best available backend. +-- Order matters: we prefer epoll (FFI) when possible, then fall back +-- to a pure-luaposix select/poll implementation. + +local candidates = { + 'fibers.io.poller.epoll', -- Linux + FFI/epoll + 'fibers.io.poller.select', -- luaposix poll/select + 'fibers.io.poller.nixio', -- nixio poll/select +} + +for _, name in ipairs(candidates) do + local ok, mod = pcall(require, name) + if ok and type(mod) == 'table' and mod.is_supported and mod.is_supported() then + return mod + end +end + +error('fibers.io.poller: no suitable poller backend available on this platform') diff --git a/src/fibers/io/poller/core.lua b/src/fibers/io/poller/core.lua new file mode 100644 index 0000000..fa38e57 --- /dev/null +++ b/src/fibers/io/poller/core.lua @@ -0,0 +1,183 @@ +-- fibers/io/poller/core.lua +-- +-- Core glue for poller backends. +-- +-- This module owns the public Poller shape and semantics. +-- Platform backends provide only low-level primitives; build_poller +-- wires those into a concrete { get, Poller, is_supported } module. +-- +-- Backend ops contract: +-- ops.new_backend() -> backend_state +-- ops.poll(backend_state, timeout_ms, rd_waitset, wr_waitset) -> events +-- events is a table: events[fd] = { rd = bool, wr = bool, err = bool } +-- ops.on_wait_change(backend_state, fd, want_rd, want_wr) -- optional +-- ops.close_backend(backend_state) -- optional +-- ops.is_supported() -> boolean -- optional +-- +---@module 'fibers.io.poller.core' + +local runtime = require 'fibers.runtime' +local wait = require 'fibers.wait' + +---@class Poller : TaskSource +---@field backend_state any +---@field rd Waitset +---@field wr Waitset +---@field _ops table +local Poller = {} +Poller.__index = Poller + +---------------------------------------------------------------------- +-- Internal helpers +---------------------------------------------------------------------- + +local function recompute_mask(self, fd) + local ops = self._ops + if not ops.on_wait_change then + return + end + local want_rd = not self.rd:is_empty(fd) + local want_wr = not self.wr:is_empty(fd) + ops.on_wait_change(self.backend_state, fd, want_rd, want_wr) +end + +local function seconds_to_ms(timeout) + if timeout == nil then + -- Non-blocking poll. + return 0 + elseif timeout < 0 then + -- “Infinite” block. + return -1 + else + return math.floor(timeout * 1e3 + 0.5) + end +end + +---------------------------------------------------------------------- +-- Public methods +---------------------------------------------------------------------- + +--- Register a task as waiting on an fd for read or write readiness. +---@param fd integer +---@param dir '"rd"'|'"wr"' +---@param task Task +---@return WaitToken +function Poller:wait(fd, dir, task) + -- assert(type(fd) == "number", "fd must be number") + assert(fd ~= nil, 'fd must be non-nil') + assert(dir == 'rd' or dir == 'wr', "dir must be 'rd' or 'wr'") + + local ws = (dir == 'rd') and self.rd or self.wr + local token = ws:add(fd, task) + + -- Update backend subscription / mask. + recompute_mask(self, fd) + + -- Wrap unlink to keep backend state in sync with waitsets. + local original_unlink = token.unlink + local owner = self + + function token.unlink(tok) + local emptied = original_unlink(tok) + if emptied then + recompute_mask(owner, fd) + end + return emptied + end + + return token +end + +--- TaskSource hook: wait for events and schedule ready tasks. +---@param sched Scheduler +---@param _ number|nil -- current monotonic time (unused) +---@param timeout number|nil -- seconds +function Poller:schedule_tasks(sched, _, timeout) + local ops = self._ops + + local timeout_ms = seconds_to_ms(timeout) + local events = ops.poll(self.backend_state, timeout_ms, self.rd, self.wr) + if not events then + -- Backend chose to do nothing (e.g. no fds registered). + return + end + + for fd, flags in pairs(events) do + if flags.rd or flags.err then + self.rd:notify_all(fd, sched) + end + if flags.wr or flags.err then + self.wr:notify_all(fd, sched) + end + + -- Re-arm / update backend subscription after delivering events. + recompute_mask(self, fd) + end +end + +-- Used by the scheduler. +Poller.wait_for_events = Poller.schedule_tasks + +function Poller:close() + if self.backend_state and self._ops.close_backend then + self._ops.close_backend(self.backend_state) + end + self.backend_state = nil +end + +---------------------------------------------------------------------- +-- Builder +---------------------------------------------------------------------- + +--- Build a concrete poller module from low-level ops. +--- +--- See top-of-file comment for ops contract. +--- +---@param ops table +---@return table poller_module -- { get = fn, Poller = Poller, is_supported = fn } +local function build_poller(ops) + assert(type(ops) == 'table', 'poller ops must be a table') + assert(type(ops.new_backend) == 'function', 'ops.new_backend must be a function') + assert(type(ops.poll) == 'function', 'ops.poll must be a function') + + local function new_poller() + local backend_state = ops.new_backend() + local self = { + backend_state = backend_state, + rd = wait.new_waitset(), + wr = wait.new_waitset(), + _ops = ops, + } + return setmetatable(self, Poller) + end + + local singleton + + local function get() + if singleton then + return singleton + end + singleton = new_poller() + local sched = runtime.current_scheduler + assert(sched.add_task_source, 'scheduler must implement add_task_source') + sched:add_task_source(singleton) + return singleton + end + + local function is_supported() + if type(ops.is_supported) == 'function' then + return not not ops.is_supported() + end + return true + end + + return { + get = get, + Poller = Poller, + is_supported = is_supported, + } +end + +return { + build_poller = build_poller, +} diff --git a/src/fibers/io/poller/epoll.lua b/src/fibers/io/poller/epoll.lua new file mode 100644 index 0000000..13400da --- /dev/null +++ b/src/fibers/io/poller/epoll.lua @@ -0,0 +1,354 @@ +-- fibers/io/poller/epoll.lua +-- +-- Linux epoll-based poller backend. +-- Supports LuaJIT (ffi) and PUC Lua with cffi via ffi_compat. +-- Intended to be selected via fibers.io.poller. +-- +---@module 'fibers.io.poller.epoll' + +local core = require 'fibers.io.poller.core' +local safe = require 'coxpcall' +local bit = rawget(_G, 'bit') or require 'bit32' +local ffi_c = require 'fibers.utils.ffi_compat' + +---------------------------------------------------------------------- +-- FFI / CFFI setup via ffi_compat +---------------------------------------------------------------------- + +if not (ffi_c.is_supported and ffi_c.is_supported()) then + return { is_supported = function () return false end } +end + +local ffi = ffi_c.ffi +local C = ffi_c.C +local ffi_tonumber = ffi_c.tonumber +local get_errno = ffi_c.errno + +local EPERM = 1 +local EINTR = 4 +local ENOENT = 2 +local EBADF = 9 + +local jit = rawget(_G, 'jit') +local ARCH = ffi.arch or ((jit and jit.arch) or 'x64') + +---------------------------------------------------------------------- +-- Low-level epoll bindings +---------------------------------------------------------------------- + +ffi.cdef [[ + typedef unsigned char uint8_t; + typedef unsigned int uint32_t; + typedef unsigned long long uint64_t; + + int epoll_create(int size); + int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); + int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); + + int close(int fd); + char *strerror(int errnum); +]] + +-- epoll_event layout differs by architecture. +if ARCH == 'x64' or ARCH == 'x86' then + ffi.cdef [[ + typedef struct epoll_event { + uint8_t raw[12]; // 4 bytes for events + 8 bytes for data + } epoll_event; + ]] +elseif ARCH == 'mips' or ARCH == 'mipsel' or ARCH == 'arm64' then + ffi.cdef [[ + typedef struct epoll_event { + uint32_t events; + uint64_t data; + } epoll_event; + ]] +else + error('fibers.io.poller.epoll: unsupported architecture ' .. tostring(ARCH)) +end + +-- Event bits. +local EPOLLIN = 0x00000001 +local EPOLLOUT = 0x00000004 +local EPOLLERR = 0x00000008 +local EPOLLHUP = 0x00000010 +local EPOLLRDHUP = 0x00002000 +local EPOLLONESHOT = bit.lshift(1, 30) + +local EPOLL_CTL_ADD = 1 +local EPOLL_CTL_DEL = 2 +local EPOLL_CTL_MOD = 3 + +-- Architecture-dependent field access. +local get_event, set_event, get_data, set_data + +if ARCH == 'x64' or ARCH == 'x86' then + get_event = function (ev) return ffi.cast('uint32_t*', ev.raw)[0] end + set_event = function (ev, value) ffi.cast('uint32_t*', ev.raw)[0] = value end + get_data = function (ev) return ffi.cast('uint64_t*', ev.raw + 4)[0] end + set_data = function (ev, value) ffi.cast('uint64_t*', ev.raw + 4)[0] = value end +else + get_event = function (ev) return ev.events end + set_event = function (ev, value) ev.events = value end + get_data = function (ev) return ev.data end + set_data = function (ev, value) ev.data = value end +end + +local function wrap_error(ret) + if ret == -1 then + local errno = get_errno() + local err = ffi.string(C.strerror(errno)) + return nil, err, errno + end + return ret, nil, nil +end + +local function epoll_create() + local fd, err, errno = wrap_error(C.epoll_create(1)) + if not fd then + error(err or ('epoll_create failed (errno ' .. tostring(errno) .. ')')) + end + return fd +end + +local function epoll_ctl_add(epfd, fd, mask) + local ev = ffi.new('struct epoll_event') + set_event(ev, mask) + set_data(ev, fd) + return wrap_error(C.epoll_ctl(epfd, EPOLL_CTL_ADD, fd, ev)) +end + +local function epoll_ctl_mod(epfd, fd, mask) + local ev = ffi.new('struct epoll_event') + set_event(ev, mask) + set_data(ev, fd) + return wrap_error(C.epoll_ctl(epfd, EPOLL_CTL_MOD, fd, ev)) +end + +local function epoll_ctl_del(epfd, fd) + return wrap_error(C.epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nil)) +end + +local function epoll_wait(epfd, timeout_ms, max_events) + local events = ffi.new('struct epoll_event[?]', max_events) + local n = C.epoll_wait(epfd, events, max_events, timeout_ms or 0) + if n == -1 then + local errno = get_errno() + if errno == EINTR then + return {}, nil, errno + end + local err = ffi.string(C.strerror(errno)) + return nil, err, errno + end + + local res = {} + for i = 0, n - 1 do + local fd = assert(ffi_tonumber(get_data(events[i]))) + local event = assert(ffi_tonumber(get_event(events[i]))) + res[fd] = event + end + return res, nil, nil +end + +local function epoll_close(epfd) + return wrap_error(C.close(epfd)) +end + +---------------------------------------------------------------------- +-- Epoll wrapper object +---------------------------------------------------------------------- + +---@class EpollState +---@field epfd integer +---@field active_events table +---@field unpollable table -- fds that return EPERM to epoll_ctl +---@field maxevents integer +local Epoll = {} +Epoll.__index = Epoll + +local INITIAL_MAXEVENTS = 8 + +local function new_epoll() + local ret = { + epfd = epoll_create(), + active_events = {}, + unpollable = {}, + maxevents = INITIAL_MAXEVENTS, + } + return setmetatable(ret, Epoll) +end + +local RD = EPOLLIN + EPOLLRDHUP +local WR = EPOLLOUT +local ERR = EPOLLERR + EPOLLHUP + +local function die_ctl(opname, fd, err, errno) + error((opname .. ' failed for fd ' .. tostring(fd) .. ' (' .. tostring(err) .. ', errno ' .. tostring(errno) .. ')')) +end + +function Epoll:add(fd, events) + -- Once an fd is known to be unpollable, do not try to epoll_ctl it again. + if self.unpollable[fd] then + return + end + + local active = self.active_events[fd] or 0 + local eventmask = bit.bor(events, active, EPOLLONESHOT) + + -- Try MOD first (common case). + local ok, err, eno = epoll_ctl_mod(self.epfd, fd, eventmask) + if ok then + self.active_events[fd] = eventmask + return + end + + -- EPERM: fd type not supported by epoll (e.g. regular file). Treat as unpollable. + if eno == EPERM then + self.active_events[fd] = nil + self.unpollable[fd] = true + return + end + + -- Not currently registered (or MOD failed): try ADD. + local ok2, err2, eno2 = epoll_ctl_add(self.epfd, fd, eventmask) + if ok2 then + self.active_events[fd] = eventmask + return + end + + if eno2 == EPERM then + self.active_events[fd] = nil + self.unpollable[fd] = true + return + end + + die_ctl('epoll_ctl(ADD)', fd, err2 or err, eno2 or eno) +end + +function Epoll:poll(timeout_ms) + local evmap, err, errno = epoll_wait(self.epfd, timeout_ms or 0, self.maxevents) + if not evmap then + error(err or ('epoll_wait failed (errno ' .. tostring(errno) .. ')')) + end + + local count = 0 + for fd, _ in pairs(evmap) do + count = count + 1 + self.active_events[fd] = nil + end + if count == self.maxevents then + self.maxevents = self.maxevents * 2 + end + + return evmap +end + +function Epoll:del(fd) + -- If this fd was unpollable, there is no kernel state to delete. + if self.unpollable[fd] then + self.unpollable[fd] = nil + self.active_events[fd] = nil + return + end + + local ok, err, errno = epoll_ctl_del(self.epfd, fd) + if not ok then + if errno == ENOENT or errno == EBADF then + self.active_events[fd] = nil + return + end + die_ctl('epoll_ctl(DEL)', fd, err, errno) + end + + self.active_events[fd] = nil +end + +function Epoll:close() + epoll_close(self.epfd) + self.epfd = nil + self.active_events = {} + self.unpollable = {} +end + +---------------------------------------------------------------------- +-- Backend ops for poller.core +---------------------------------------------------------------------- + +local function new_backend() + return new_epoll() +end + +local function on_wait_change(ep, fd, want_rd, want_wr) + local mask = 0 + if want_rd then mask = bit.bor(mask, RD) end + if want_wr then mask = bit.bor(mask, WR) end + + if mask ~= 0 then + ep:add(fd, mask) + else + ep:del(fd) + end +end + +local function poll_backend(ep, timeout_ms, rd_waitset, wr_waitset) + local events = {} + + -- Synthesize readiness for fds that epoll cannot watch (EPERM). + -- This keeps Poller:wait and backend registration exception-free. + local had_synthetic = false + if ep.unpollable then + for fd, _ in pairs(ep.unpollable) do + local rd = rd_waitset and (not rd_waitset:is_empty(fd)) or false + local wr = wr_waitset and (not wr_waitset:is_empty(fd)) or false + if rd or wr then + had_synthetic = true + events[fd] = { rd = rd, wr = wr, err = false } + end + end + end + + -- If we have synthetic events to deliver, do not block in epoll_wait. + local real_timeout = had_synthetic and 0 or timeout_ms + + local evmap = ep:poll(real_timeout) + for fd, ev in pairs(evmap) do + local flags = { + rd = bit.band(ev, RD + ERR) ~= 0, + wr = bit.band(ev, WR + ERR) ~= 0, + err = bit.band(ev, ERR) ~= 0, + } + local cur = events[fd] + if cur then + -- Merge with any synthetic readiness. + cur.rd = cur.rd or flags.rd + cur.wr = cur.wr or flags.wr + cur.err = cur.err or flags.err + else + events[fd] = flags + end + end + + return events +end + +local function close_backend(ep) + ep:close() +end + +local function is_supported() + local ok = safe.pcall(function () + local e = new_epoll() + e:close() + end) + return ok +end + +local ops = { + new_backend = new_backend, + on_wait_change = on_wait_change, + poll = poll_backend, + close_backend = close_backend, + is_supported = is_supported, +} + +return core.build_poller(ops) diff --git a/src/fibers/io/poller/nixio.lua b/src/fibers/io/poller/nixio.lua new file mode 100644 index 0000000..de3cae4 --- /dev/null +++ b/src/fibers/io/poller/nixio.lua @@ -0,0 +1,105 @@ +-- fibers/io/poller/nixio.lua +-- +-- nixio.poll()-based poller backend (no epoll / luaposix dependency). +-- Intended to be selected via fibers.io.poller. +-- +---@module 'fibers.io.poller.nixio' + +local core = require 'fibers.io.poller.core' +local nixio = require 'nixio' + +---------------------------------------------------------------------- +-- Backend ops for poller.core +---------------------------------------------------------------------- + +local function new_backend() + -- No persistent kernel state required; everything is derived from the + -- current waitsets on each poll call. + return {} +end + +--- Build the fds table in the shape expected by nixio.poll: +--- fds[i] = { fd = , events = } +--- +--- Here the "fd" field is the nixio object itself; poll() accepts that. +local function build_fds(rd_waitset, wr_waitset) + local fds = {} + local index = 1 + + -- We need to iterate over the union of keys in both waitsets. + local seen = {} + + for fd, list in pairs(rd_waitset.buckets) do + if list and #list > 0 then + local events = nixio.poll_flags('in') + fds[index] = { fd = fd, events = events } + seen[fd] = index + index = index + 1 + end + end + + for fd, list in pairs(wr_waitset.buckets) do + if list and #list > 0 then + local pos = seen[fd] + if pos then + local e = fds[pos] + e.events = nixio.poll_flags(e.events, 'out') + else + local events = nixio.poll_flags('out') + fds[index] = { fd = fd, events = events } + seen[fd] = index + index = index + 1 + end + end + end + + return fds +end + +local function poll_backend(_, timeout_ms, rd_waitset, wr_waitset) + local fds = build_fds(rd_waitset, wr_waitset) + + -- nixio.poll(fds, timeout_ms) -> nready, fds' + local nready, fds_ret, _, _ = nixio.poll(fds, timeout_ms) + + if not nready then + -- Treat EINTR as benign; anything else can reasonably surface. + -- For a "simple" backend you can choose to treat all errors as + -- "no events"; if you prefer you can error() here instead. + return {} + end + + if nready == 0 then + return {} + end + + local events = {} + + for _, info in pairs(fds_ret) do + local revents = info.revents or 0 + if revents ~= 0 then + local flags = nixio.poll_flags(revents) + events[info.fd] = { + rd = not not (flags['in'] or flags.hup or flags.err or flags.nval), + wr = not not (flags.out or flags.err or flags.nval), + err = not not (flags.err or flags.nval), + } + end + end + + return events +end + +local function is_supported() + local ok = pcall(require, 'nixio') + return ok +end + +local ops = { + new_backend = new_backend, + poll = poll_backend, + -- on_wait_change not needed; state is rebuilt each poll. + is_supported = is_supported, +} + +return core.build_poller(ops) diff --git a/src/fibers/io/poller/select.lua b/src/fibers/io/poller/select.lua new file mode 100644 index 0000000..b0901b7 --- /dev/null +++ b/src/fibers/io/poller/select.lua @@ -0,0 +1,136 @@ +-- fibers/io/poller/select.lua +-- +-- luaposix.poll()-based poller backend (no epoll required). +-- Intended to be selected via fibers.io.poller. +-- +---@module 'fibers.io.poller.select' + +local core = require 'fibers.io.poller.core' + +-- Try to load luaposix poll support. +local ok, poll_mod = pcall(require, 'posix.poll') +if not ok or type(poll_mod) ~= 'table' or type(poll_mod.poll) ~= 'function' then + return { + is_supported = function () return false end, + } +end +local errno_mod = require 'posix.errno' + +local poll_fn = poll_mod.poll + +local function wake_all_waiters(rd_waitset, wr_waitset) + local events = {} + + for fd, list in pairs(rd_waitset.buckets) do + if list and #list > 0 then + local e = events[fd] or {} + e.rd = true + events[fd] = e + end + end + + for fd, list in pairs(wr_waitset.buckets) do + if list and #list > 0 then + local e = events[fd] or {} + e.wr = true + events[fd] = e + end + end + + return events +end + +---------------------------------------------------------------------- +-- Backend ops for poller.core +---------------------------------------------------------------------- + +local function new_backend() + -- No persistent kernel state required for poll(); everything is + -- derived from the current waitsets on each poll call. + return {} +end + +--- Build the fds table in the shape expected by posix.poll.poll: +--- fds[fd] = { events = { IN = true, OUT = true } } +local function build_fds(rd_waitset, wr_waitset) + local fds = {} + + -- Any fd with one or more read waiters gets IN. + for fd, list in pairs(rd_waitset.buckets) do + if list and #list > 0 then + local e = fds[fd] + if not e then + e = { events = {} } + fds[fd] = e + end + e.events.IN = true + end + end + + -- Any fd with one or more write waiters gets OUT. + for fd, list in pairs(wr_waitset.buckets) do + if list and #list > 0 then + local e = fds[fd] + if not e then + e = { events = {} } + fds[fd] = e + end + e.events.OUT = true + end + end + + return fds +end + +local function poll_backend(_, timeout_ms, rd_waitset, wr_waitset) + local fds = build_fds(rd_waitset, wr_waitset) + + -- poll() with nfds == 0 is defined and just sleeps for timeout. + local nready, err, eno = poll_fn(fds, timeout_ms) + if nready == nil then + -- Treat EINTR as a benign interruption. Unlike epoll, the + -- luaposix SIGCHLD backend may rely on poll() being interrupted + -- rather than on a self-pipe write under LuaJIT. Wake current + -- waiters spuriously so their non-blocking step functions can + -- observe any completed work and then re-register if needed. + if eno == errno_mod.EINTR then + return wake_all_waiters(rd_waitset, wr_waitset) + end + error(('%s (errno %s)'):format(tostring(err), tostring(eno))) + end + if nready == 0 then + return {} + end + + local events = {} + + -- luaposix reports readiness in fds[fd].revents with flags + -- such as IN, OUT, ERR, HUP, NVAL. + for fd, info in pairs(fds) do + local re = info.revents + if re then + local rd_flag = re.IN or re.HUP or re.ERR or re.NVAL + local wr_flag = re.OUT or re.ERR or re.NVAL + local err_flag = re.ERR or re.NVAL + + if rd_flag or wr_flag or err_flag then + events[fd] = { + rd = not not rd_flag, + wr = not not wr_flag, + err = not not err_flag, + } + end + end + end + + return events +end + +local ops = { + new_backend = new_backend, + poll = poll_backend, + -- on_wait_change: not needed for poll(); state is rebuilt each time. + is_supported = function () return true end, +} + +return core.build_poller(ops) diff --git a/src/fibers/io/socket.lua b/src/fibers/io/socket.lua new file mode 100644 index 0000000..87a6595 --- /dev/null +++ b/src/fibers/io/socket.lua @@ -0,0 +1,486 @@ +-- fibers/io/socket.lua +-- +-- Socket helpers on top of fd_backend + stream. +-- +-- Exposes: +-- socket(domain, stype, protocol?) -> Socket +-- listen_unix(path, opts?) -> Socket (listening AF_UNIX) +-- connect_unix(path, stype?, proto?) -> Stream +-- listen_inet(host, port, opts?) -> Socket (listening AF_INET) +-- connect_inet(host, port, opts?) -> Stream +-- +-- Socket supports: +-- :bind(sa) +-- :listen() +-- :listen_unix(path) +-- :listen_inet(host, port) +-- :accept_op() +-- :accept() +-- :connect_op(sa) +-- :connect(sa) +-- :connect_unix_op(path) +-- :connect_unix(path) +-- :connect_inet_op(host, port) +-- :connect_inet(host, port) +-- :close() +-- +---@module 'fibers.io.socket' + +local wait = require 'fibers.wait' +local poller_mod = require 'fibers.io.poller' +local fd_backend = require 'fibers.io.fd_backend' +local stream_mod = require 'fibers.io.stream' +local perform = require 'fibers.performer'.perform + +---@class Socket +---@field fd integer|nil +local Socket = {} +Socket.__index = Socket + +---------------------------------------------------------------------- +-- Internal helpers +---------------------------------------------------------------------- + +--- Wrap an fd as a full-duplex Stream. +---@param fd integer +---@param filename? string +---@return Stream +local function fd_to_stream(fd, filename) + local io = fd_backend.new(fd, { filename = filename }) + return stream_mod.open(io, true, true) +end + +--- Create a new non-blocking socket object from an fd. +---@param fd integer +---@return Socket +local function new_socket(fd) + local ok, err = fd_backend.set_nonblock(fd) + if not ok then + fd_backend.close_fd(fd) + error('set_nonblock(socket fd) failed: ' .. tostring(err)) + end + return setmetatable({ fd = fd }, Socket) +end + +--- Return underlying fd or error if closed. +---@return integer +function Socket:_fd() + local fd = self.fd + assert(fd, 'socket is closed') + return fd +end + +--- Build an AF_INET sockaddr token understood by fd_backend. +---@param host string +---@param port number|string +---@return table|nil sa, any err +local function inet_sa(host, port) + if type(host) ~= 'string' or host == '' then + return nil, 'host must be a non-empty string' + end + + port = tonumber(port) + if not port or port < 0 or port > 65535 then + return nil, 'port must be 0..65535' + end + + return { + family = 'inet', + host = host, + port = math.floor(port), + } +end + +---------------------------------------------------------------------- +-- Constructors +---------------------------------------------------------------------- + +--- Create a new non-blocking socket via the backend. +---@param domain integer +---@param stype integer +---@param protocol? integer +---@return Socket|nil s, any err +local function socket(domain, stype, protocol) + local fd, err = fd_backend.socket(domain, stype, protocol or 0) + if not fd then + return nil, err + end + + local ok, nerr = fd_backend.set_nonblock(fd) + if not ok then + fd_backend.close_fd(fd) + return nil, nerr + end + + return new_socket(fd) +end + +---------------------------------------------------------------------- +-- Generic bind/listen helpers +---------------------------------------------------------------------- + +--- Bind this socket to an address token (UNIX path string or inet table). +---@param sa any +---@return boolean|nil ok, any err +function Socket:bind(sa) + local fd = self:_fd() + local ok, err = fd_backend.bind(fd, sa) + if not ok then + return nil, ('bind failed: %s'):format(tostring(err)) + end + return true +end + +--- Mark this socket as listening. +---@return boolean|nil ok, any err +function Socket:listen() + local fd = self:_fd() + local ok, err = fd_backend.listen(fd) + if not ok then + return nil, ('listen failed: %s'):format(tostring(err)) + end + return true +end + +---------------------------------------------------------------------- +-- Listening and address helpers (UNIX / INET) +---------------------------------------------------------------------- + +--- Listen on a UNIX-domain path using this Socket. +---@param path string +---@return boolean|nil ok, any err +function Socket:listen_unix(path) + local ok, err = self:bind(path) + if not ok then + return nil, err + end + return self:listen() +end + +--- Bind this socket to an IPv4 address/port. +---@param host string +---@param port number|string +---@return boolean|nil ok, any err +function Socket:bind_inet(host, port) + local sa, err = inet_sa(host, port) + if not sa then + return nil, err + end + return self:bind(sa) +end + +--- Listen on an IPv4 address/port using this Socket. +---@param host string +---@param port number|string +---@return boolean|nil ok, any err +function Socket:listen_inet(host, port) + local ok, err = self:bind_inet(host, port) + if not ok then + return nil, err + end + return self:listen() +end + +---------------------------------------------------------------------- +-- accept() as an Op +---------------------------------------------------------------------- + +--- Build an Op that accepts a connection and returns a Stream. +---@return Op +function Socket:accept_op() + local P = poller_mod.get() + local fd = self:_fd() + + local function step() + local new_fd, err, again = fd_backend.accept(fd) + if new_fd then + return true, new_fd, nil + end + if again then + return false + end + return true, nil, err + end + + local function register(task) + return P:wait(fd, 'rd', task) + end + + local function wrap(new_fd, err) + if not new_fd then + return nil, err + end + return fd_to_stream(new_fd) + end + + return wait.waitable(register, step, wrap) +end + +--- Accept a connection synchronously into a Stream. +---@return Stream|nil client, any err +function Socket:accept() + return perform(self:accept_op()) +end + +---------------------------------------------------------------------- +-- connect() as an Op (generic sockaddr token) +---------------------------------------------------------------------- + +--- Build an Op that connects this Socket to an address token. +--- sa may be: +--- * UNIX path string +--- * { family = 'inet', host = '1.2.3.4', port = 1234 } +---@param sa any +---@return Op +function Socket:connect_op(sa) + local P = poller_mod.get() + local fd = self:_fd() + local state = 'initial' + + local function step() + if state == 'initial' then + local ok, err, inprogress = fd_backend.connect_start(fd, sa) + if ok then + return true, true, nil + end + if inprogress then + state = 'waiting' + return false + end + return true, false, err + elseif state == 'waiting' then + local ok, err = fd_backend.connect_finish(fd) + if not ok then + return true, false, err + end + return true, true, nil + else + return true, false, 'invalid connect state' + end + end + + local function register(task) + return P:wait(fd, 'wr', task) + end + + local function wrap(ok, err) + if not ok then + return nil, err + end + local new_fd = fd + self.fd = nil -- hand ownership to Stream + return fd_to_stream(new_fd) + end + + return wait.waitable(register, step, wrap) +end + +--- Connect synchronously and return a Stream. +---@param sa any +---@return Stream|nil stream, any err +function Socket:connect(sa) + return perform(self:connect_op(sa)) +end + +---------------------------------------------------------------------- +-- UNIX-domain convenience +---------------------------------------------------------------------- + +--- Build an Op that connects this socket to a UNIX-domain path. +---@param path string +---@return Op +function Socket:connect_unix_op(path) + return self:connect_op(path) +end + +--- Connect synchronously to a UNIX-domain path. +---@param path string +---@return Stream|nil stream, any err +function Socket:connect_unix(path) + return perform(self:connect_unix_op(path)) +end + +--- Listen on a UNIX-domain path and return a listening Socket. +---@param path string +---@param opts? { stype?: integer, protocol?: integer, ephemeral?: boolean } +---@return Socket|nil s, any err +local function listen_unix(path, opts) + opts = opts or {} + + local stype = opts.stype or fd_backend.SOCK_STREAM + local protocol = opts.protocol or 0 + + local s, err = socket(fd_backend.AF_UNIX, stype, protocol) + if not s then + return nil, err + end + + local ok, lerr = s:listen_unix(path) + if not ok then + s:close() + return nil, lerr + end + + if opts.ephemeral then + local parent_close = s.close + function s:close() + local ok1, err1 = parent_close(self) + + local ok2, err2 = fd_backend.unlink(path) + if not ok2 then + return false, ('failed to remove %s: %s'):format( + tostring(path), + tostring(err2) + ) + end + + if ok1 == false then + return false, err1 + end + return true, nil + end + end + + return s +end + +--- Connect to a UNIX-domain socket path and return a Stream. +---@param path string +---@param stype? integer +---@param protocol? integer +---@return Stream|nil stream, any err +local function connect_unix(path, stype, protocol) + stype = stype or fd_backend.SOCK_STREAM + protocol = protocol or 0 + + local s, err = socket(fd_backend.AF_UNIX, stype, protocol) + if not s then + return nil, err + end + + local stream, cerr = s:connect_unix(path) + if not stream then + s:close() + return nil, cerr + end + return stream +end + +---------------------------------------------------------------------- +-- AF_INET convenience +---------------------------------------------------------------------- + +--- Build an Op that connects this socket to an IPv4 host/port. +---@param host string +---@param port number|string +---@return Op +function Socket:connect_inet_op(host, port) + local sa, err = inet_sa(host, port) + if not sa then + error(err, 2) + end + return self:connect_op(sa) +end + +--- Connect synchronously to an IPv4 host/port. +---@param host string +---@param port number|string +---@return Stream|nil stream, any err +function Socket:connect_inet(host, port) + return perform(self:connect_inet_op(host, port)) +end + +--- Listen on an IPv4 address/port and return a listening Socket. +---@param host string +---@param port number|string +---@param opts? { stype?: integer, protocol?: integer } +---@return Socket|nil s, any err +local function listen_inet(host, port, opts) + opts = opts or {} + + local stype = opts.stype or fd_backend.SOCK_STREAM + local protocol = opts.protocol or 0 + + local s, err = socket(fd_backend.AF_INET, stype, protocol) + if not s then + return nil, err + end + + local ok, lerr = s:listen_inet(host, port) + if not ok then + s:close() + return nil, lerr + end + + return s +end + +--- Connect to an IPv4 host/port and return a Stream. +--- opts.bind_host / opts.bind_port can be used to bind a source address/port first. +---@param host string +---@param port number|string +---@param opts? { stype?: integer, protocol?: integer, bind_host?: string, bind_port?: number|string } +---@return Stream|nil stream, any err +local function connect_inet(host, port, opts) + opts = opts or {} + + local stype = opts.stype or fd_backend.SOCK_STREAM + local protocol = opts.protocol or 0 + + local s, err = socket(fd_backend.AF_INET, stype, protocol) + if not s then + return nil, err + end + + if opts.bind_host ~= nil or opts.bind_port ~= nil then + local ok, berr = s:bind_inet(opts.bind_host or '0.0.0.0', opts.bind_port or 0) + if not ok then + s:close() + return nil, berr + end + end + + local stream, cerr = s:connect_inet(host, port) + if not stream then + s:close() + return nil, cerr + end + + return stream +end + +---------------------------------------------------------------------- +-- Lifecycle +---------------------------------------------------------------------- + +--- Close the underlying socket fd. +---@return boolean ok, any err +function Socket:close() + if self.fd then + local ok, err = fd_backend.close_fd(self.fd) + self.fd = nil + return ok, err + end + return true, nil +end + +---------------------------------------------------------------------- +-- Public API +---------------------------------------------------------------------- + +return { + socket = socket, + + listen_unix = listen_unix, + connect_unix = connect_unix, + + listen_inet = listen_inet, + connect_inet = connect_inet, + + Socket = Socket, + + -- re-export useful constants for callers + AF_UNIX = fd_backend.AF_UNIX, + AF_INET = fd_backend.AF_INET, + SOCK_STREAM = fd_backend.SOCK_STREAM, +} diff --git a/src/fibers/io/stream.lua b/src/fibers/io/stream.lua new file mode 100644 index 0000000..5d44f9b --- /dev/null +++ b/src/fibers/io/stream.lua @@ -0,0 +1,1016 @@ +-- fibers/io/stream.lua + +local wait = require 'fibers.wait' +local bytes = require 'fibers.utils.bytes' +local op = require 'fibers.op' +local perform = require 'fibers.performer'.perform +local runtime = require 'fibers.runtime' + +local RingBuf = bytes.RingBuf +local LinearBuf = bytes.LinearBuf + +---@class StreamBackend +---@field read_string fun(self: StreamBackend, max: integer): string|nil, any|nil, any|nil +---@field write_string fun(self: StreamBackend, data: string): integer|nil, any|nil, any|nil +---@field on_readable fun(self: StreamBackend, task: Task): WaitToken +---@field on_writable fun(self: StreamBackend, task: Task): WaitToken +---@field close fun(self: StreamBackend): boolean, any|nil +---@field seek fun(self: StreamBackend, whence: string, offset: integer): integer|nil, any|nil +---@field nonblock fun(self: StreamBackend)|nil +---@field block fun(self: StreamBackend)|nil +---@field filename string|nil +---@field fileno fun(self: StreamBackend): integer|nil + +---@class Stream +---@field io StreamBackend|nil +---@field rx any|nil +---@field tx any|nil +---@field line_buffering boolean +---@field _bufmode '"no"'|'"line"'|'"full"'|nil +---@field _bufsize integer|nil +---@field _ws Waitset +---@field _closed boolean +---@field _closing boolean +---@field _sticky_rerr any|nil +---@field _sticky_werr any|nil +---@field _big string|nil +---@field _big_off integer +---@field _pump_task Task +---@field _pump_token WaitToken|nil +---@field _pump_scheduled boolean +---@field _close_done boolean +---@field _close_ok boolean|nil +---@field _close_err any|nil +---@field _rd_owner any|nil +---@field _wr_owner any|nil +local Stream = {} +Stream.__index = Stream + +local DEFAULT_BUFFER_SIZE = 2 ^ 12 +local BIG_WRITE_CHUNK = 64 * 1024 + +-- Single internal wait key. Everything that could unblock someone notifies K_STATE. +local K_STATE = 'state' +local WANT_STATE = K_STATE + +---------------------------------------------------------------------- +-- Small helpers +---------------------------------------------------------------------- + +local function sched() return runtime.current_scheduler end + +-- Lifecycle predicates. +function Stream:_has_backend() return self.io ~= nil end + +-- No backend, or already fully terminated. +function Stream:_is_dead() return self._closed or (self.io == nil) end + +-- In the close handshake, but not yet torn down. +function Stream:_is_closing() return self._closing and not self._closed end + +function Stream:_is_readable() return self.rx ~= nil end + +function Stream:_is_writable() return self.tx ~= nil end + +local function token2(t1, t2) + return { + unlink = function () + if t1 and t1.unlink then t1:unlink() end + if t2 and t2.unlink then t2:unlink() end + return false + end, + } +end + +local NO_TOKEN = { unlink = function () return false end } + +function Stream:_signal_state() + self._ws:notify_all(K_STATE, sched()) +end + +local function drained_tx(self) + return (not self._big) and self.tx and (self.tx:read_avail() == 0) +end + +---------------------------------------------------------------------- +-- Lane serialisation (read/write) +---------------------------------------------------------------------- + +local function new_lane(stream, field) + local owner = {} + + local function acquire() + local cur = stream[field] + if cur == nil or cur == owner then + stream[field] = owner + return true + end + return false + end + + local function release() + if stream[field] == owner then + stream[field] = nil + stream:_signal_state() + if stream._closing and not stream._close_done then + stream:_finish_close_if_ready() + end + end + end + + -- Common wrapper; probe releases on would-block, run holds the lane. + local function wrap(step, release_on_fail) + return function () + if not acquire() then return false, WANT_STATE end + + local ok, v = step() + if ok then return true, v end + + if release_on_fail then release() end + + return false, v or WANT_STATE + end + end + + local function wrap_probe(step) return wrap(step, true) end + + local function wrap_run(step) return wrap(step, false) end + + return { release = release, wrap_probe = wrap_probe, wrap_run = wrap_run } +end + +---------------------------------------------------------------------- +-- Backend wait registration +---------------------------------------------------------------------- + +local function make_register(self, opts) + opts = opts or {} + local primed = false + + return function (task, waker, want) + -- Always register internal state, so close/pump changes wake everyone. + local t_state = self._ws:add(K_STATE, task) + + if opts.prime_once and not primed then + primed = true + waker:wakeup(task) + end + + -- Internal waits (or unspecified wants) just wait on state changes. + if want == K_STATE or want == nil or not (want == 'rd' or want == 'wr') then + return token2(t_state, NO_TOKEN) + end + + local io = self.io + if not io then + -- Ensure the task runs again and the step observes closure. + waker:wakeup(task) + return token2(t_state, NO_TOKEN) + end + + local tok + if want == 'wr' and io.on_writable then + tok = io:on_writable(task) + else + tok = io:on_readable(task) + end + + return token2(t_state, tok) + end +end + +---------------------------------------------------------------------- +-- Construction +---------------------------------------------------------------------- + +---@param io_backend StreamBackend +---@param readable? boolean +---@param writable? boolean +---@param bufsize? integer +---@return Stream +local function open(io_backend, readable, writable, bufsize) + bufsize = bufsize or DEFAULT_BUFFER_SIZE + + local s = setmetatable({ + io = io_backend, + line_buffering = false, + _ws = wait.new_waitset(), + _big_off = 0, + }, Stream) + + if readable ~= false then s.rx = RingBuf.new(bufsize) end + if writable ~= false then s.tx = RingBuf.new(bufsize) end + + s._pump_task = { run = function () s:_pump() end } + return s +end + +---@param x any +---@return boolean +local function is_stream(x) + return type(x) == 'table' and getmetatable(x) == Stream +end + +function Stream:nonblock() + if self.io and self.io.nonblock then self.io:nonblock() end +end + +function Stream:block() + if self.io and self.io.block then self.io:block() end +end + +---------------------------------------------------------------------- +-- Close / terminate +---------------------------------------------------------------------- + +function Stream:_latch_close(ok, err) + if self._close_done then return end + self._close_done = true + self._close_ok = ok + self._close_err = err + self:_signal_state() +end + +function Stream:_unlink_pump_wait() + local pt = self._pump_token + self._pump_token = nil + if pt and pt.unlink then pt:unlink() end +end + +function Stream:terminate(_) + -- Idempotent. + if self._closed then + if not self._close_done then + if self._sticky_werr ~= nil then + self:_latch_close(nil, self._sticky_werr) + else + self:_latch_close(true, nil) + end + end + self:_signal_state() + return + end + + self._closed = true + self._closing = true + self._rd_owner = nil + self._wr_owner = nil + + self:_unlink_pump_wait() + + local io = self.io + self.io = nil + + self.rx, self.tx = nil, nil + self._big, self._big_off = nil, 0 + + if io and io.close then + pcall(function () io:close() end) + end + + if not self._close_done then + if self._sticky_werr ~= nil then + self:_latch_close(nil, self._sticky_werr) + else + self:_latch_close(true, nil) + end + end + + self:_signal_state() +end + +function Stream:_finish_close_if_ready() + if self._close_done or self._closed then return end + if not self._closing then return end + + -- If writable, wait for drain or sticky write error. + if self.tx then + if self._sticky_werr ~= nil then + self:_latch_close(nil, self._sticky_werr) + self:terminate('closed') + return + end + if not drained_tx(self) then + return + end + end + + -- Do not tear down while a lane is owned. + if self._rd_owner ~= nil or self._wr_owner ~= nil then + return + end + + self:_latch_close(true, nil) + self:terminate('closed') +end + +function Stream:_begin_close(_) + if self._closed then + self:_signal_state() + self:_finish_close_if_ready() + return + end + self._closing = true + self:_signal_state() + self:_finish_close_if_ready() +end + +---@return Op +function Stream:close_op() + local register = make_register(self, { prime_once = true }) + + local function probe_step() + if self._close_done then + return true, function () return self._close_ok, self._close_err end + end + return false, WANT_STATE + end + + local function run_step() + if not self._closing and not self._closed then + self:_begin_close('closing') + end + + -- Ensure pending output makes progress if any. + if self.tx then self:_kick_pump() end + + self:_finish_close_if_ready() + + if self._close_done then + return true, function () return self._close_ok, self._close_err end + end + return false, WANT_STATE + end + + return wait.waitable2(register, probe_step, run_step) + :wrap(function (th) + local ok, err = th() + if ok then return true, nil end + return nil, err + end) +end + +---------------------------------------------------------------------- +-- Read path (choice-safe; ALWAYS returns thunks on ready) +---------------------------------------------------------------------- + +---@param stream Stream +---@param buf any +---@param min integer +---@param max integer +---@param terminator string|nil +---@return fun(): boolean, any +---@return fun(): boolean, any +local function make_read_steps(stream, buf, min, max, terminator) + local tally = 0 + local term_target = nil + local want_hint = WANT_STATE + + local function term_enabled() + return terminator ~= nil and terminator ~= '' + end + + local function maybe_clamp() + if term_target or not term_enabled() or not stream.rx then return end + local loc = stream.rx:find(terminator) + if not loc then return end + local final = tally + loc + #terminator + if final <= max then + term_target = final + min, max = final, final + end + end + + local function drain_once() + if not stream.rx then return end + local avail = stream.rx:read_avail() + if avail <= 0 or tally >= max then return end + local need = math.min(avail, max - tally) + if need <= 0 then return end + local chunk = stream.rx:take(need) + if chunk and #chunk > 0 then + buf:append(chunk) + tally = tally + #chunk + end + end + + local function drain_all() + if not stream.rx then return end + while tally < max do + local before = tally + drain_once() + if tally == before then break end + end + end + + -- Terminal check used for both probe and run; does not perform backend I/O. + -- Always returns either nil (not terminal) or a thunk. + local function terminal_thunk() + if stream._sticky_rerr ~= nil then + maybe_clamp() + return function () + drain_all() + return buf, tally, stream._sticky_rerr + end + end + if stream:_is_dead() or stream:_is_closing() then + return function () return buf, tally, 'closed' end + end + return nil + end + + local function probe_step() + local th = terminal_thunk() + if th then return true, th end + + maybe_clamp() + if tally >= min then + return true, function () return buf, tally, nil end + end + + -- Choice-safe: if rx has enough to satisfy min, return a drain thunk. + local rx = stream.rx + if rx and tally < max then + local avail = rx:read_avail() + if avail > 0 then + local possible = tally + math.min(avail, max - tally) + if possible >= min then + return true, function () + drain_once() + return buf, tally, nil + end + end + end + end + + return false, want_hint + end + + local function run_step() + while true do + local th = terminal_thunk() + if th then return true, th end + + maybe_clamp() + drain_once() + if tally >= min then + return true, function () return buf, tally, nil end + end + + local io = stream.io + if not (io and io.read_string) then + return true, function () return buf, tally, 'backend does not support read_string' end + end + + local room = stream.rx:write_avail() + if room <= 0 then + return true, function () return buf, tally, 'buffer capacity exhausted' end + end + + local data, err, want = io:read_string(room) + if err ~= nil then + stream._sticky_rerr = err + stream:_signal_state() + return true, function () return buf, tally, err end + end + + if data == nil then + want_hint = want or 'rd' + return false, want_hint + end + + if data == '' then + -- EOF + return true, function () return buf, tally, nil end + end + + stream.rx:put(data) + end + end + + return probe_step, run_step +end + +---@param buf any +---@param opts? { min?: integer, max?: integer, terminator?: string, eof_ok?: boolean } +---@return Op +function Stream:read_into_op(buf, opts) + assert(self.rx, 'stream is not readable') + + opts = opts or {} + local min = opts.min or 1 + local max = opts.max or min + local terminator = opts.terminator + local eof_ok = not not opts.eof_ok + + local lane = new_lane(self, '_rd_owner') + local probe_step, run_step = make_read_steps(self, buf, min, max, terminator) + + probe_step = lane.wrap_probe(probe_step) + run_step = lane.wrap_run(run_step) + + local register = make_register(self, { prime_once = true }) + + local function wrap(th) + local ret_buf, cnt, err = th() + lane.release() + + if cnt == 0 and not eof_ok then + return nil, 0, err + end + return ret_buf, cnt, err + end + + local ev = wait.waitable2(register, probe_step, run_step, wrap) + return ev:on_abort(function () lane.release() end) +end + +function Stream:core_read_op(opts) + local buf = LinearBuf.new() + local ev = self:read_into_op(buf, opts) + + return ev:wrap(function (ret_buf, cnt, err) + if not ret_buf then return nil, 0, err end + local s = ret_buf:tostring() + if cnt == 0 and s == '' then + return nil, 0, err + end + return s, cnt, err + end) +end + +function Stream:read_some_op(max) + assert(type(max) == 'number' and max >= 0, 'read_some_op: max must be non-negative') + if max == 0 then return op.always('', nil) end + + return self:core_read_op { min = 1, max = max, eof_ok = true } + :wrap(function (s, cnt, err) + if err ~= nil then return nil, err end + if not s or cnt == 0 then return nil, nil end + return s, nil + end) +end + +function Stream:read_exactly_op(n) + assert(type(n) == 'number' and n >= 0, 'read_exactly_op: n must be non-negative') + if n == 0 then return op.always('', nil) end + + return self:core_read_op { min = n, max = n, eof_ok = false } + :wrap(function (s, cnt, err) + if err ~= nil then return nil, err end + if not s or cnt ~= n then return nil, 'short read' end + return s, nil + end) +end + +function Stream:read_line_op(opts) + assert(self.rx, 'stream is not readable') + + opts = opts or {} + local term = opts.terminator or '\n' + local keep_term = not not opts.keep_terminator + + local ev = self:core_read_op { + min = math.huge, + max = math.huge, + terminator = term, + eof_ok = true, + } + + return ev:wrap(function (s, cnt, err) + if err ~= nil then return nil, err end + if not s or cnt == 0 then return nil, nil end + + if not keep_term and #term > 0 and s:sub(- #term) == term then + s = s:sub(1, - #term - 1) + end + + return s, nil + end) +end + +function Stream:read_all_op() + assert(self.rx, 'stream is not readable') + + return self:core_read_op { min = math.huge, max = math.huge, eof_ok = true } + :wrap(function (s, _, err) + if not s then return '', err end + return s, err + end) +end + +---------------------------------------------------------------------- +-- Buffered write pump +---------------------------------------------------------------------- + +function Stream:_kick_pump() + if self._pump_scheduled then return end + if self:_is_dead() then return end + self._pump_scheduled = true + sched():schedule(self._pump_task) +end + +local function next_write_chunk(self) + if self._big then + if self._big_off >= #self._big then + self._big = nil + self._big_off = 0 + self:_signal_state() + return nil + end + local remaining = #self._big - self._big_off + local take = remaining + if take > BIG_WRITE_CHUNK then take = BIG_WRITE_CHUNK end + return self._big:sub(self._big_off + 1, self._big_off + take), 'big' + end + + if self.tx and self.tx:read_avail() > 0 then + local avail = self.tx:read_avail() + if avail > BIG_WRITE_CHUNK then avail = BIG_WRITE_CHUNK end + return self.tx:peek(avail), 'ring' + end + + return nil +end + +local function advance_after_write(self, mode, n) + if mode == 'big' then + self._big_off = self._big_off + n + if self._big_off >= #self._big then + self._big = nil + self._big_off = 0 + end + self:_signal_state() + return + end + + self.tx:advance_read(n) + self:_signal_state() +end + +function Stream:_pump() + self._pump_scheduled = false + + local io = self.io + if self:_is_dead() or not io then return false end + if self._sticky_werr then return false end + if not (self.tx or self._big) then return false end + + self:_unlink_pump_wait() + + local progressed = false + + while true do + if self._sticky_werr or self:_is_dead() then break end + + local chunk, mode = next_write_chunk(self) + if not chunk or #chunk == 0 then break end + + local n, err, want = io:write_string(chunk) + if err then + self._sticky_werr = err + self:_signal_state() + break + end + + if n == nil or n == 0 then + -- Would block: arm readiness (poller is responsible for any EPERM cases). + local w = (want == 'rd') and 'rd' or 'wr' + if w == 'rd' and io.on_readable then + self._pump_token = io:on_readable(self._pump_task) + else + self._pump_token = io:on_writable(self._pump_task) + end + break + end + + progressed = true + advance_after_write(self, mode, n) + end + + if drained_tx(self) then self:_signal_state() end + + self:_finish_close_if_ready() + return progressed +end + +---------------------------------------------------------------------- +-- Buffered write ops +---------------------------------------------------------------------- + +-- Shared output-lane op builder. +-- kind: +-- * 'write' : publish bytes (buffer/big) and return (n|nil, err|nil) +-- * 'flush' : wait until outbound is drained and return (true|nil, err|nil) +local function output_lane_op(self, kind, str) + assert(kind == 'write' or kind == 'flush', 'output_lane_op: bad kind') + + local lane = new_lane(self, '_wr_owner') + local register = make_register(self) + + local function pending() + return (self._big ~= nil) or (self.tx and self.tx:read_avail() > 0) + end + + local function drained() return not pending() end + + -- Decide whether we can accept `str` into the outbound queue. + -- Terminal/error cases are checked by step() before calling this. + local function can_accept(len) + local mode = self._bufmode or 'full' + + if mode == 'no' then + -- Do not allow queuing; require fully drained output. + if pending() then return false end + return true, 'big' + end + + -- Existing buffered behaviour. + if self._big then return false end + + local cap = self.tx:capacity() + if len <= self.tx:write_avail() then return true, 'ring' end + if self.tx:read_avail() == 0 and len > cap then return true, 'big' end + + return false + end + + local function publish(mode, s) + if mode == 'ring' then + self.tx:put(s) + else + self._big = s + self._big_off = 0 + end + end + + local function rollback_published(mode) + -- Only used in the idle-fast-path failure case. + -- Safe because was_idle implies there was no prior pending output. + if mode == 'ring' then + if self.tx then self.tx:reset() end + else + self._big, self._big_off = nil, 0 + end + end + + local function step(is_probe) + -- Sticky backend write error always wins. + if self._sticky_werr ~= nil then + local e = self._sticky_werr + return true, function () return nil, e end + end + + if kind == 'write' then + -- Writes do not proceed once closing/closed or backend absent. + if self:_is_dead() or self:_is_closing() then + return true, function () return nil, 'closed' end + end + + local len = #str + local ok, mode = can_accept(len) + if not ok then + if not is_probe then self:_kick_pump() end + return false, WANT_STATE + end + + local was_idle = drained() + + return true, function () + publish(mode, str) + + -- Opportunistic progress when previously idle; surfaces peer-close promptly. + local progressed = false + if was_idle then + progressed = self:_pump() or false + end + + -- If the very first attempt discovers a terminal error before any progress, + -- fail this write (and drop the just-published bytes). + if was_idle and not progressed and self._sticky_werr ~= nil then + local e = self._sticky_werr + rollback_published(mode) + self:_signal_state() + return nil, e + end + + if pending() and not self._pump_token then + self:_kick_pump() + end + + self:_signal_state() + return len, nil + end + end + + -- kind == 'flush' + if self:_is_dead() then + if drained() then + return true, function () return true, nil end + end + return true, function () return nil, 'closed' end + end + + if drained() then + return true, function () return true, nil end + end + + if is_probe then return false, WANT_STATE end + + self:_kick_pump() + return false, WANT_STATE + end + + local function probe_step() return step(true) end + local function run_step() return step(false) end + + probe_step = lane.wrap_probe(probe_step) + run_step = lane.wrap_run(run_step) + + local function wrap(th) + local a, b = th() + lane.release() + return a, b + end + + local ev = wait.waitable2(register, probe_step, run_step, wrap) + return ev:on_abort(function () lane.release() end) +end + +function Stream:core_write_op(str) + assert(self.tx, 'stream is not writable') + assert(type(str) == 'string', 'core_write_op expects a string') + if str == '' then return op.always(0, nil) end + return output_lane_op(self, 'write', str) +end + +local function flush_required_for_write(self, str) + local mode = self._bufmode or 'full' + if mode == 'no' then + return true + end + if mode == 'line' and type(str) == 'string' then + return str:find('\n', 1, true) ~= nil + end + return false +end + +function Stream:write_op(...) + assert(self.tx, 'stream is not writable') + + local count = select('#', ...) + if count == 0 then return op.always(0, nil) end + + local parts = {} + for i = 1, count do + local v = select(i, ...) + parts[i] = (type(v) == 'string') and v or tostring(v) + end + local str = table.concat(parts) + return self:core_write_op(str):wrap(function (n, err) + if n == nil then return nil, err end + + if flush_required_for_write(self, str) then + local ok, ferr = perform(self:flush_op()) + if ok == nil then return nil, ferr end + end + + return n, nil + end) +end + +function Stream:flush_op() + if not self.tx then return op.always(true, nil) end + return output_lane_op(self, 'flush') +end + +---------------------------------------------------------------------- +-- Misc +---------------------------------------------------------------------- + +function Stream:flush_input() + if self.rx then self.rx:reset() end + self:_signal_state() +end + +function Stream:seek(whence, offset) + self:flush() + if not (self.io and self.io.seek) then + return nil, 'stream is not seekable' + end + whence = whence or 'cur' + offset = offset or 0 + return self.io:seek(whence, offset) +end + +local function next_pow2(n) + if n <= 1 then return 1 end + local p = 1 + while p < n do p = p * 2 end + return p +end + +function Stream:setvbuf(mode, size) + if mode ~= 'no' and mode ~= 'line' and mode ~= 'full' then + error('bad mode: ' .. tostring(mode)) + end + + self._bufmode = mode + self.line_buffering = (mode == 'line') + + if size ~= nil then + assert(type(size) == 'number' and size > 0, 'setvbuf: size must be positive') + size = next_pow2(math.floor(size)) + self._bufsize = size + + if self.rx and self.rx:read_avail() == 0 then + self.rx = RingBuf.new(size) + end + if self.tx and (not self._big) and self.tx:read_avail() == 0 then + self.tx = RingBuf.new(size) + end + self:_signal_state() + end + + return self +end + +function Stream:filename() + return self.io and self.io.filename +end + +---------------------------------------------------------------------- +-- Synchronous convenience wrappers +---------------------------------------------------------------------- + +function Stream:read_line(opts) return perform(self:read_line_op(opts)) end + +function Stream:read_exactly(n) return perform(self:read_exactly_op(n)) end + +function Stream:read_some(max) return perform(self:read_some_op(max)) end + +function Stream:read_all() return perform(self:read_all_op()) end + +function Stream:write(...) return perform(self:write_op(...)) end + +function Stream:flush() return perform(self:flush_op()) end + +function Stream:close() return perform(self:close_op()) end + +---------------------------------------------------------------------- +-- Lua io-like compatibility +---------------------------------------------------------------------- + +function Stream:read_op(fmt) + assert(self.rx, 'stream is not readable') + + if fmt == nil or fmt == '*l' then return self:read_line_op() end + if fmt == '*L' then return self:read_line_op { keep_terminator = true } end + if fmt == '*a' then return self:read_all_op() end + + if type(fmt) == 'number' then + assert(fmt >= 0, 'read_op: n must be non-negative') + if fmt == 0 then return op.always('', nil) end + + return self:core_read_op { min = 1, max = fmt, eof_ok = true } + :wrap(function (s, cnt, err) + if err then return nil, err end + if not s or cnt == 0 then return nil, nil end + return s, nil + end) + end + + error('read_op: invalid format ' .. tostring(fmt)) +end + +function Stream:read(fmt) + return perform(self:read_op(fmt)) +end + +---------------------------------------------------------------------- +-- Module-level helpers +---------------------------------------------------------------------- + +--- Race a single line read across multiple named streams. +--- +--- When performed, returns: +--- name : string +--- line : string|nil +--- err : string|nil +---@param named_streams table +---@param opts? { terminator?: string, keep_terminator?: boolean, max?: integer } +---@return Op +local function merge_lines_op(named_streams, opts) + local arms = {} + for name, s in pairs(named_streams) do + arms[name] = s:read_line_op(opts) + end + return op.named_choice(arms) +end + +return { + open = open, + is_stream = is_stream, + merge_lines_op = merge_lines_op, + Stream = Stream, +} diff --git a/src/fibers/mailbox.lua b/src/fibers/mailbox.lua new file mode 100644 index 0000000..3043585 --- /dev/null +++ b/src/fibers/mailbox.lua @@ -0,0 +1,487 @@ +-- fibers/mailbox.lua +-- +-- Mailbox: closeable, drainable queue for fibers. +-- +-- Conventions +-- * nil payloads are forbidden; nil is reserved for end-of-stream. +-- * rx:recv() returns: +-- - a non-nil message, or +-- - nil when the mailbox is closed and drained. +-- rx:why() yields the close reason (if any). +-- * tx:send(v) returns: +-- - true if the message was accepted (delivered or enqueued), +-- - false, "full" if the message was not accepted due to capacity/policy, +-- - nil if the mailbox is closed (send rejected). +-- tx:why() yields the close reason (if any). +-- * Multi-producer: +-- - tx:clone() creates a new counted sender handle. +-- - each counted handle should be closed once finished. +-- - mailbox closes-for-send when the last counted handle closes. +-- +-- Full policies (when no receiver is waiting and the mailbox is full): +-- * "block" : sender blocks until space/receiver is available (default) +-- * "reject_newest" : reject the incoming value; send returns false, "full" +-- * "drop_oldest" : drop the oldest buffered value (if any), enqueue the new one; +-- send returns true (accepted), and dropped counter increments +-- +-- For rendezvous mailboxes (capacity == 0), "drop_oldest" behaves like "reject_newest". + +---@module 'fibers.mailbox' + +local op = require 'fibers.op' +local fifo = require 'fibers.utils.fifo' +local dlist = require 'fibers.utils.dlist' +local perform = require 'fibers.performer'.perform + +---@alias MailboxWant nil -- reserved for future extensions + +---@class MailboxState +---@field cap integer +---@field buf any|nil -- FIFO buffer when cap>0; nil for rendezvous +---@field getq any -- cancellable wait-list of waiting receivers +---@field putq any -- cancellable wait-list of waiting senders +---@field taskq any -- cancellable wait-list of task waiters for recv readiness +---@field closed boolean +---@field reason any|nil +---@field senders integer -- counted sender handles still open +---@field full '"block"'|'"reject_newest"'|'"drop_oldest"' +---@field dropped integer -- total number of dropped messages due to full policy + +---@class MailboxTx +---@field _st MailboxState +---@field _closed boolean -- this handle closed (idempotent) +---@field _counted boolean -- whether this handle contributes to st.senders +local Tx = {} +Tx.__index = Tx + +---@class MailboxRx +---@field _st MailboxState +local Rx = {} +Rx.__index = Rx + +---------------------------------------------------------------------- +-- Internal helpers +---------------------------------------------------------------------- + +--- Pop the next entry whose suspension is still waiting, if any. +---@param q any +---@return table|nil +local function pop_active(q) + while not q:empty() do + local e = q:pop_head() + local s = e.suspension + if not s or s:waiting() then + return e + end + end +end + +local function cleanup_recv_waiter(entry) + entry.suspension = nil + entry.wrap = nil +end + +local function cleanup_send_waiter(entry) + entry.val = nil + entry.suspension = nil + entry.wrap = nil +end + +local function cleanup_task_waiter(entry) + entry.task = nil + entry.waker = nil +end + +---@param st MailboxState +local function notify_task_waiters(st) + local q = st.taskq + if not q then return end + + while not q:empty() do + local e = q:pop_head() + if e and e.task and e.waker then + local task, waker = e.task, e.waker + waker:wakeup(task) + cleanup_task_waiter(e) + end + end +end + +---@param st MailboxState +---@return boolean +local function recv_may_succeed(st) + if st.closed then return true end + if st.buf and st.buf:length() > 0 then return true end + if st.putq and not st.putq:empty() then return true end + return false +end + +---@param st MailboxState +---@param reason any|nil +local function record_reason(st, reason) + if st.reason == nil and reason ~= nil then + st.reason = reason + end +end + +--- Close the mailbox state (idempotent), record reason, and wake blocked parties. +--- Receivers drain buffered values (if any), then receive nil. +--- Waiting senders are rejected (nil). +---@param st MailboxState +---@param reason any|nil +local function close_state(st, reason) + if st.closed then + record_reason(st, reason) + return + end + + st.closed = true + record_reason(st, reason) + + -- Wake receivers: deliver buffered values first, then nil when buffer empty. + while true do + local recv = pop_active(st.getq) + if not recv then break end + + local v + if st.buf and st.buf:length() > 0 then + v = st.buf:pop() + end + recv.suspension:complete(recv.wrap, v) + cleanup_recv_waiter(recv) + end + + -- Reject senders (nil result means "closed"). + while true do + local snd = pop_active(st.putq) + if not snd then break end + snd.suspension:complete(snd.wrap, nil) + cleanup_send_waiter(snd) + end + + notify_task_waiters(st) +end + +---------------------------------------------------------------------- +-- Construction +---------------------------------------------------------------------- + +---@param full any +---@return '"block"'|'"reject_newest"'|'"drop_oldest"' +local function norm_full_policy(full, capacity) + if full == nil then full = 'block' end + if full ~= 'block' and full ~= 'reject_newest' and full ~= 'drop_oldest' then + error('mailbox.new: invalid full policy: ' .. tostring(full), 3) + end + -- Rendezvous mailboxes have no buffer; drop_oldest collapses to reject_newest. + if capacity == 0 and full == 'drop_oldest' then + full = 'reject_newest' + end + return full +end + +--- Create a mailbox. Returns (tx, rx). +---@param capacity? integer # 0 or nil -> rendezvous; >0 -> buffered capacity +---@param opts? { full?: '"block"'|'"reject_newest"'|'"drop_oldest"' } +---@return MailboxTx tx, MailboxRx rx +local function new(capacity, opts) + capacity = capacity or 0 + opts = opts or {} + local full = norm_full_policy(opts.full, capacity) + + ---@type MailboxState + local st = { + cap = capacity, + buf = (capacity > 0) and fifo.new() or nil, + getq = dlist.new(), + putq = dlist.new(), + taskq = dlist.new(), + closed = false, + reason = nil, + senders = 1, + full = full, + dropped = 0, + } + + local tx = setmetatable({ _st = st, _closed = false, _counted = true }, Tx) + local rx = setmetatable({ _st = st }, Rx) + return tx, rx +end + +---------------------------------------------------------------------- +-- Tx (sender) +---------------------------------------------------------------------- + +--- Return the mailbox close reason (if any). +---@return any|nil +function Tx:why() + return self._st.reason +end + +--- Return total number of dropped messages due to the full policy. +--- For reject_newest: counts incoming messages dropped (and send returns false,"full"). +--- For drop_oldest: counts buffered messages evicted to admit new ones. +---@return integer +function Tx:dropped() + return self._st.dropped or 0 +end + +--- Clone this sender handle (multi-producer). +--- If the mailbox or this handle is closed, returns an inert, uncounted handle. +---@return MailboxTx +function Tx:clone() + local st = self._st + if self._closed or st.closed then + return setmetatable({ _st = st, _closed = true, _counted = false }, Tx) + end + st.senders = st.senders + 1 + return setmetatable({ _st = st, _closed = false, _counted = true }, Tx) +end + +--- Close this sender handle (idempotent). +--- When the last counted sender closes, the mailbox closes-for-send. +---@param reason any|nil +---@return boolean ok +function Tx:close(reason) + local st = self._st + record_reason(st, reason) + + if self._closed then return true end + + self._closed = true + + -- If already uncounted, or mailbox already closed, nothing to do. + if not self._counted or st.closed then + self._counted = false + return true + end + + self._counted = false + st.senders = st.senders - 1 + if st.senders <= 0 then + st.senders = 0 + close_state(st, reason) + end + + return true +end + +--- Op that sends a message. +--- When performed: +--- * true : accepted (delivered or enqueued) +--- * false, "full" : not accepted due to capacity/policy (reject_newest) +--- * nil : mailbox closed (send rejected) +---@param v any # MUST NOT be nil +---@return Op +function Tx:send_op(v) + assert(v ~= nil, 'mailbox.send: nil payload is not permitted') + + local st = self._st + local getq, putq, buf, cap = st.getq, st.putq, st.buf, st.cap + local full = st.full + + -- Full-policy handler returns: + -- ready:boolean_for_op, result1, result2 + -- where ready==true means the op is ready and result* are returned to the caller. + local function handle_full() + if full == 'block' then + -- Not ready; must block. + return false + end + + -- Some message is being discarded due to boundedness. + st.dropped = st.dropped + 1 + + if full == 'drop_oldest' and buf then + -- Evict one buffered value to admit the new one. + -- (For cap==0, drop_oldest is normalised away to reject_newest.) + buf:pop() + buf:push(v) + notify_task_waiters(st) + return true, true + end + + -- reject_newest: do not admit v. + return true, false, 'full' + end + + local function try() + if st.closed or self._closed then + -- Ready: closed is signalled to caller by nil result. + return true, nil + end + + -- Rendezvous with a waiting receiver. + local recv = pop_active(getq) + if recv then + recv.suspension:complete(recv.wrap, v) + cleanup_recv_waiter(recv) + notify_task_waiters(st) + return true, true + end + + -- Buffered enqueue when there is space. + if buf and buf:length() < cap then + buf:push(v) + notify_task_waiters(st) + return true, true + end + + -- Full (buffered) or no receiver (rendezvous): apply full policy. + return handle_full() + end + + local function block(suspension, wrap_fn) + if st.closed or self._closed then + -- Resume sender with nil (closed). + return suspension:complete(wrap_fn, nil) + end + -- Only used for "block" policy. + local entry = { val = v, suspension = suspension, wrap = wrap_fn } + local node = putq:push_tail(entry) + suspension:add_cleanup(function () + if node:remove() then + cleanup_send_waiter(entry) + end + end) + end + + return op.new_primitive(nil, try, block) +end + +--- Register a task to be woken when recv may succeed (message arrives or close). +--- This does not expose the scheduler; callers provide a waker capability. +---@param task Task +---@param waker table +---@return WaitToken +function Rx:on_message(task, waker) + local st = self._st + assert(task and type(task) == 'table' and type(task.run) == 'function', + 'on_message: task must have :run()') + assert(waker and type(waker.wakeup) == 'function', + 'on_message: waker must support :wakeup(task)') + + if recv_may_succeed(st) then + waker:wakeup(task) + return { unlink = function () return false end } + end + + local entry = { task = task, waker = waker } + local node = st.taskq:push_tail(entry) + + return { + unlink = function () + if node:remove() then + cleanup_task_waiter(entry) + return true + end + return false + end, + } +end + +--- Synchronously send a message. +---@param v any +---@return boolean|nil ok +---@return string|nil reason -- "full" when ok==false +function Tx:send(v) + return perform(self:send_op(v)) +end + +---------------------------------------------------------------------- +-- Rx (receiver) +---------------------------------------------------------------------- + +--- Return the mailbox close reason (if any). +---@return any|nil +function Rx:why() + return self._st.reason +end + +--- Return total number of dropped messages due to the full policy. +---@return integer +function Rx:dropped() + return self._st.dropped or 0 +end + +--- Op that receives the next message. +--- When performed: a non-nil value, or nil when closed and drained. +---@return Op +function Rx:recv_op() + local st = self._st + local getq, putq, buf = st.getq, st.putq, st.buf + + local function try() + -- Prefer unblocking a waiting sender (if present); we may still return + -- a buffered value first. + local snd = pop_active(putq) + if snd then + -- Sender was accepted (delivered or enqueued-by-refill below). + snd.suspension:complete(snd.wrap, true) + end + + if buf and buf:length() > 0 then + local v = buf:pop() + -- If there was a sender waiting, refill the buffer with its value. + if snd then + buf:push(snd.val) + cleanup_send_waiter(snd) + end + return true, v + end + + if snd then + local v = snd.val + cleanup_send_waiter(snd) + return true, v + end + + if st.closed then + return true, nil + end + + return false + end + + ---@param suspension Suspension + ---@param wrap_fn WrapFn + local function block(suspension, wrap_fn) + if st.closed then + return suspension:complete(wrap_fn, nil) + end + local entry = { suspension = suspension, wrap = wrap_fn } + local node = getq:push_tail(entry) + suspension:add_cleanup(function () + if node:remove() then + cleanup_recv_waiter(entry) + end + end) + end + + return op.new_primitive(nil, try, block) +end + +--- Synchronously receive the next message. +---@return any|nil v +function Rx:recv() + return perform(self:recv_op()) +end + +--- Iterator over received messages, ending at nil (closed and drained). +---@return fun(): any|nil +function Rx:iter() + return function () + return self:recv() + end +end + +---------------------------------------------------------------------- +-- Public API +---------------------------------------------------------------------- + +return { + new = new, + + Tx = Tx, + Rx = Rx, +} diff --git a/src/fibers/oneshot.lua b/src/fibers/oneshot.lua new file mode 100644 index 0000000..938c4c7 --- /dev/null +++ b/src/fibers/oneshot.lua @@ -0,0 +1,118 @@ +-- fibers/oneshot.lua + +--- One-shot notification primitive. +---@module 'fibers.oneshot' + +---@alias OneshotWaiter fun() + +---@class Oneshot +---@field triggered boolean +---@field waiters table[] # list of { fn = OneshotWaiter|nil } +---@field on_after_signal fun()|nil +local Oneshot = {} +Oneshot.__index = Oneshot + +local function noop() end +--- Create a new one-shot. +---@param on_after_signal? fun() # optional callback run after signalling all waiters +---@return Oneshot +local function new(on_after_signal) + return setmetatable({ + triggered = false, + waiters = {}, + on_after_signal = on_after_signal, + }, Oneshot) +end + +--- Register a waiter. +--- If already triggered, the thunk is run immediately. +---@param thunk OneshotWaiter +---@return fun() cancel # idempotent deregistration thunk +function Oneshot:add_waiter(thunk) + if self.triggered then + thunk() + return noop + end + + local ws = self.waiters + local rec = { fn = thunk, idx = #ws + 1 } + ws[rec.idx] = rec + + return function () + -- Idempotent. A cancelled waiter must be physically removed, not + -- merely tombstoned. Scope cancellation/fault one-shots are normally + -- long-lived and most waits are cancelled by losing-choice cleanup; + -- leaving empty records in the waiter array makes long-lived scopes + -- grow with every completed scoped perform. + if rec.fn == nil then return end + rec.fn = nil + + -- During or after signalling, signal() owns the waiter array. Clearing + -- fn above is enough and avoids mutating the array being iterated. + if self.triggered then return end + + local i = rec.idx + if i and ws[i] == rec then + local last_i = #ws + local last = ws[last_i] + ws[last_i] = nil + if last ~= rec then + ws[i] = last + if last then last.idx = i end + end + rec.idx = nil + return + end + + -- Fallback for defensive correctness if an index became stale. + for j = #ws, 1, -1 do + if ws[j] == rec then + local last_i = #ws + local last = ws[last_i] + ws[last_i] = nil + if last ~= rec then + ws[j] = last + if last then last.idx = j end + end + rec.idx = nil + return + end + end + end +end + +--- Trigger the one-shot. +--- All waiters are run once; the optional callback runs afterwards. +--- Idempotent: subsequent calls after the first have no effect. +function Oneshot:signal() + if self.triggered then return end + self.triggered = true + + local ws = self.waiters + self.waiters = {} + for i = 1, #ws do + local rec = ws[i] + ws[i] = nil + if rec then + local f = rec.fn + rec.fn = nil + rec.idx = nil + if f then f() end + end + end + + local cb = self.on_after_signal + if cb then + cb() + end +end + +--- Check whether the one-shot has fired. +---@return boolean +function Oneshot:is_triggered() + return self.triggered +end + +return { + new = new, +} diff --git a/src/fibers/op.lua b/src/fibers/op.lua new file mode 100644 index 0000000..e52812f --- /dev/null +++ b/src/fibers/op.lua @@ -0,0 +1,779 @@ +-- fibers/op.lua + +--- Concurrent ML style operations for structured concurrency. +--- Provides composable operations (ops) that may complete immediately +--- or block, with support for choice, guards, negative acknowledgements +--- and abort/cleanup behaviour. +---@module 'fibers.op' + +local runtime = require 'fibers.runtime' +local safe = require 'coxpcall' +local oneshot = require 'fibers.oneshot' + +local unpack = rawget(table, 'unpack') or _G.unpack +local pack = rawget(table, 'pack') or function (...) + return { n = select('#', ...), ... } +end + +local function id_wrap(...) return ... end + +---------------------------------------------------------------------- +-- Suspensions and completion tasks +---------------------------------------------------------------------- + +--- A suspension of a fiber waiting on an op. +---@class Suspension : Task +---@field state "waiting"|"synchronized" +---@field sched Scheduler +---@field fiber Fiber +---@field wrap WrapFn|nil +---@field val table|nil +local Suspension = {} +Suspension.__index = Suspension + +---@class CompleteTask : Task +---@field suspension Suspension +---@field wrap WrapFn +---@field val table +local CompleteTask = {} +CompleteTask.__index = CompleteTask + +function Suspension:waiting() + return self.state == 'waiting' +end + +function Suspension:add_cleanup(f) + if type(f) ~= 'function' then + error('cleanup must be a function', 2) + end + + if self.cleaned then + safe.pcall(f) + return + end + + local cs = self.cleanups + if not cs then + cs = {} + self.cleanups = cs + end + + cs[#cs + 1] = f +end + +function Suspension:_run_cleanups() + if self.cleaned then return end + self.cleaned = true + + local cs = self.cleanups + self.cleanups = nil + if not cs then return end + + for i = #cs, 1, -1 do + safe.pcall(cs[i]) + cs[i] = nil + end +end + +function Suspension:wakeup(task) + self.sched:schedule(task) +end + +function Suspension:at_time(t, task) + return self.sched:schedule_at_time(t, task) +end + +function Suspension:after(dt, task) + return self.sched:schedule_after_sleep(dt, task) +end + +function Suspension:complete(wrap, ...) + assert(self:waiting()) + self.state = 'synchronized' + self.wrap = wrap + self.val = pack(...) + self:_run_cleanups() + self.sched:schedule(self) +end + +function Suspension:complete_and_run(wrap, ...) + assert(self:waiting()) + self.state = 'synchronized' + self:_run_cleanups() + return self.fiber:resume(wrap, ...) +end + +function Suspension:complete_task(wrap, ...) + return setmetatable({ + suspension = self, + wrap = wrap, + val = pack(...), + }, CompleteTask) +end + +function Suspension:run() + assert(not self:waiting()) + return self.fiber:resume(self.wrap, unpack(self.val, 1, self.val.n)) +end + +local function new_suspension(sched, fib) + return setmetatable({ + state = 'waiting', + sched = sched, + fiber = fib, + cleanups = nil, + cleaned = false, + }, Suspension) +end + +function CompleteTask:run() + if self.suspension:waiting() then + self.suspension:complete_and_run( + self.wrap, + unpack(self.val, 1, self.val.n) + ) + end +end + +function CompleteTask:cancel(reason) + if self.suspension:waiting() then + local msg = reason or 'cancelled' + + local function cancelled_wrap() + return false, msg + end + + self.suspension:complete(cancelled_wrap) + end +end + +---------------------------------------------------------------------- +-- Op type +---------------------------------------------------------------------- + +---@alias WrapFn fun(...: any): ... +---@alias TryFn fun(): boolean, ... +---@alias BlockFn fun(suspension: Suspension, wrap_fn: WrapFn) + +---@class NackCond +---@field wait_op fun(): Op +---@field signal fun() + +---@class CompiledLeaf +---@field try_fn TryFn +---@field block_fn BlockFn +---@field wrap WrapFn +---@field nacks NackCond[] + +---@class Op +---@field kind "prim"|"choice"|"guard"|"with_nack"|"wrap"|"abort" +---@field ops Op[]|nil +---@field builder fun(...: any): Op +---@field wrap_fn WrapFn|nil +---@field inner Op|nil +---@field abort_fn fun()|nil +---@field try_fn TryFn|nil +---@field block_fn BlockFn|nil +local Op = {} +Op.__index = Op + +local perform + +local function is_op(v) + return type(v) == 'table' and getmetatable(v) == Op +end + +--- Construct a primitive op. +---@param wrap_fn? WrapFn +---@param try_fn TryFn +---@param block_fn BlockFn +---@return Op +local function new_primitive(wrap_fn, try_fn, block_fn) + if type(try_fn) ~= 'function' then + error('new_primitive: try_fn must be a function', 2) + end + + if type(block_fn) ~= 'function' then + error('new_primitive: block_fn must be a function', 2) + end + + if wrap_fn ~= nil and type(wrap_fn) ~= 'function' then + error('new_primitive: wrap_fn must be a function or nil', 2) + end + + return setmetatable({ + kind = 'prim', + wrap_fn = wrap_fn or id_wrap, + try_fn = try_fn, + block_fn = block_fn, + }, Op) +end + +--- Delayed op builder; executed once per synchronisation. +---@param g fun(): Op +---@return Op +local function guard(g) + if type(g) ~= 'function' then + error('guard expects a function', 2) + end + + return setmetatable({ + kind = 'guard', + builder = g, + }, Op) +end + +--- CML-style with_nack. +---@param g fun(nack_op: Op): Op +---@return Op +local function with_nack(g) + if type(g) ~= 'function' then + error('with_nack expects a function', 2) + end + + return setmetatable({ + kind = 'with_nack', + builder = g, + }, Op) +end + +--- Op that is immediately ready with the given results. +---@param ... any +---@return Op +local function always(...) + local results = pack(...) + + return new_primitive( + nil, + function () + return true, unpack(results, 1, results.n) + end, + function () + error('always: block_fn should never run') + end + ) +end + +--- Op that never becomes ready. +---@return Op +local function never() + return new_primitive( + nil, + function () + return false + end, + function () + -- Intentionally never completes the suspension. + end + ) +end + +local function append_choice_arg(out, v, level) + level = level or 2 + + if is_op(v) then + if v.kind == 'choice' then + for i = 1, #(v.ops or {}) do + out[#out + 1] = v.ops[i] + end + else + out[#out + 1] = v + end + + return + end + + if type(v) == 'table' then + local n = #v + + for k in pairs(v) do + if type(k) ~= 'number' + or k < 1 + or k % 1 ~= 0 + or k > n + then + error('choice expects Op values or dense arrays of Op values', level) + end + end + + for i = 1, n do + append_choice_arg(out, v[i], level + 1) + end + + return + end + + error('choice expects Op values or dense arrays of Op values', level) +end + +--- Choice op over zero or more sub-ops. +--- +--- Empty choice is valid and never becomes ready. +--- Nested choices are flattened. +--- +--- Accepted forms: +--- choice(op_a, op_b) +--- choice({ op_a, op_b }) +--- choice(op_a, { op_b, op_c }) +--- choice() +--- choice({}) +---@param ... Op|Op[] +---@return Op +local function choice(...) + local ops = {} + + for i = 1, select('#', ...) do + append_choice_arg(ops, select(i, ...), 2) + end + + if #ops == 0 then return never() end + if #ops == 1 then return ops[1] end + + return setmetatable({ + kind = 'choice', + ops = ops, + }, Op) +end + +function Op:wrap(f) + if type(f) ~= 'function' then + error('wrap expects a function', 2) + end + + return setmetatable({ + kind = 'wrap', + inner = self, + wrap_fn = f, + }, Op) +end + +function Op:on_abort(f) + if type(f) ~= 'function' then + error('on_abort expects a function', 2) + end + + return setmetatable({ + kind = 'abort', + inner = self, + abort_fn = f, + }, Op) +end + +---------------------------------------------------------------------- +-- Nack conditions +---------------------------------------------------------------------- + +local function new_cond(opts) + local abort_fn = opts and opts.abort_fn or nil + + local os = oneshot.new(function () + if abort_fn then + safe.pcall(abort_fn) + end + end) + + local function wait_op() + assert(not abort_fn, 'abort-only cond has no wait_op') + + return new_primitive( + nil, + + function () + return os:is_triggered() + end, + + function (suspension, wrap_fn) + local cancel = os:add_waiter(function () + if suspension:waiting() then + suspension:complete(wrap_fn) + end + end) + + suspension:add_cleanup(cancel) + end + ) + end + + return { + wait_op = wait_op, + + signal = function () + os:signal() + end, + } +end + +---------------------------------------------------------------------- +-- Compile op tree +---------------------------------------------------------------------- + +---@param ev Op +---@param outer_wrap? WrapFn +---@param out? CompiledLeaf[] +---@param nacks? NackCond[] +---@return CompiledLeaf[] +local function compile_op(ev, outer_wrap, out, nacks) + out = out or {} + outer_wrap = outer_wrap or id_wrap + nacks = nacks or {} + + if ev.kind == 'choice' then + for _, sub in ipairs(ev.ops or {}) do + compile_op(sub, outer_wrap, out, nacks) + end + + elseif ev.kind == 'guard' then + local inner = ev.builder() + compile_op(inner, outer_wrap, out, nacks) + + elseif ev.kind == 'with_nack' then + local cond = new_cond() + local inner = ev.builder(cond.wait_op()) + local child_nacks = { unpack(nacks) } + + child_nacks[#child_nacks + 1] = cond + compile_op(inner, outer_wrap, out, child_nacks) + + elseif ev.kind == 'wrap' then + local f = assert(ev.wrap_fn) + + local new_outer = function (...) + return outer_wrap(f(...)) + end + + compile_op(ev.inner, new_outer, out, nacks) + + elseif ev.kind == 'abort' then + local cond = new_cond({ abort_fn = ev.abort_fn }) + local child_nacks = { unpack(nacks) } + + child_nacks[#child_nacks + 1] = cond + compile_op(ev.inner, outer_wrap, out, child_nacks) + + else + local function wrapped(...) + return outer_wrap(ev.wrap_fn(...)) + end + + out[#out + 1] = { + try_fn = ev.try_fn, + block_fn = ev.block_fn, + wrap = wrapped, + nacks = nacks, + } + end + + return out +end + +---------------------------------------------------------------------- +-- Nacks and readiness +---------------------------------------------------------------------- + +local function trigger_nacks(leaves, winner_index) + local winner_set + + if winner_index then + winner_set = {} + + for _, cond in ipairs(leaves[winner_index].nacks or {}) do + winner_set[cond] = true + end + end + + local signalled = {} + + for i = 1, #leaves do + if not winner_index or i ~= winner_index then + for j = #(leaves[i].nacks or {}), 1, -1 do + local cond = leaves[i].nacks[j] + + if cond + and not (winner_set and winner_set[cond]) + and not signalled[cond] + then + signalled[cond] = true + cond.signal() + end + end + end + end +end + +local function try_ready(leaves) + local n = #leaves + if n == 0 then return nil end + + local start = math.random(n) + + for k = 0, n - 1 do + local idx = ((start + k - 1) % n) + 1 + local leaf = leaves[idx] + local retval = pack(leaf.try_fn()) + + if retval[1] then + return idx, retval + end + end + + return nil +end + +local function apply_wrap(wrap, retval) + assert(retval ~= nil, 'apply_wrap: retval must not be nil') + return wrap(unpack(retval, 2, retval.n)) +end + +---------------------------------------------------------------------- +-- or_else +---------------------------------------------------------------------- + +--- Non-blocking choice: try this op, otherwise run fallback_thunk. +---@param fallback_thunk fun(): any +---@return Op +function Op:or_else(fallback_thunk) + if type(fallback_thunk) ~= 'function' then + error('or_else expects a function', 2) + end + + if self.kind == 'prim' then + local try_fn = assert(self.try_fn) + local wrap_fn = assert(self.wrap_fn) + + return new_primitive( + nil, + + function () + local r = pack(try_fn()) + + if r[1] then + return true, wrap_fn(unpack(r, 2, r.n)) + end + + return true, fallback_thunk() + end, + + function () + error('or_else(prim): block_fn should never run') + end + ) + end + + return guard(function () + local leaves = compile_op(self) + local idx, retval = try_ready(leaves) + + if idx then + trigger_nacks(leaves, idx) + + local results = pack(apply_wrap(leaves[idx].wrap, retval)) + return always(unpack(results, 1, results.n)) + end + + trigger_nacks(leaves, nil) + + local results = pack(fallback_thunk()) + return always(unpack(results, 1, results.n)) + end) +end + +---------------------------------------------------------------------- +-- Blocking path +---------------------------------------------------------------------- + +local function block_choice_op(sched, fib, leaves) + local suspension = new_suspension(sched, fib) + + for _, leaf in ipairs(leaves) do + leaf.block_fn(suspension, leaf.wrap) + end +end + +local function block_prim_op(sched, fib, prim) + local suspension = new_suspension(sched, fib) + prim.block_fn(suspension, prim.wrap_fn) +end + +---------------------------------------------------------------------- +-- Perform +---------------------------------------------------------------------- + +perform = function (ev) + if not runtime.current_fiber() then + error('perform_raw must be called from inside a fiber (use fibers.run as an entry point)', 2) + end + + if ev.kind == 'guard' then + return perform(ev.builder()) + end + + if ev.kind == 'prim' then + local r = pack(ev.try_fn()) + + if r[1] then + return ev.wrap_fn(unpack(r, 2, r.n)) + end + + local suspended = pack(runtime.suspend(block_prim_op, ev)) + local wrap = suspended[1] + + return wrap(unpack(suspended, 2, suspended.n)) + end + + local leaves = compile_op(ev) + + local idx, retval = try_ready(leaves) + if idx then + trigger_nacks(leaves, idx) + return apply_wrap(leaves[idx].wrap, retval) + end + + local suspended = pack(runtime.suspend(block_choice_op, leaves)) + local wrap = suspended[1] + + local winner_index + + for i, leaf in ipairs(leaves) do + if leaf.wrap == wrap then + winner_index = i + break + end + end + + trigger_nacks(leaves, winner_index) + + return wrap(unpack(suspended, 2, suspended.n)) +end + +---------------------------------------------------------------------- +-- bracket / finally +---------------------------------------------------------------------- + +local function bracket(acquire, release, use) + if type(acquire) ~= 'function' then + error('bracket: acquire must be a function', 2) + end + + if type(release) ~= 'function' then + error('bracket: release must be a function', 2) + end + + if type(use) ~= 'function' then + error('bracket: use must be a function', 2) + end + + return guard(function () + local res = acquire() + local used = use(res) + + local wrapped = used:wrap(function (...) + release(res, false) + return ... + end) + + return wrapped:on_abort(function () + release(res, true) + end) + end) +end + +function Op:finally(cleanup) + if type(cleanup) ~= 'function' then + error('finally expects a function', 2) + end + + return bracket( + function () return nil end, + function (_, aborted) cleanup(aborted) end, + function () return self end + ) +end + +---------------------------------------------------------------------- +-- Higher-level choice helpers +---------------------------------------------------------------------- + +local function race(ops, on_win) + if type(on_win) ~= 'function' then + error('race expects on_win callback', 2) + end + + if type(ops) ~= 'table' then + error('race expects a dense array of Op values', 2) + end + + local wrapped = {} + + for i, ev in ipairs(ops) do + if not is_op(ev) then + error('race expects a dense array of Op values', 2) + end + + wrapped[i] = ev:wrap(function (...) + return on_win(i, ...) + end) + end + + return choice(wrapped) +end + +local function first_ready(ops) + return race(ops, function (i, ...) + return i, ... + end) +end + +local function named_choice(arms) + if type(arms) ~= 'table' then + error('named_choice expects a table of Op values', 2) + end + + local ops, names = {}, {} + + for name, ev in pairs(arms) do + if not is_op(ev) then + error('named_choice expects a table of Op values', 2) + end + + names[#names + 1] = name + ops[#ops + 1] = ev + end + + return race(ops, function (i, ...) + return names[i], ... + end) +end + +local function boolean_choice(op_true, op_false) + if not is_op(op_true) or not is_op(op_false) then + error('boolean_choice expects two Op values', 2) + end + + return race({ op_true, op_false }, function (i, ...) + if i == 1 then + return true, ... + end + + return false, ... + end) +end + +---------------------------------------------------------------------- +-- Public API +---------------------------------------------------------------------- + +return { + perform_raw = perform, + new_primitive = new_primitive, + choice = choice, + guard = guard, + with_nack = with_nack, + bracket = bracket, + always = always, + never = never, + Op = Op, + race = race, + first_ready = first_ready, + named_choice = named_choice, + boolean_choice = boolean_choice, +} diff --git a/src/fibers/performer.lua b/src/fibers/performer.lua new file mode 100644 index 0000000..fe57754 --- /dev/null +++ b/src/fibers/performer.lua @@ -0,0 +1,50 @@ +-- fibers/performer.lua +--- +-- Scope-aware performer for ops. +-- Preferred entry point for synchronising on ops in normal code. +-- Delegates to the current scope if available, otherwise falls back +-- to the raw op.perform. +---@module 'fibers.performer' + +local Op = require 'fibers.op' +local Runtime = require 'fibers.runtime' + +---@type any +local scope_mod + +--- Get the current scope if the scope module has been loaded. +---@return Scope|nil +local function current_scope() + if not scope_mod then + scope_mod = require 'fibers.scope' + end + return scope_mod.current and scope_mod.current() or nil +end + +--- Check that a value is an Op instance. +---@param op any +local function assert_op(op) + if type(op) ~= 'table' or getmetatable(op) ~= Op.Op then + error(('perform: expected op, got %s (%s)'):format(type(op), tostring(op)), 3) + end +end + +--- Perform an op under the current scope, if any. +--- Must be called from inside a fiber. +---@param op Op +---@return any ... +local function perform(op) + if not Runtime.current_fiber() then error('perform be called from inside a fiber', 2) end + assert_op(op) + + local s = current_scope() + if s and s.perform then + return s:perform(op) + else + return Op.perform_raw(op) + end +end + +return { + perform = perform, +} diff --git a/src/fibers/pulse.lua b/src/fibers/pulse.lua new file mode 100644 index 0000000..b1fa75c --- /dev/null +++ b/src/fibers/pulse.lua @@ -0,0 +1,196 @@ +-- fibers/pulse.lua +-- +-- Pulse: a versioned broadcast notifier for fibers. +-- +-- Purpose +-- A Pulse holds a monotonic version counter. Callers can: +-- * read the current version (snapshot), +-- * signal an update (increments version, wakes all waiters), +-- * wait (as an Op) until the version advances past a seen value. +-- +-- Semantics +-- * This is a notification primitive, not a queue: updates coalesce. +-- * changed_op(last_seen) completes when version > last_seen. +-- * close(reason) is optional but useful for clean shutdown and end-of-stream. +-- +-- Return conventions (changed/next): +-- * on change: returns (version, nil) +-- * on closed: returns (nil, reason) +-- +---@module 'fibers.pulse' + +local op = require 'fibers.op' +local cond_mod = require 'fibers.cond' +local perform = require 'fibers.performer'.perform +local runtime = require 'fibers.runtime' + +local M = {} + +---@class Pulse +---@field _ver integer +---@field _cond any -- Cond (per-generation) +---@field _closed boolean +---@field _reason any|nil +local Pulse = {} +Pulse.__index = Pulse + +local function assert_in_fiber(errlvl) + if not runtime.current_fiber() then + error('pulse: must be called from inside a fiber', errlvl or 3) + end +end + +--- Create a new Pulse. +---@param initial_version? integer +---@return Pulse +function M.new(initial_version) + if initial_version ~= nil then + if type(initial_version) ~= 'number' or initial_version < 0 or initial_version % 1 ~= 0 then + error('pulse.new: initial_version must be a non-negative integer', 2) + end + end + + return setmetatable({ + _ver = initial_version or 0, + _cond = cond_mod.new(), + _closed = false, + _reason = nil, + }, Pulse) +end + +--- Create a Pulse tied to the current scope; closes on scope finalisation. +---@param opts? { close_reason?: any } +---@return Pulse +function M.scoped(opts) + assert_in_fiber(3) + + local fibers = require 'fibers' + local scope = fibers.current_scope() + + local p = M.new() + opts = opts or {} + + scope:finally(function (_, st, primary) + local reason = opts.close_reason + or primary + or ((st ~= 'ok') and st) + or 'scope finalised' + p:close(reason) + end) + + return p +end + +--- Current version (monotonic). +---@return integer +function Pulse:version() + return self._ver +end + +--- Whether this pulse is closed. +---@return boolean +function Pulse:is_closed() + return self._closed +end + +--- Close reason, if any. +---@return any|nil +function Pulse:why() + return self._reason +end + +--- Signal an update: increments version, wakes all waiters, and rolls the generation. +--- Returns the new version, or nil if closed. +---@return integer|nil version +function Pulse:signal() + if self._closed then + return nil + end + + self._ver = self._ver + 1 + + local c = self._cond + if c then + c:signal() + end + self._cond = cond_mod.new() + + return self._ver +end + +--- Close the pulse (idempotent) and wake all waiters. +---@param reason any|nil +---@return boolean ok +function Pulse:close(reason) + if self._reason == nil and reason ~= nil then + self._reason = reason + end + if self._closed then + return true + end + + self._closed = true + + local c = self._cond + if c then + c:signal() + end + + return true +end + +--- Op that completes when version > last_seen, or when closed. +---@param last_seen integer +---@return Op -- when performed: (version, nil) | (nil, reason) +function Pulse:changed_op(last_seen) + if type(last_seen) ~= 'number' or last_seen % 1 ~= 0 then + error('pulse.changed_op: last_seen must be an integer', 2) + end + + return op.guard(function () + if self._ver > last_seen then + return op.always(self._ver, nil) + end + + if self._closed then + return op.always(nil, self._reason) + end + + local c = self._cond + return c:wait_op():wrap(function () + if self._closed then + return nil, self._reason + end + -- A signal implies _ver advanced; return current snapshot. + return self._ver, nil + end) + end) +end + +--- Convenience op: wait for the next signal from "now" (evaluated at perform time). +---@return Op +function Pulse:next_op() + return op.guard(function () + local v = self._ver + return self:changed_op(v) + end) +end + +--- Synchronous convenience: block until changed since last_seen. +---@param last_seen integer +---@return integer|nil version +---@return any|nil reason +function Pulse:changed(last_seen) + return perform(self:changed_op(last_seen)) +end + +--- Synchronous convenience: block until next signal. +---@return integer|nil version +---@return any|nil reason +function Pulse:next() + return perform(self:next_op()) +end + +M.Pulse = Pulse + +return M diff --git a/src/fibers/runtime.lua b/src/fibers/runtime.lua new file mode 100644 index 0000000..2dff83d --- /dev/null +++ b/src/fibers/runtime.lua @@ -0,0 +1,205 @@ +-- fibers/runtime.lua +--- +-- Runtime module for fibers. +-- Provides a global scheduler, fiber creation, suspension and error reporting. +---@module 'fibers.runtime' + +local sched = require 'fibers.sched' + +--- Identity helper used as the wrap function when resuming fibers. +---@generic T +---@param ... T +---@return T ... +local function id(...) + return ... +end + +--- Record of an uncaught fiber error. +---@class FiberErrorRecord +---@field fiber Fiber +---@field err any + +--- Record of a fiber waiting for an error notification. +---@class ErrorWaiterRecord +---@field fiber Fiber + +---@type FiberErrorRecord[] +local error_queue = {} + +---@type ErrorWaiterRecord[] +local error_waiters = {} + +--- Task used to wake a fiber waiting for an error. +---@class WaiterTask : Task +---@field waiter Fiber # waiting fiber +---@field err_fiber Fiber # fiber that failed +---@field err any # error value +local WaiterTask = {} +WaiterTask.__index = WaiterTask + +--- Resume the waiting fiber with (wrap, err_fiber, err). +function WaiterTask:run() + self.waiter:resume(id, self.err_fiber, self.err) +end + +--- Cooperative fiber object managed by the runtime. +---@class Fiber : Task +---@field coroutine thread +---@field alive boolean +---@field sockets table +---@field traceback string|nil +local Fiber = {} +Fiber.__index = Fiber + +---@type Fiber|nil +local _current_fiber + +---@type Scheduler +local current_scheduler = sched.new() + +--- Spawn a new fiber scheduled on the global scheduler. +--- The function is called as fn(wrap, ...), where wrap is typically the identity. +---@param fn fun(wrap: fun(...: any): any, ...: any) +local function spawn(fn) + local tb = debug.traceback('', 2):match('\n[^\n]*\n(.*)') or '' + if _current_fiber and _current_fiber.traceback then + tb = tb .. '\n' .. _current_fiber.traceback + end + + current_scheduler:schedule( + setmetatable({ + coroutine = coroutine.create(fn), + alive = true, + sockets = {}, + traceback = tb, + }, Fiber) + ) +end + +--- Resume execution of this fiber. +--- If the fiber is dead, an error is raised. +---@param wrap fun(...: any): any +---@param ... any +function Fiber:resume(wrap, ...) + assert(self.alive, 'dead fiber') + local saved_current_fiber = _current_fiber + _current_fiber = self + local ok, err = coroutine.resume(self.coroutine, wrap, ...) + _current_fiber = saved_current_fiber + + if coroutine.status(self.coroutine) == 'dead' then + self.alive = false + end + + if not ok then + -- Report uncaught error to any waiting fiber, or queue it. + if #error_waiters > 0 then + local waiter = table.remove(error_waiters, 1) + current_scheduler:schedule(setmetatable({ + waiter = waiter.fiber, + err_fiber = self, + err = err, + }, WaiterTask)) + else + error_queue[#error_queue + 1] = { + fiber = self, + err = err, + } + end + end +end + +--- Alias for :resume, so a Fiber can be scheduled as a Task. +Fiber.run = Fiber.resume + +--- Suspend this fiber until block_fn arranges to reschedule it. +--- block_fn receives (scheduler, fiber, ...). +---@param block_fn fun(scheduler: Scheduler, fiber: Fiber, ...: any) +---@param ... any +---@return any ... +function Fiber:suspend(block_fn, ...) + assert(_current_fiber == self) + block_fn(current_scheduler, assert(_current_fiber), ...) + return coroutine.yield() +end + +--- Return the captured creation traceback for this fiber, if any. +---@return string +function Fiber:get_traceback() + return self.traceback or 'No traceback available' +end + +--- Return the current Fiber object, or nil if not inside a fiber. +---@return Fiber|nil +local function current_fiber() + return _current_fiber +end + +--- Current scheduler time in monotonic seconds. +---@return number +local function now() + return current_scheduler:now() +end + +--- Suspend the current fiber using block_fn. +--- block_fn must arrange for the fiber to be rescheduled later. +---@param block_fn fun(scheduler: Scheduler, fiber: Fiber, ...: any) +---@param ... any +---@return any ... +local function suspend(block_fn, ...) + assert(_current_fiber, 'can only suspend from inside a fiber') + return _current_fiber:suspend(block_fn, ...) +end + +--- Yield the current fiber and re-queue it as runnable. +---@return any ... +local function yield() + assert(current_fiber(), 'can only yield from inside a fiber') + return suspend(function (scheduler, fiber) + scheduler:schedule(fiber) + end) +end + +--- Request that the global scheduler stops its main loop. +local function stop() + current_scheduler:stop() +end + +--- Wait for the next uncaught fiber error. +--- Returns the failing fiber and its error value. +---@return Fiber err_fiber +---@return any err +local function wait_fiber_error() + if #error_queue > 0 then + local rec = table.remove(error_queue, 1) + return rec.fiber, rec.err + end + + assert(_current_fiber, 'wait_fiber_error must be called from within a fiber') + + local function block_fn(_, fib) + error_waiters[#error_waiters + 1] = { fiber = fib } + end + + local _, err_fiber, err = _current_fiber:suspend(block_fn) + return err_fiber, err +end + +--- Run the main event loop using the global scheduler. +local function main() + return current_scheduler:main() +end + +return { + current_scheduler = current_scheduler, + current_fiber = current_fiber, + now = now, + suspend = suspend, + yield = yield, + wait_fiber_error = wait_fiber_error, + + -- fiber management + spawn_raw = spawn, + stop = stop, + main = main, +} diff --git a/src/fibers/sched.lua b/src/fibers/sched.lua new file mode 100644 index 0000000..7c80dd6 --- /dev/null +++ b/src/fibers/sched.lua @@ -0,0 +1,208 @@ +-- fibers/sched.lua + +--- Core cooperative scheduler for fiber tasks. +---@module 'fibers.sched' + +local time = require 'fibers.utils.time' +local timer = require 'fibers.timer' + +local MAX_SLEEP_TIME = 10 + +--- A runnable task with a :run() method invoked by the scheduler. +---@class Task +---@field run fun(self: Task) + +--- A source of tasks (timers, pollers, etc.) that can enqueue work on a scheduler. +---@class TaskSource +---@field schedule_tasks fun(self: TaskSource, sched: Scheduler, now: number) +---@field cancel_all_tasks fun(self: TaskSource, sched: Scheduler)|nil +---@field wait_for_events fun(self: TaskSource, sched: Scheduler, now: number, timeout: number)|nil + +--- Main scheduler state and API. +---@class Scheduler +---@field next Task[] # tasks runnable next turn +---@field cur Task[] # tasks being run this turn +---@field sources TaskSource[] # timer, poller, etc. +---@field wheel Timer # timer wheel using the same clock +---@field maxsleep number # maximum sleep interval in seconds +---@field get_time fun(): number # monotonic time source +---@field event_waiter TaskSource|nil # single source used for blocking waits (if any) +---@field done boolean +local Scheduler = {} +Scheduler.__index = Scheduler + +--- Create a new scheduler instance. +---@param get_time? fun(): number # monotonic time source (defaults to fibers.utils.time.monotonic) +---@return Scheduler +local function new(get_time) + local now_src = get_time or time.monotonic + local now = now_src() + + local ret = setmetatable({ + next = {}, + cur = {}, + sources = {}, + wheel = timer.new(now), + maxsleep = MAX_SLEEP_TIME, + get_time = now_src, + event_waiter = nil, + done = false, + }, Scheduler) + + --- Timer source: advances the wheel and schedules due tasks. + ---@class TimerTaskSource : TaskSource + ---@field wheel Timer + local timer_task_source = { wheel = ret.wheel } + + --- Advance the timer wheel and schedule any due tasks. + ---@param sched Scheduler + ---@param now_ number + function timer_task_source:schedule_tasks(sched, now_) + self.wheel:advance(now_, sched) + end + + function timer_task_source:cancel_all_tasks() + end + + ret:add_task_source(timer_task_source) + return ret +end + +--- Register a task source with this scheduler. +--- A source must implement :schedule_tasks(sched, now). +--- If the source implements :wait_for_events, it becomes the scheduler's +--- sole event waiter (overwriting any previous one). +---@param source TaskSource +function Scheduler:add_task_source(source) + table.insert(self.sources, source) + if source.wait_for_events then + self.event_waiter = source + end +end + +--- Schedule a task to be run on the next turn. +---@param task Task +function Scheduler:schedule(task) + table.insert(self.next, task) +end + +--- Get current monotonic time from the scheduler's clock source. +---@return number +function Scheduler:monotime() + return self.get_time() +end + +--- Get the last time observed by the timer wheel. +---@return number +function Scheduler:now() + return self.wheel.now +end + +--- Schedule a task at an absolute time. +---@param t number # absolute time on the scheduler clock +---@param task Task +function Scheduler:schedule_at_time(t, task) + return self.wheel:add_absolute(t, task) +end + +--- Schedule a task after a delay from the wheel's current time. +---@param dt number # delay in seconds +---@param task Task +function Scheduler:schedule_after_sleep(dt, task) + return self.wheel:add_delta(dt, task) +end + +--- Ask all registered sources to enqueue any ready tasks. +---@param now number +function Scheduler:schedule_tasks_from_sources(now) + for i = 1, #self.sources do + self.sources[i]:schedule_tasks(self, now) + end +end + +--- Run all tasks currently scheduled as runnable. +--- If now is nil, the current monotonic time is used. +---@param now? number +function Scheduler:run(now) + if now == nil then + now = self:monotime() + end + + self:schedule_tasks_from_sources(now) + + self.cur, self.next = self.next, self.cur + + for i = 1, #self.cur do + local task = self.cur[i] + self.cur[i] = nil + task:run() + end +end + +--- Compute the next time the scheduler may need to wake. +--- If there are runnable tasks, returns now() (do not sleep). +--- Otherwise defers to the timer wheel, which returns a time or math.huge. +---@return number +function Scheduler:next_wake_time() + if #self.next > 0 then + return self:now() + end + return self.wheel:next_entry_time() +end + +--- Block until the next event or timeout. +--- Uses an event_waiter (e.g. poller) if present, otherwise sleeps. +function Scheduler:wait_for_events() + local now = self:monotime() + local next_time = self:next_wake_time() + + local timeout = math.min(self.maxsleep, next_time - now) + if timeout < 0 then timeout = 0 end + + if self.event_waiter then + self.event_waiter:wait_for_events(self, now, timeout) + else + -- No poller installed; fall back to process-blocking sleep. + time._block(timeout) + end +end + +--- Request that the scheduler main loop stops after the current iteration. +function Scheduler:stop() + self.done = true +end + +--- Run the scheduler main loop until stopped. +--- Repeatedly waits for events and runs ready tasks. +function Scheduler:main() + self.done = false + repeat + self:wait_for_events() + self:run(self:monotime()) + until self.done +end + +--- Attempt to drain runnable work and ask sources to cancel outstanding tasks. +--- Sources are given an opportunity to cancel pending work; the scheduler +--- continues to drive sources (including timers) while draining. +--- Returns true if the runnable queue is drained within the iteration limit. +---@return boolean drained # true if work queue drained, false on iteration limit +function Scheduler:shutdown() + for _ = 1, 100 do + for i = 1, #self.sources do + local src = self.sources[i] + if src.cancel_all_tasks then + src:cancel_all_tasks(self) + end + end + + if #self.next == 0 then return true end + + self:run() + end + return false +end + +return { + new = new, +} diff --git a/src/fibers/scope.lua b/src/fibers/scope.lua new file mode 100644 index 0000000..bd56c0f --- /dev/null +++ b/src/fibers/scope.lua @@ -0,0 +1,815 @@ +-- fibers/scope.lua +-- +-- Stable core structured concurrency scopes that complement the Op layer. +-- +-- 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. +-- * Finalisers may perform only Ops that are ready now. During finalisation, +-- scope-aware perform attempts the Op immediately and raises if it would +-- suspend. This permits explicit try-now helpers built with or_else(), +-- while preventing hidden waits in cleanup paths. +-- * 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) +-- - run_op(fn, ...) -> Op yielding status, report, ... (on not-ok: ... is primary) +-- +-- Notes +-- * Returning variable arity across boundaries follows Lua conventions. +-- 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 (via reports), not escalated. +-- +---@module 'fibers.scope' + +local runtime = require 'fibers.runtime' +local waitgroup = require 'fibers.waitgroup' +local oneshot = require 'fibers.oneshot' +local op = require 'fibers.op' +local dlist = require 'fibers.utils.dlist' +local safe = require 'coxpcall' + +local DEBUG = false + +--- 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 (...) + return { n = select('#', ...), ... } +end + +---------------------------------------------------------------------- +-- Cancellation sentinel (robust, non-colliding) +---------------------------------------------------------------------- + +local 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 + +---------------------------------------------------------------------- +-- Error normalisation policy (xpcall handlers) +---------------------------------------------------------------------- + +local function with_tb(msg, tb) + return DEBUG and (tb or debug.traceback(msg, 2)) or msg +end + +local function tb_handler(e, tb) + return is_cancelled(e) and e or with_tb(tostring(e), tb) +end + +local function join_tb_handler(e, tb) + return is_cancelled(e) and with_tb('join raised cancellation: ' .. tostring(cancel_reason(e)), tb) + or with_tb(tostring(e), tb) +end + +local finaliser_handler = tb_handler + +local FINALISER_WAIT_ERR = 'attempted to perform a non-ready Op during scope finalisation' + +---------------------------------------------------------------------- +-- 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 + +---@class Scope +---@field _id integer +---@field _parent Scope|nil +---@field _children table +---@field _order Scope[] +---@field _wg Waitgroup +---@field _closed boolean +---@field _close_reason any|nil +---@field _close_os Oneshot +---@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 +---@field _finalisers DList +---@field _finalising boolean +---@field _started boolean +---@field _join_started boolean +---@field _join_outcome ScopeJoinOutcome|nil +---@field _join_os Oneshot +local Scope = {} +Scope.__index = Scope + +-- Weak-key map: Fiber -> Scope for attribution of uncaught runtime fiber errors. +local fiber_scopes = setmetatable({}, { __mode = 'k' }) + +-- Process-wide root scope. +local root_scope + +-- Monotonic scope id sequence (local to the process). +local next_id = 0 + +local function current_fiber() + return runtime.current_fiber() +end + +---------------------------------------------------------------------- +-- Unscoped error handling +---------------------------------------------------------------------- + +local unscoped_error_handler = function (_, err) + io.stderr:write('Unscoped fiber error: ' .. tostring(err) .. '\n') +end + +---@param handler fun(fib:any, err:any) +local function set_unscoped_error_handler(handler) + if type(handler) ~= 'function' then error('unscoped error handler must be a function', 2) end + unscoped_error_handler = handler +end + +---------------------------------------------------------------------- +-- Current scope install/restore (fiber-local only) +---------------------------------------------------------------------- + +local function install_current_scope(s) + 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 + +local function restore_current_scope(fib, prev) + 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 + +---------------------------------------------------------------------- +-- 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 + +--- Build a primitive op from an oneshot-like readiness predicate. +---@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 + return true, get_values() + end + return false + end, function (suspension, wrap_fn) + local cancel = os:add_waiter(function () + if suspension:waiting() then 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) + 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 + +---@param self Scope +---@param child_outcomes? ScopeChildOutcome[] +---@return ScopeReport +local function make_report(self, child_outcomes) + return { + id = self._id, + extra_errors = copy_array(self._extra_errors), + children = child_outcomes or {}, + } +end + +--- 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 + 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 +end + +---------------------------------------------------------------------- +-- Observational status (non-blocking snapshot) +---------------------------------------------------------------------- + +---@return string st +---@return any v +function Scope:status() + local out = self._join_outcome + if out ~= nil then return out.st, out.primary 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 + +---@return string st +---@return any reason +function Scope:admission() + if self._closed then return 'closed', self._close_reason end + return 'open', nil +end + +---------------------------------------------------------------------- +-- Construction / root / current +---------------------------------------------------------------------- + +---@param parent Scope|nil +---@return Scope +local function new_scope(parent) + next_id = next_id + 1 + + local s = setmetatable({ + _id = next_id, + _parent = parent, + _children = {}, + _extra_errors = {}, + _order = {}, + _finalisers = dlist.new(), + _close_os = oneshot.new(), + _cancel_os = oneshot.new(), + _fault_os = oneshot.new(), + _join_os = oneshot.new(), + _wg = waitgroup.new(), + }, Scope) + + if parent then + parent._children[s] = true + parent._order[#parent._order + 1] = s + if parent._cancel_reason ~= nil then s:cancel(parent._cancel_reason) end + end + + return s +end + +---@return Scope +local function root() + if not root_scope then + root_scope = new_scope(nil) + runtime.spawn_raw(function () + while true do + local fib, err = runtime.wait_fiber_error() + if not is_cancelled(err) then + local s = fiber_scopes[fib] + if s then s:_record_fault(err) else unscoped_error_handler(fib, err) end + end + end + end) + end + 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() + return fib and (fiber_scopes[fib] or root()) or 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 + + local ord = self._order + for i = #ord, 1, -1 do + if ord[i] == child then + table.remove(ord, i) + break + end + end + + child._parent = nil +end + +function Scope:_detach_from_parent() + local p = self._parent + if p then p:_remove_child(self) end +end + +---@return Scope|nil child, any|nil err +function Scope:child() + local why = reject_reason(self) + if why then return nil, why end + return new_scope(self), nil +end + +---------------------------------------------------------------------- +-- Admission gate (close) +---------------------------------------------------------------------- + +---@param reason any|nil +function Scope:close(reason) + if self._join_outcome 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 + +---@return Op +function Scope:close_op() + return oneshot_value_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._join_outcome then return end + + -- Cancellation implies admission is closed. + self:close(reason) + + 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(self._cancel_reason) end +end + +function Scope:_record_fault(err) + if is_cancelled(err) then + return self:cancel(cancel_reason(err)) + end + + local e = (type(err) == 'string' or type(err) == 'number') and err or tostring(err) + + if self._failed_primary ~= nil then + self._extra_errors[#self._extra_errors + 1] = e + return + end + + self._failed_primary = e + self._fault_os:signal() + + -- single source of truth for cancellation + downward cascade + self:cancel(e) +end + +---@return Op +function Scope:cancel_op() + return oneshot_value_op( + function () return self._cancel_reason ~= nil end, + self._cancel_os, + function () return 'cancelled', self._cancel_reason end + ) +end + +---@return Op +function Scope:fault_op() + return oneshot_value_op( + function () return self._failed_primary ~= nil end, + self._fault_os, + 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 () + if self._failed_primary ~= nil then return 'failed', self._failed_primary end + return 'cancelled', self._cancel_reason + end) +end + +---------------------------------------------------------------------- +-- Finalisers +---------------------------------------------------------------------- + +---@param f fun(aborted:boolean, status:'ok'|'failed'|'cancelled', primary:any|nil) +---@return fun() detach +function Scope:finally(f) + if type(f) ~= 'function' then error('scope:finally expects a function', 2) end + + local fib = current_fiber() + if not fib then error('scope:finally must be called from inside a fiber', 2) end + + local cur = fiber_scopes[fib] or root() + if self._started and cur ~= self then + error('once started scope:finally must be called from within the target scope', 2) + end + + if self._finalising or self._join_outcome ~= nil then + error('scope:finally: scope is finalising or has joined', 2) + end + + local node = self._finalisers:push_tail(f) + return function () node:remove() end +end + +---------------------------------------------------------------------- +-- Spawning (attached obligations) +---------------------------------------------------------------------- + +---@param fn fun(s:Scope, ...): any +---@param ... any +---@return boolean ok, any|nil err +function Scope:spawn(fn, ...) + local why = reject_reason(self) + if why then return false, why end + + -- From this point, treat the scope as having started work. + self._started = true + + local args = pack(...) + self._wg:add(1) + + runtime.spawn_raw(function () + local ok, err = xpcall_in_scope(self, tb_handler, function () + return fn(self, unpack(args, 1, args.n)) + end) + if not ok then self:_record_fault(err) end + self._wg:done() + end) + + return true, nil +end + +---------------------------------------------------------------------- +-- Join (non-interruptible finalisation) +---------------------------------------------------------------------- + +---@param self Scope +---@return ScopeChildOutcome[] +function Scope:_finalise_join_body() + self:close('joining') + + local children = copy_array(self._order) + local child_outcomes = {} + + op.perform_raw(self._wg:wait_op()) + + for i = 1, #children do + local ch = children[i] + if ch and ch._parent == self then + local st, rep, primary = 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 st, primary = terminal_status(self) + local aborted = (st ~= 'ok') + + -- Freeze finaliser registration at the start of finalisation. + self._finalising = true + + local node = self._finalisers.tail + while node do + local prev = node.prev + local f = node.value + node:remove() -- ensure it cannot be run twice, and drop refs early + + if f then + local ok, err = safe.xpcall(function () + return f(aborted, st, primary) + 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) + end + st, primary = terminal_status(self) + aborted = (st ~= 'ok') + end + end + + node = prev + end + + return child_outcomes +end + +function Scope:_start_join_worker() + if self._join_started then return end + self._started = true + self._join_started = true + + runtime.spawn_raw(function () + local child_outcomes + 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) + local rep = make_report(self, child_outcomes or {}) + + self._join_outcome = { st = st, primary = primary, report = rep } + self._join_os:signal() + self:_detach_from_parent() + end) +end + +---@return Op +function Scope:join_op() + return oneshot_value_op( + function () return self._join_outcome ~= nil end, + self._join_os, + function () + local out = assert(self._join_outcome, 'join signalled without outcome') + return out.st, out.report, out.primary + end, + function () self:_start_join_worker() end + ) +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) + end +end + +---@param ev Op +---@return Op +local function finalising_try_op(ev) + -- Finalisers run after the scope is already failed, cancelled or joining. They + -- still need to be able to use explicit immediate attempts such as: + -- + -- tx:send_op(value):or_else(function () return nil, 'not_ready' end) + -- + -- but finalisation must not hide a suspension. or_else performs only the + -- Op's readiness probe and chooses the fallback if it would block. For + -- composite Ops this also triggers losing-arm nacks/abort handlers, which is + -- the right cleanup behaviour for an attempted synchronisation that did not + -- commit. + return ev:wrap(function (...) + return 'ok', ... + end):or_else(function () + return 'failed', FINALISER_WAIT_ERR + end) +end + +---@param ev Op +---@return Op +function Scope:try_op(ev) + assert_op_value(ev) + + return op.guard(function () + if self._finalising then + return finalising_try_op(ev) + 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 (...) + 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) + + return op.choice(body, self:not_ok_op()) + end) +end + +---@param ev Op +---@return 'ok'|'failed'|'cancelled', ... +function Scope:try(ev) + if not current_fiber() then error('scope:try must be called from inside a fiber', 2) end + return op.perform_raw(self:try_op(ev)) +end + +---@param ev Op +---@return any ... +function Scope:perform(ev) + 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 error(cancelled(r[2]), 0) end + error(r[2] or 'scope failed', 0) +end + +---------------------------------------------------------------------- +-- Boundaries +---------------------------------------------------------------------- + +---@param body_fn fun(s:Scope, ...): ... +---@param ... any +---@return Op +local function run_op(body_fn, ...) + if type(body_fn) ~= 'function' then error('scope.run_op expects a function', 2) end + + local args = pack(...) + + 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, child_err, results + + local function start_once() + if child ~= nil or child_err ~= nil then return end + + child, child_err = parent:child() + 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 + + s:close('body complete') + s:_start_join_worker() + end) + + if not ok_spawn then + child:_record_fault(spawn_err) + child:close('body spawn failed') + child:_start_join_worker() + end + end + + local function complete_from_join(suspension, wrap_fn) + 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() + suspension:complete(wrap_fn, 'ok', out.report, unpack(r, 1, r.n)) + else + suspension:complete(wrap_fn, out.st, out.report, out.primary) + end + end + + local function try_fn() return false end + + local function block_fn(suspension, wrap_fn) + start_once() + + if not child then + suspension:complete(wrap_fn, 'cancelled', make_report(parent, {}), child_err) + return + end + + 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 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, ...) + if type(body_fn) ~= 'function' then error('scope.run expects a function body', 2) end + if not current_fiber() then error('scope.run must be called from inside a fiber', 2) end + return op.perform_raw(run_op(body_fn, ...)) +end + +---------------------------------------------------------------------- +-- Public API +---------------------------------------------------------------------- + +return { + root = root, + current = current, + Scope = Scope, + + run = run, + run_op = run_op, + + cancelled = cancelled, + is_cancelled = is_cancelled, + cancel_reason = cancel_reason, + + set_debug = set_debug, + + set_unscoped_error_handler = set_unscoped_error_handler, +} diff --git a/src/fibers/sleep.lua b/src/fibers/sleep.lua new file mode 100644 index 0000000..de84eb0 --- /dev/null +++ b/src/fibers/sleep.lua @@ -0,0 +1,64 @@ +-- Use of this source code is governed by the Apache 2.0 license; see COPYING. + +--- Sleep operations for fibers. +--- Provides ops and helpers for suspending fibers for a duration or until a deadline. +---@module 'fibers.sleep' + +local op = require 'fibers.op' +local runtime = require 'fibers.runtime' + +local perform = require 'fibers.performer'.perform + +--- Primitive op that becomes ready when the absolute time t is reached. +---@param t number # absolute time on the runtime clock +---@return Op +local function deadline_op(t) + local function try() + return runtime.now() >= t + end + + --- Schedule completion of the suspension at time t. + ---@param suspension Suspension + ---@param wrap_fn WrapFn + local function block(suspension, wrap_fn) + local cancel_timer = suspension:at_time(t, suspension:complete_task(wrap_fn)) + suspension:add_cleanup(cancel_timer) + end + + return op.new_primitive(nil, try, block) +end + +--- Op that sleeps until absolute time t. +---@param t number # absolute time on the runtime clock +---@return Op +local function sleep_until_op(t) + return deadline_op(t) +end + +--- Sleep until absolute time t. +---@param t number # absolute time on the runtime clock +local function sleep_until(t) + return perform(sleep_until_op(t)) +end + +--- Op that sleeps for a duration dt. +---@param dt number # delay in seconds +---@return Op +local function sleep_op(dt) + return op.guard(function () + return deadline_op(runtime.now() + dt) + end) +end + +--- Sleep for a duration dt. +---@param dt number # delay in seconds +local function sleep(dt) + return perform(sleep_op(dt)) +end + +return { + sleep = sleep, + sleep_op = sleep_op, + sleep_until = sleep_until, + sleep_until_op = sleep_until_op, +} diff --git a/src/fibers/timer.lua b/src/fibers/timer.lua new file mode 100644 index 0000000..e4e3b90 --- /dev/null +++ b/src/fibers/timer.lua @@ -0,0 +1,221 @@ +-- fibers/timer.lua + +--- Monotonic timer built on a binary min-heap. +---@module 'fibers.timer' + +---@class TimerNode +---@field time number # absolute due time (monotonic seconds) +---@field obj any # scheduled payload +---@field index integer|nil # current heap index, nil when not queued + +---@alias TimerCancel fun(): boolean + +local floor, huge = math.floor, math.huge + +--- Simple min-heap keyed by node.time. +---@class Heap +---@field heap TimerNode[] +---@field size integer +local Heap = {} +Heap.__index = Heap + +---@return Heap +local function new_heap() + return setmetatable({ heap = {}, size = 0 }, Heap) +end + +---@param i integer +---@param j integer +function Heap:swap(i, j) + local heap = self.heap + heap[i], heap[j] = heap[j], heap[i] + heap[i].index = i + heap[j].index = j +end + +---@param node TimerNode +function Heap:push(node) + local size = self.size + 1 + self.size = size + node.index = size + self.heap[size] = node + self:heapify_up(size) +end + +---@return TimerNode|nil +function Heap:pop() + local size = self.size + if size == 0 then + return nil + end + + local heap = self.heap + local root = heap[1] + root.index = nil + + if size == 1 then + heap[1] = nil + self.size = 0 + return root + end + + local last = heap[size] + heap[size] = nil + self.size = size - 1 + heap[1] = last + last.index = 1 + self:heapify_down(1) + + return root +end + +---@param node TimerNode +---@return boolean +function Heap:remove(node) + local idx = node.index + if type(idx) ~= 'number' or idx < 1 or idx > self.size then + return false + end + + local heap = self.heap + if heap[idx] ~= node then + return false + end + + local size = self.size + node.index = nil + + if idx == size then + heap[size] = nil + self.size = size - 1 + return true + end + + local last = heap[size] + heap[size] = nil + self.size = size - 1 + heap[idx] = last + last.index = idx + + local parent = floor(idx / 2) + if idx > 1 and heap[idx].time < heap[parent].time then + self:heapify_up(idx) + else + self:heapify_down(idx) + end + + return true +end + +---@param idx integer +function Heap:heapify_up(idx) + local heap = self.heap + while idx > 1 do + local parent = floor(idx / 2) + if heap[parent].time <= heap[idx].time then + break + end + self:swap(parent, idx) + idx = parent + end +end + +---@param idx integer +function Heap:heapify_down(idx) + local heap = self.heap + local size = self.size + + while true do + local left = 2 * idx + local right = left + 1 + local smallest = idx + + if left <= size and heap[left].time < heap[smallest].time then + smallest = left + end + if right <= size and heap[right].time < heap[smallest].time then + smallest = right + end + + if smallest == idx then + break + end + + self:swap(idx, smallest) + idx = smallest + end +end + +---@class Timer +---@field now number # current timer time (monotonic seconds) +---@field heap Heap +local Timer = {} +Timer.__index = Timer + +--- Create a new timer instance. +---@param now number # initial monotonic time. +---@return Timer +local function new(now) + return setmetatable({ now = now, heap = new_heap() }, Timer) +end + +--- Schedule an object at absolute time t. +---@param t number # absolute due time +---@param obj any # payload to pass to the scheduler +---@return TimerCancel cancel # idempotent cancellation handle +function Timer:add_absolute(t, obj) + local node = { time = t, obj = obj, index = nil } + self.heap:push(node) + + local cancelled = false + return function () + if cancelled then + return false + end + + cancelled = true + local removed = self.heap:remove(node) + node.obj = nil + return removed + end +end + +--- Schedule an object after a delay from the current timer time. +---@param dt number # delay in seconds from self.now +---@param obj any # payload to pass to the scheduler +---@return TimerCancel cancel # idempotent cancellation handle +function Timer:add_delta(dt, obj) + return self:add_absolute(self.now + dt, obj) +end + +--- Get the time of the next scheduled entry, or math.huge if none exist. +---@return number +function Timer:next_entry_time() + local heap = self.heap + return heap.size > 0 and heap.heap[1].time or huge +end + +--- Pop the next scheduled entry without dispatching it. +---@return TimerNode|nil +function Timer:pop() + return self.heap:pop() +end + +--- Advance the timer to time t and dispatch all due entries. +---@param t number # new monotonic time +---@param sched { schedule: fun(self:any, obj:any) } +function Timer:advance(t, sched) + local heap = self.heap + + while heap.size > 0 and t >= heap.heap[1].time do + local node = assert(heap:pop()) -- non-nil since size>0 + local obj = node.obj + node.obj = nil + self.now = node.time + sched:schedule(obj) + end + + self.now = t +end + +return { new = new } diff --git a/src/fibers/utils/bytes.lua b/src/fibers/utils/bytes.lua new file mode 100644 index 0000000..685e495 --- /dev/null +++ b/src/fibers/utils/bytes.lua @@ -0,0 +1,50 @@ +-- fibers/utils/bytes.lua +-- +-- Unified buffer abstraction: +-- * bytes.RingBuf : ring buffer for bytes +-- * bytes.LinearBuf : growable linear buffer +-- +-- Backend selection: +-- - FFI-backed (LuaJIT or cffi) : fibers.utils.bytes.ffi +-- - Pure Lua rope/string-based : fibers.utils.bytes.lua +-- +-- This shim picks the first supported backend. + +---@module 'fibers.utils.bytes' + +---------------------------------------------------------------------- +-- Forward type declarations for tooling +---------------------------------------------------------------------- + +---@class RingBuf +---@field size integer # total capacity (bytes) +---@field read_avail fun(self: RingBuf): integer # available bytes to read +---@field write_avail fun(self: RingBuf): integer # available space to write +---@field take fun(self: RingBuf, n: integer): string # remove n bytes and return them +---@field put fun(self: RingBuf, s: string) # append bytes +---@field reset fun(self: RingBuf) # clear buffer +---@field find fun(self: RingBuf, needle: string): integer|nil # find substring in readable region + +---@class LinearBuf +---@field append fun(self: LinearBuf, s: string) # append bytes +---@field tostring fun(self: LinearBuf): string # materialise as a single string +---@field reset fun(self: LinearBuf) # clear buffer (optional but common) + +---@class BytesBackend +---@field RingBuf { new: fun(size?: integer): RingBuf } +---@field LinearBuf { new: fun(): LinearBuf } +---@field is_supported fun(): boolean + +local candidates = { + 'fibers.utils.bytes.ffi', + 'fibers.utils.bytes.lua', +} + +for _, name in ipairs(candidates) do + local ok, mod = pcall(require, name) + if ok and type(mod) == 'table' and mod.is_supported and mod.is_supported() then + return mod + end +end + +error('fibers.utils.bytes: no suitable bytes backend available on this platform') diff --git a/src/fibers/utils/bytes/ffi.lua b/src/fibers/utils/bytes/ffi.lua new file mode 100644 index 0000000..38a1b9e --- /dev/null +++ b/src/fibers/utils/bytes/ffi.lua @@ -0,0 +1,276 @@ +-- fibers/utils/bytes/ffi.lua +-- +-- FFI-backed byte buffers: +-- * RingBuf : fixed-capacity ring buffer +-- * LinearBuf : growable buffer + +---@module 'fibers.utils.bytes.ffi' + +local bit = rawget(_G, 'bit') or require 'bit32' +local ffi_c = require 'fibers.utils.ffi_compat' + +-- If there is no usable FFI layer, mark this backend unsupported. +if not (ffi_c.is_supported and ffi_c.is_supported()) then + return { + is_supported = function () return false end, + } +end + +local ffi = ffi_c.ffi +local band = bit.band + +ffi.cdef [[ + typedef unsigned int uint32_t; + typedef unsigned char uint8_t; + + typedef struct { + uint32_t read_idx; + uint32_t write_idx; + uint32_t size; + uint8_t buf[?]; + } fibers_ringbuf_t; +]] + +local ring_mt, lin_mt = {}, {} +ring_mt.__index = ring_mt +lin_mt.__index = lin_mt + +local ring_ct = ffi.metatype('fibers_ringbuf_t', ring_mt) + +local function to_u32(n) + return n % 2 ^ 32 +end + +local function pos(self, idx) + return band(idx, self.size - 1) +end + +---------------------------------------------------------------------- +-- RingBuf +---------------------------------------------------------------------- + +--- Initialise ring buffer. +function ring_mt:init(size) + assert(type(size) == 'number' and size > 0, 'RingBuf: positive size required') + assert(band(size, size - 1) == 0, 'RingBuf: size must be power of two') + self.size = size + self.read_idx = 0 + self.write_idx = 0 + return self +end + +function ring_mt:reset() + self.read_idx, self.write_idx = 0, 0 +end + +function ring_mt:read_avail() + return to_u32(self.write_idx - self.read_idx) +end + +function ring_mt:write_avail() + return self.size - self:read_avail() +end + +function ring_mt:is_empty() + return self.read_idx == self.write_idx +end + +function ring_mt:is_full() + return self:read_avail() == self.size +end + +local function copy_out(self, n) + local tmp = ffi.new('uint8_t[?]', n) + local size = self.size + local start = pos(self, self.read_idx) + local first = math.min(n, size - start) + + if first > 0 then + ffi.copy(tmp, self.buf + start, first) + end + + local rest = n - first + if rest > 0 then + ffi.copy(tmp + first, self.buf, rest) + end + + self.read_idx = self.read_idx + ffi.cast('uint32_t', n) + return tmp +end + +local function copy_in(self, src, n) + local size = self.size + local start = pos(self, self.write_idx) + local first = math.min(n, size - start) + + if first > 0 then + ffi.copy(self.buf + start, src, first) + end + + local rest = n - first + if rest > 0 then + ffi.copy(self.buf, src + first, rest) + end + + self.write_idx = self.write_idx + ffi.cast('uint32_t', n) +end + +function ring_mt:put(str) + assert(type(str) == 'string', 'RingBuf:put expects a string') + local n = #str + if n == 0 then return end + assert(n <= self:write_avail(), 'RingBuf: write would exceed capacity') + local tmp = ffi.new('uint8_t[?]', n) + ffi.copy(tmp, str, n) + copy_in(self, tmp, n) +end + +-- Opaque mark of the current write position (for tail rollback). +-- Intended for "publish then possibly roll back" patterns in higher layers. +function ring_mt:mark_write() + return self.write_idx +end + +-- Rewind the write position to a previously obtained mark. +-- Caller must ensure no consumer progress happened since the mark. +function ring_mt:rewind_write(mark) + -- mark is expected to be the cdata returned by mark_write() + self.write_idx = mark +end + +function ring_mt:take(n) + assert(type(n) == 'number' and n >= 0, 'RingBuf:take expects non-negative count') + local avail = self:read_avail() + if avail == 0 or n == 0 then + return '' + end + if n > avail then + n = avail + end + local tmp = copy_out(self, n) + return ffi.string(tmp, n) +end + +function ring_mt:tostring() + local n = self:read_avail() + if n == 0 then + return '' + end + local old = self.read_idx + local tmp = copy_out(self, n) + self.read_idx = old + return ffi.string(tmp, n) +end + +function ring_mt:find(pattern) + assert(type(pattern) == 'string' and #pattern > 0, + 'RingBuf:find expects non-empty string') + local s = self:tostring() + local i = s:find(pattern, 1, true) + return i and (i - 1) or nil +end + +local function RingBuf_new(size) + local self = ring_ct(size) + return ring_mt.init(self, size) +end + +function ring_mt:capacity() + return self.size +end + +function ring_mt:advance_read(n) + assert(type(n) == 'number' and n >= 0, 'RingBuf:advance_read expects non-negative count') + local avail = self:read_avail() + assert(n <= avail, 'RingBuf:advance_read out of range') + if n == 0 then return end + self.read_idx = self.read_idx + ffi.cast('uint32_t', n) +end + +function ring_mt:peek(n) + assert(type(n) == 'number' and n >= 0, 'RingBuf:peek expects non-negative count') + local avail = self:read_avail() + if avail == 0 or n == 0 then + return '' + end + if n > avail then + n = avail + end + + -- Like tostring() but only for n bytes, and without mutating read_idx. + local tmp = ffi.new('uint8_t[?]', n) + local size = self.size + local start = pos(self, self.read_idx) + local first = math.min(n, size - start) + + if first > 0 then + ffi.copy(tmp, self.buf + start, first) + end + + local rest = n - first + if rest > 0 then + ffi.copy(tmp + first, self.buf, rest) + end + + return ffi.string(tmp, n) +end + +---------------------------------------------------------------------- +-- LinearBuf +---------------------------------------------------------------------- + +local function LinearBuf_new(cap) + cap = cap or 4096 + assert(cap > 0, 'LinearBuf.new: positive initial capacity required') + local buf = ffi.new('uint8_t[?]', cap) + return setmetatable({ buf = buf, len = 0, cap = cap }, lin_mt) +end + +function lin_mt:reset() + self.len = 0 +end + +function lin_mt:ensure(extra) + local needed = self.len + extra + if needed <= self.cap then + return + end + + local new_cap = self.cap > 0 and self.cap or 1 + while new_cap < needed do + new_cap = new_cap * 2 + end + + local new_buf = ffi.new('uint8_t[?]', new_cap) + if self.len > 0 then + ffi.copy(new_buf, self.buf, self.len) + end + self.buf, self.cap = new_buf, new_cap +end + +function lin_mt:append(str) + assert(type(str) == 'string', 'LinearBuf:append expects a string') + local n = #str + if n == 0 then return end + self:ensure(n) + ffi.copy(self.buf + self.len, str, n) + self.len = self.len + n +end + +function lin_mt:tostring() + if self.len == 0 then + return '' + end + return ffi.string(self.buf, self.len) +end + +---------------------------------------------------------------------- +-- Public API +---------------------------------------------------------------------- + +return { + RingBuf = { new = RingBuf_new }, + LinearBuf = { new = LinearBuf_new }, + has_ffi = true, + is_supported = function () return true end, +} diff --git a/src/fibers/utils/bytes/lua.lua b/src/fibers/utils/bytes/lua.lua new file mode 100644 index 0000000..612269f --- /dev/null +++ b/src/fibers/utils/bytes/lua.lua @@ -0,0 +1,308 @@ +-- fibers/utils/bytes/lua.lua +-- +-- Pure Lua byte buffers: +-- * RingBuf : fixed-capacity ring buffer (rope of strings) +-- * LinearBuf : growable buffer (rope of strings) + +---@module 'fibers.utils.bytes.lua' + +---------------------------------------------------------------------- +-- Shared rope helpers +---------------------------------------------------------------------- + +local function rope_tostring(chunks, head_idx, head_off) + local n = #chunks + if head_idx > n then + return '' + end + + local out = {} + local first = chunks[head_idx] + if head_off > 0 then + first = first:sub(head_off + 1) + end + out[1] = first + + for i = head_idx + 1, n do + out[#out + 1] = chunks[i] + end + + return table.concat(out) +end + +---------------------------------------------------------------------- +-- RingBuf +---------------------------------------------------------------------- + +---@class LuaRingBuf : RingBuf +local RingBuf_mt = {} +RingBuf_mt.__index = RingBuf_mt + +local function RingBuf_new(size) + assert(type(size) == 'number' and size > 0, + 'RingBuf.new: positive size required') + return setmetatable({ + chunks = {}, + head_idx = 1, + head_off = 0, + len = 0, + size = size, + }, RingBuf_mt) +end + +local function compact(self) + local hi = self.head_idx + if hi <= 8 and hi <= (#self.chunks / 2) then + return + end + for i = 1, hi - 1 do + self.chunks[i] = nil + end + local k = 1 + for j = hi, #self.chunks do + self.chunks[k] = self.chunks[j] + if k ~= j then + self.chunks[j] = nil + end + k = k + 1 + end + self.head_idx = 1 +end + +function RingBuf_mt:reset() + self.chunks = {} + self.head_idx = 1 + self.head_off = 0 + self.len = 0 +end + +function RingBuf_mt:read_avail() + return self.len +end + +function RingBuf_mt:write_avail() + return self.size - self.len +end + +function RingBuf_mt:is_empty() + return self.len == 0 +end + +function RingBuf_mt:is_full() + return self.len >= self.size +end + +function RingBuf_mt:advance_read(n) + assert(n >= 0 and n <= self.len, 'RingBuf:advance_read out of range') + if n == 0 then return end + + self.len = self.len - n + + local i = self.head_idx + local off = self.head_off + + while n > 0 and i <= #self.chunks do + local chunk = self.chunks[i] + local rem = #chunk - off + if n < rem then + off = off + n + n = 0 + else + n = n - rem + i = i + 1 + off = 0 + end + end + + self.head_idx = i + self.head_off = off + compact(self) +end + +function RingBuf_mt:write(src, count) + assert(type(src) == 'string', 'RingBuf:write expects string') + local n = count or #src + assert(n <= #src, 'RingBuf:write count > #src') + assert(n <= self:write_avail(), 'RingBuf: write xrun') + if n == 0 then return end + + local s = src + if n < #s then + s = s:sub(1, n) + end + self.len = self.len + n + self.chunks[#self.chunks + 1] = s +end + +function RingBuf_mt:read(_, count) + assert(count >= 0 and count <= self.len, 'RingBuf: read xrun') + if count == 0 then + return '' + end + + local out = {} + local need = count + local i = self.head_idx + local off = self.head_off + local n = #self.chunks + + while need > 0 and i <= n do + local chunk = self.chunks[i] + local rem = #chunk - off + local take = math.min(need, rem) + out[#out + 1] = chunk:sub(off + 1, off + take) + need = need - take + if take == rem then + i = i + 1 + off = 0 + else + off = off + take + end + end + + local s = table.concat(out) + self.head_idx = i + self.head_off = off + self.len = self.len - count + compact(self) + return s +end + +function RingBuf_mt:put(str) + assert(type(str) == 'string', 'RingBuf:put expects a string') + local n = #str + if n == 0 then return end + assert(n <= self:write_avail(), 'RingBuf: write would exceed capacity') + self:write(str, n) +end + +-- Opaque mark of the current write position (for tail rollback). +-- We only need enough state to drop newly appended chunks and restore len. +function RingBuf_mt:mark_write() + return { n = #self.chunks, len = self.len } +end + +-- Rewind the write position to a previously obtained mark. +-- Caller must ensure no consumer progress happened since the mark. +function RingBuf_mt:rewind_write(mark) + assert(type(mark) == 'table', 'RingBuf:rewind_write expects mark table') + local n = mark.n or 0 + local len = mark.len or 0 + + for i = #self.chunks, n + 1, -1 do + self.chunks[i] = nil + end + + self.len = len +end + +function RingBuf_mt:take(n) + assert(type(n) == 'number' and n >= 0, 'RingBuf:take expects non-negative count') + if self.len == 0 or n == 0 then + return '' + end + if n > self.len then + n = self.len + end + return self:read(nil, n) +end + +function RingBuf_mt:tostring() + if self.len == 0 then + return '' + end + return rope_tostring(self.chunks, self.head_idx, self.head_off) +end + +function RingBuf_mt:find(pattern) + assert(type(pattern) == 'string' and #pattern > 0, + 'RingBuf:find expects non-empty string') + local s = self:tostring() + local i = s:find(pattern, 1, true) + return i and (i - 1) or nil +end + +function RingBuf_mt:capacity() + return self.size +end + +function RingBuf_mt:peek(n) + assert(type(n) == 'number' and n >= 0, 'RingBuf:peek expects non-negative count') + if n == 0 or self.len == 0 then + return '' + end + if n > self.len then + n = self.len + end + + -- Same as read(nil, n) but without advancing. + local out = {} + local need = n + local i = self.head_idx + local off = self.head_off + local last = #self.chunks + + while need > 0 and i <= last do + local chunk = self.chunks[i] + local rem = #chunk - off + local take = math.min(need, rem) + out[#out + 1] = chunk:sub(off + 1, off + take) + need = need - take + if take == rem then + i = i + 1 + off = 0 + else + off = off + take + end + end + + return table.concat(out) +end + +---------------------------------------------------------------------- +-- LinearBuf +---------------------------------------------------------------------- + +---@class LuaLinearBuf : LinearBuf +local LinearBuf_mt = {} +LinearBuf_mt.__index = LinearBuf_mt + +local function LinearBuf_new(_) + return setmetatable({ + chunks = {}, + head_idx = 1, + head_off = 0, + len = 0, + }, LinearBuf_mt) +end + +function LinearBuf_mt:reset() + self.chunks = {} + self.head_idx = 1 + self.head_off = 0 + self.len = 0 +end + +function LinearBuf_mt:append(s) + if not s or #s == 0 then return end + self.len = self.len + #s + self.chunks[#self.chunks + 1] = s +end + +function LinearBuf_mt:tostring() + if self.len == 0 then + return '' + end + return rope_tostring(self.chunks, self.head_idx, self.head_off) +end + +---------------------------------------------------------------------- +-- Public API +---------------------------------------------------------------------- + +return { + RingBuf = { new = RingBuf_new }, + LinearBuf = { new = LinearBuf_new }, + has_ffi = false, + is_supported = function () return true end, +} diff --git a/src/fibers/utils/dlist.lua b/src/fibers/utils/dlist.lua new file mode 100644 index 0000000..25bb678 --- /dev/null +++ b/src/fibers/utils/dlist.lua @@ -0,0 +1,76 @@ +---@module 'fibers.utils.dlist' + +---@class DListNode +---@field list DList|nil +---@field prev DListNode|nil +---@field next DListNode|nil +---@field value any +local DListNode = {} +DListNode.__index = DListNode + +---@class DList +---@field head DListNode|nil +---@field tail DListNode|nil +---@field len integer +local DList = {} +DList.__index = DList + +function DListNode:remove() + local list = self.list + if not list then + return false + end + + local p, n = self.prev, self.next + if p then p.next = n else list.head = n end + if n then n.prev = p else list.tail = p end + + list.len = list.len - 1 + + self.list, self.prev, self.next = nil, nil, nil + self.value = nil + return true +end + +function DList:push_tail(value) + local node = setmetatable({ list = self, prev = self.tail, next = nil, value = value }, DListNode) + if self.tail then + self.tail.next = node + else + self.head = node + end + self.tail = node + self.len = self.len + 1 + return node +end + +function DList:pop_head() + local node = self.head + if not node then return nil end + local value = node.value + node:remove() + return value +end + +function DList:peek_head() + local node = self.head + return node and node.value or nil +end + +function DList:empty() + return self.len == 0 +end + +function DList:length() + return self.len +end + +---@return DList +local function new() + return setmetatable({ head = nil, tail = nil, len = 0 }, DList) +end + +return { + new = new, + DList = DList, +} diff --git a/src/fibers/utils/ffi_compat.lua b/src/fibers/utils/ffi_compat.lua new file mode 100644 index 0000000..2decd15 --- /dev/null +++ b/src/fibers/utils/ffi_compat.lua @@ -0,0 +1,60 @@ +-- fibers/utils/ffi_compat.lua +-- +-- Unified wrapper around LuaJIT ffi and cffi. +-- +-- Exposes: +-- M.ffi : the provider module (luajit ffi or cffi) +-- M.C : ffi.C +-- M.tonumber : cdata-aware tonumber +-- M.type : cdata-aware type +-- M.errno : errno getter +-- M.is_null : NULL / nullptr check for pointers + +local ok, ffi = pcall(require, 'ffi') +local provider = 'luajit_ffi' + +if not ok then + local ok2, cffi = pcall(require, 'cffi') + if not ok2 then + return { + is_supported = function () return false end, + } + end + ffi = cffi + provider = 'cffi' +end + +-- Normalise helper functions. +local tonumber_fn = rawget(ffi, 'tonumber') or tonumber +local type_fn = rawget(ffi, 'type') or type + +local function errno() + -- Both LuaJIT ffi and cffi expose errno() in their API. + return ffi.errno() +end + +local function is_null(ptr) + -- For LuaJIT ffi, NULL pointers compare equal to nil. + -- For cffi, you must compare with cffi.nullptr. + if ptr == nil then + return true + end + local ffi_nullptr = rawget(ffi, 'nullptr') + if ffi_nullptr and ptr == ffi_nullptr then + return true + end + return false +end + +local M = { + ffi = ffi, + C = ffi.C, + provider = provider, + tonumber = tonumber_fn, + type = type_fn, + errno = errno, + is_null = is_null, + is_supported = function () return true end, +} + +return M diff --git a/fibers/utils/fifo.lua b/src/fibers/utils/fifo.lua similarity index 55% rename from fibers/utils/fifo.lua rename to src/fibers/utils/fifo.lua index ee7997f..2632185 100644 --- a/fibers/utils/fifo.lua +++ b/src/fibers/utils/fifo.lua @@ -8,49 +8,49 @@ Fifo.__index = Fifo --- Create a new FIFO queue. -- @treturn Fifo The created FIFO queue. local function new() - return setmetatable({ - count = 0, -- total items ever pushed - first = 1, -- index of first item - items = {} -- storage table - }, Fifo) + return setmetatable({ + count = 0, -- total items ever pushed + first = 1, -- index of first item + items = {} -- storage table + }, Fifo) end --- Push a value onto the queue. -- @param x The value to push (can be nil) function Fifo:push(x) - self.count = self.count + 1 - self.items[self.count] = x + self.count = self.count + 1 + self.items[self.count] = x end --- Check if the queue is empty. -- @treturn boolean True if empty function Fifo:empty() - return self.first > self.count + return self.first > self.count end --- Peek at the first item without removing it. -- @return The first item function Fifo:peek() - assert(not self:empty(), "queue is empty") - return self.items[self.first] + assert(not self:empty(), 'queue is empty') + return self.items[self.first] end --- Remove and return the first item. -- @return The first item function Fifo:pop() - assert(not self:empty(), "queue is empty") - local val = self.items[self.first] - self.items[self.first] = nil -- allow GC - self.first = self.first + 1 - return val + assert(not self:empty(), 'queue is empty') + local val = self.items[self.first] + self.items[self.first] = nil -- allow GC + self.first = self.first + 1 + return val end --- Return the length of the queue. -- @return The queue length function Fifo:length() - return self.count - self.first + 1 + return self.count - self.first + 1 end return { - new = new + new = new } diff --git a/src/fibers/utils/time.lua b/src/fibers/utils/time.lua new file mode 100644 index 0000000..1621b63 --- /dev/null +++ b/src/fibers/utils/time.lua @@ -0,0 +1,34 @@ +-- fibers.utils.time +-- +-- Top-level time provider shim. +-- +-- Backends (priority order): +-- 1. fibers.utils.time.ffi - clock_gettime + nanosleep (via ffi_compat) +-- 2. fibers.utils.time.luaposix - luaposix clock_gettime/nanosleep +-- 3. fibers.utils.time.nixio - nixio gettime, nanosleep + /proc/uptime +-- 4. fibers.utils.time.linux - /proc/uptime or os.time + os.execute("sleep") +-- +---@module 'fibers.utils.time' + +local candidates = { + 'fibers.utils.time.ffi', + 'fibers.utils.time.luaposix', + 'fibers.utils.time.nixio', + 'fibers.utils.time.linux', +} + +local chosen + +for _, name in ipairs(candidates) do + local ok, mod = pcall(require, name) + if ok and type(mod) == 'table' and mod.is_supported and mod.is_supported() then + chosen = mod + break + end +end + +if not chosen then + error('fibers.utils.time: no suitable time backend available on this platform') +end + +return chosen diff --git a/src/fibers/utils/time/core.lua b/src/fibers/utils/time/core.lua new file mode 100644 index 0000000..4b954ee --- /dev/null +++ b/src/fibers/utils/time/core.lua @@ -0,0 +1,88 @@ +-- fibers/utils/time/core.lua +-- +-- Core glue for time backends. +-- +---@class TimeSourceInfo +---@field name string +---@field resolution number +---@field monotonic boolean +---@field epoch string|nil + +---@class TimeSleepInfo +---@field name string +---@field resolution number +---@field clock "realtime"|"monotonic"|string + +---@class TimeOps +---@field realtime fun(): number +---@field monotonic fun(): number +---@field realtime_info TimeSourceInfo +---@field monotonic_info TimeSourceInfo +---@field _block fun(dt: number): boolean, string|nil +---@field block_info TimeSleepInfo +---@field is_supported fun(): boolean|nil -- optional + +local function build_backend(ops) + assert(type(ops) == 'table', 'time backend ops must be a table') + assert(type(ops.realtime) == 'function', 'time ops.realtime must be a function') + assert(type(ops.monotonic) == 'function', 'time ops.monotonic must be a function') + assert(type(ops._block) == 'function', 'time ops._block must be a function') + assert(type(ops.realtime_info) == 'table', 'time ops.realtime_info must be a table') + assert(type(ops.monotonic_info) == 'table', 'time ops.monotonic_info must be a table') + assert(type(ops.block_info) == 'table', 'time ops.block_info must be a table') + + local function realtime() + return ops.realtime() + end + + local function monotonic() + return ops.monotonic() + end + + local function block(dt) + assert(type(dt) == 'number' and dt >= 0, 'block: dt must be a non-negative number') + return ops._block(dt) + end + + local function info() + return { + realtime = ops.realtime_info, + monotonic = ops.monotonic_info, + sleep = ops.block_info, + } + end + + local function realtime_source() + return ops.realtime_info + end + + local function monotonic_source() + return ops.monotonic_info + end + + local function block_source() + return ops.block_info + end + + local function is_supported() + if type(ops.is_supported) == 'function' then + return not not ops.is_supported() + end + return true + end + + return { + realtime = realtime, + monotonic = monotonic, + _block = block, + info = info, + realtime_source = realtime_source, + monotonic_source = monotonic_source, + block_source = block_source, + is_supported = is_supported, + } +end + +return { + build_backend = build_backend, +} diff --git a/src/fibers/utils/time/ffi.lua b/src/fibers/utils/time/ffi.lua new file mode 100644 index 0000000..26ed3c4 --- /dev/null +++ b/src/fibers/utils/time/ffi.lua @@ -0,0 +1,167 @@ +-- fibers/utils/time/ffi.lua +-- +-- Time backend using clock_gettime(2) and nanosleep(2) via ffi_compat. +-- Linux-oriented; requires fibers.utils.ffi_compat. +-- +---@module 'fibers.utils.time.ffi' + +local core = require 'fibers.utils.time.core' +local ffi_c = require 'fibers.utils.ffi_compat' + +if not (ffi_c.is_supported and ffi_c.is_supported()) then + return { is_supported = function () return false end } +end + +local ffi = ffi_c.ffi +local C = ffi_c.C +local toint = ffi_c.tonumber +local get_errno = ffi_c.errno + +ffi.cdef [[ + typedef long time_t; + typedef long suseconds_t; + + struct timespec { + time_t tv_sec; + long tv_nsec; + }; + + int clock_gettime(int clk_id, struct timespec *tp); + int clock_getres(int clk_id, struct timespec *tp); + int nanosleep(const struct timespec *req, struct timespec *rem); + char *strerror(int errnum); +]] + +-- Linux/glibc constants. +local CLOCK_REALTIME = 0 +local CLOCK_MONOTONIC = 1 + +local EINTR = 4 + +---------------------------------------------------------------------- +-- Helpers +---------------------------------------------------------------------- + +local function strerror(e) + local s = C.strerror(e) + if s == nil then + return 'errno ' .. tostring(e) + end + return ffi.string(s) +end + +local function ts_to_seconds(ts) + return tonumber(ts.tv_sec) + tonumber(ts.tv_nsec) * 1e-9 +end + +local function read_clock(clk_id) + local ts = ffi.new('struct timespec[1]') + local rc = toint(C.clock_gettime(clk_id, ts)) + if rc ~= 0 then + local e = get_errno() + error('clock_gettime failed: ' .. strerror(e)) + end + return ts_to_seconds(ts[0]) +end + +local function clock_resolution(clk_id) + local ts = ffi.new('struct timespec[1]') + local rc = toint(C.clock_getres(clk_id, ts)) + if rc ~= 0 then + -- Fall back to a conservative default (1 ms) if the query fails. + return 1e-3 + end + return ts_to_seconds(ts[0]) +end + +---------------------------------------------------------------------- +-- Blocking sleep +---------------------------------------------------------------------- + +local function _block(dt) + if dt <= 0 then + return true, nil + end + + local req = ffi.new('struct timespec[1]') + local rem = ffi.new('struct timespec[1]') + + local sec = math.floor(dt) + local frac = dt - sec + local nsec = math.floor(frac * 1e9 + 0.5) + if nsec >= 1000000000 then + sec = sec + 1 + nsec = nsec - 1000000000 + end + + req[0].tv_sec = sec + req[0].tv_nsec = nsec + + while true do + local rc = toint(C.nanosleep(req, rem)) + if rc == 0 then + return true, nil + end + local e = get_errno() + if e == EINTR then + -- Interrupted; continue with remaining time. + req[0].tv_sec = rem[0].tv_sec + req[0].tv_nsec = rem[0].tv_nsec + else + return false, 'nanosleep failed: ' .. strerror(e) + end + end +end + +---------------------------------------------------------------------- +-- Metadata and support +---------------------------------------------------------------------- + +local realtime_res = clock_resolution(CLOCK_REALTIME) +local monotonic_res = clock_resolution(CLOCK_MONOTONIC) + +local function is_supported() + -- Probe both clocks once; treat errors as lack of support. + local ok = pcall(function () + read_clock(CLOCK_REALTIME) + read_clock(CLOCK_MONOTONIC) + end) + return ok +end + +local ops = { + realtime = function () + return read_clock(CLOCK_REALTIME) + end, + + monotonic = function () + return read_clock(CLOCK_MONOTONIC) + end, + + realtime_info = { + name = 'clock_gettime(CLOCK_REALTIME)', + resolution = realtime_res, + monotonic = false, + epoch = 'unix', + }, + + monotonic_info = { + name = 'clock_gettime(CLOCK_MONOTONIC)', + resolution = monotonic_res, + monotonic = true, + epoch = 'unspecified', + }, + + _block = _block, + + block_info = { + name = 'nanosleep', + resolution = monotonic_res, + clock = 'monotonic', + }, + + is_supported = is_supported, +} + + +return core.build_backend(ops) diff --git a/src/fibers/utils/time/linux.lua b/src/fibers/utils/time/linux.lua new file mode 100644 index 0000000..e24abbb --- /dev/null +++ b/src/fibers/utils/time/linux.lua @@ -0,0 +1,189 @@ +-- fibers/utils/time/linux.lua +-- +-- Pure Lua fallback time backend for Linux. +-- Monotonic time from /proc/uptime; blocking sleep via shell "sleep". +-- +---@module 'fibers.utils.time.linux' + +local core = require 'fibers.utils.time.core' + +---------------------------------------------------------------------- +-- Monotonic time: /proc/uptime +---------------------------------------------------------------------- + +local function read_uptime_raw() + local f = io.open('/proc/uptime', 'r') + if not f then + return nil + end + local line = f:read('*l') + f:close() + if not line then + return nil + end + local first = line:match('^(%S+)') + return first +end + +local function read_uptime() + local token = read_uptime_raw() + if not token then + return nil + end + local v = tonumber(token) + return v +end + +-- Estimate resolution from the number of fractional digits in /proc/uptime. +local monotonic_resolution = 1.0 +do + local token = read_uptime_raw() + if token then + local frac = token:match('%.([0-9]+)') + if frac and #frac > 0 then + monotonic_resolution = 10 ^ (- #frac) + end + end +end + +local function monotonic() + local v = read_uptime() + if not v then + error('fallback monotonic: /proc/uptime not available') + end + return v +end + +---------------------------------------------------------------------- +-- Realtime: prefer luasocket.gettime, fall back to os.time +---------------------------------------------------------------------- + +local realtime_fn +local realtime_name +local realtime_resolution + +do + local ok, socket = pcall(require, 'socket') + if ok and type(socket) == 'table' and type(socket.gettime) == 'function' then + realtime_fn = socket.gettime + realtime_name = 'socket.gettime' + realtime_resolution = 1e-6 -- approximate; depends on platform + else + realtime_fn = function () return os.time() end + realtime_name = 'os.time' + realtime_resolution = 1.0 + end +end + +local function realtime() + return realtime_fn() +end + +---------------------------------------------------------------------- +-- Blocking sleep: shell "sleep", fractional if available +---------------------------------------------------------------------- + +local function command_succeeded(a, b, c) + -- Lua 5.1: boolean, "exit"/"signal", code + if a == true then + return true + end + -- Lua 5.2+: numeric exit code + if type(a) == 'number' then + return a == 0 + end + -- Some implementations: nil, "exit", code + if a == nil and b == 'exit' then + return c == 0 + end + return false +end + +local function run_sleep(arg) + local cmd = 'sleep ' .. arg + local a, b, c = os.execute(cmd) + return command_succeeded(a, b, c) +end + +local has_fractional_sleep +do + local ok = run_sleep('0.01') + has_fractional_sleep = ok +end + +local function _block(dt) + if dt <= 0 then + return true, nil + end + + if has_fractional_sleep then + -- Round to centiseconds. + local centis = math.max(1, math.floor(dt * 100 + 0.5)) + local arg = string.format('%.2f', centis / 100.0) + local ok = run_sleep(arg) + if not ok then + -- As a last resort, busy-wait using monotonic time. + local start = monotonic() + while monotonic() - start < dt do end + return true, 'sleep command failed; used busy-wait' + end + return true, nil + else + -- Integral seconds only; round up so we do not undersleep. + local secs = math.ceil(dt) + local ok = run_sleep(tostring(secs)) + if not ok then + local start = monotonic() + while monotonic() - start < dt do end + return true, 'sleep command failed; used busy-wait' + end + return true, nil + end +end + +---------------------------------------------------------------------- +-- Capability and metadata +---------------------------------------------------------------------- + +local function is_supported() + -- This backend is only considered usable if /proc/uptime exists. + local f = io.open('/proc/uptime', 'r') + if f then + f:close() + return true + end + return false +end + +local ops = { + realtime = realtime, + monotonic = monotonic, + + realtime_info = { + name = realtime_name, + resolution = realtime_resolution, + monotonic = false, + epoch = 'unix', + }, + + monotonic_info = { + name = '/proc/uptime', + resolution = monotonic_resolution, + monotonic = true, + epoch = 'unspecified', + }, + + _block = _block, + + block_info = { + name = has_fractional_sleep + and 'sleep (shell, fractional)' + or 'sleep (shell, integral)', + resolution = has_fractional_sleep and 0.01 or 1.0, + clock = 'realtime', + }, + + is_supported = is_supported, +} + +return core.build_backend(ops) diff --git a/src/fibers/utils/time/luaposix.lua b/src/fibers/utils/time/luaposix.lua new file mode 100644 index 0000000..8eb51a4 --- /dev/null +++ b/src/fibers/utils/time/luaposix.lua @@ -0,0 +1,137 @@ +-- fibers/utils/time/luaposix.lua +-- +-- Time backend using posix.time (clock_gettime/nanosleep) and posix.unistd. +-- +---@module 'fibers.utils.time.luaposix' + +local core = require 'fibers.utils.time.core' + +local ok_time, ptime = pcall(require, 'posix.time') +if not ok_time or type(ptime) ~= 'table' then + return { is_supported = function () return false end } +end + +local ok_unistd, unistd = pcall(require, 'posix.unistd') +if not ok_unistd or type(unistd) ~= 'table' then + return { is_supported = function () return false end } +end + +local errno = require 'posix.errno' + +local CLOCK_REALTIME = ptime.CLOCK_REALTIME +local CLOCK_MONOTONIC = ptime.CLOCK_MONOTONIC + +if not CLOCK_REALTIME or not CLOCK_MONOTONIC then + return { is_supported = function () return false end } +end + +---------------------------------------------------------------------- +-- Helpers +---------------------------------------------------------------------- + +local function ts_to_seconds(ts) + return ts.tv_sec + ts.tv_nsec * 1e-9 +end + +local function read_clock(clk_id) + local ts, err = ptime.clock_gettime(clk_id) + if not ts then + error('clock_gettime failed: ' .. tostring(err)) + end + return ts_to_seconds(ts) +end + +local function clock_resolution(clk_id) + if type(ptime.clock_getres) == 'function' then + local ts = select(1, ptime.clock_getres(clk_id)) + if ts then + return ts_to_seconds(ts) + end + end + -- Fallback if clock_getres is missing or fails. + return 1e-3 +end + +---------------------------------------------------------------------- +-- Blocking sleep +---------------------------------------------------------------------- + +local function _block(dt) + if dt <= 0 then + return true, nil + end + + local sec = math.floor(dt) + local frac = dt - sec + local nsec = math.floor(frac * 1e9 + 0.5) + if nsec >= 1000000000 then + sec = sec + 1 + nsec = nsec - 1000000000 + end + + local req = { tv_sec = sec, tv_nsec = nsec } + + while true do + local ok, err, eno, rem = ptime.nanosleep(req) + if ok then + return true, nil + end + if eno == errno.EINTR and rem then + -- Interrupted; continue with remaining time. + req = rem + else + return false, err or ('nanosleep failed (errno ' .. tostring(eno) .. ')') + end + end +end + +---------------------------------------------------------------------- +-- Metadata and support +---------------------------------------------------------------------- + +local realtime_res = clock_resolution(CLOCK_REALTIME) +local monotonic_res = clock_resolution(CLOCK_MONOTONIC) + +local function is_supported() + local ok = pcall(function () + read_clock(CLOCK_MONOTONIC) + end) + return ok +end + +local ops = { + realtime = function () + return read_clock(CLOCK_REALTIME) + end, + + monotonic = function () + return read_clock(CLOCK_MONOTONIC) + end, + + realtime_info = { + name = 'posix.time.clock_gettime(CLOCK_REALTIME)', + resolution = realtime_res, + monotonic = false, + epoch = 'unix', + }, + + monotonic_info = { + name = 'posix.time.clock_gettime(CLOCK_MONOTONIC)', + resolution = monotonic_res, + monotonic = true, + epoch = 'unspecified', + }, + + _block = _block, + + block_info = { + name = 'posix.time.nanosleep', + resolution = monotonic_res, + clock = 'monotonic', + }, + + is_supported = is_supported, +} + + +return core.build_backend(ops) diff --git a/src/fibers/utils/time/nixio.lua b/src/fibers/utils/time/nixio.lua new file mode 100644 index 0000000..7154a0a --- /dev/null +++ b/src/fibers/utils/time/nixio.lua @@ -0,0 +1,176 @@ +-- fibers/utils/time/nixio.lua +-- +-- Time backend using nixio.gettime, nixio.nanosleep and /proc/uptime. +-- +---@module 'fibers.utils.time.nixio' + +local core = require 'fibers.utils.time.core' + +local ok, nixio = pcall(require, 'nixio') +if not ok or type(nixio) ~= 'table' then + return { is_supported = function () return false end } +end + +---------------------------------------------------------------------- +-- Monotonic time via /proc/uptime +---------------------------------------------------------------------- + +local UPTIME_PATH = '/proc/uptime' + +--- Read the first field from /proc/uptime as a number. +---@return number|nil value, string|nil err +local function read_uptime() + local f, err = io.open(UPTIME_PATH, 'r') + if not f then + return nil, ('failed to open %s: %s'):format(UPTIME_PATH, tostring(err)) + end + + local line = f:read('*l') + f:close() + + if not line then + return nil, ('failed to read %s: empty file'):format(UPTIME_PATH) + end + + local first = line:match('^%s*(%S+)') + if not first then + return nil, ('failed to parse %s: no fields'):format(UPTIME_PATH) + end + + local val = tonumber(first) + if not val then + return nil, ("failed to parse %s: non-numeric uptime '%s'"):format( + UPTIME_PATH, + tostring(first) + ) + end + + return val, nil +end + +-- Probe once at load time so metadata and is_supported() can report accurately. +local monotonic_ok +do + local v = read_uptime() + monotonic_ok = (v ~= nil) +end + +---------------------------------------------------------------------- +-- Core time functions +---------------------------------------------------------------------- + +--- Wall-clock time: seconds since Unix epoch as a Lua number. +local function realtime() + -- nixio.gettime() already returns epoch seconds with fractional part. + return nixio.gettime() +end + +--- Monotonic time in seconds. +--- +--- Primary source: /proc/uptime (seconds since boot, fractional). +--- If this fails at call time for any reason, fall back to +--- nixio.gettime() rather than raising; monotonic_info.name still +--- reflects /proc/uptime as the intended primary source. +local function monotonic() + local v = read_uptime() + if v ~= nil then + return v + end + -- Degraded path: not truly monotonic, but avoids hard failure. + return nixio.gettime() +end + +---------------------------------------------------------------------- +-- Blocking sleep +---------------------------------------------------------------------- + +---@param dt number +---@return boolean ok, string|nil err +local function _block(dt) + if type(dt) ~= 'number' then + return false, 'sleep: dt must be a number' + end + if dt <= 0 then + return true, nil + end + + local deadline = monotonic() + dt + + while true do + local now = monotonic() + local remaining = deadline - now + if remaining <= 0 then + return true, nil + end + + local secs = math.floor(remaining) + if secs < 0 then secs = 0 end + + local frac = remaining - secs + if frac < 0 then frac = 0 end + local nsec = math.floor(frac * 1e9 + 0.5) + + -- nixio.nanosleep(seconds, nanoseconds) + local ok_ns, err, eno = nixio.nanosleep(secs, nsec) + if not ok_ns then + local msg = tostring(err or eno or '') + if msg ~= '' and msg ~= 'EINTR' then + return false, ('nixio.nanosleep failed: %s'):format(msg) + end + -- EINTR or unknown soft error: loop again and recompute remaining. + end + end +end + +---------------------------------------------------------------------- +-- Metadata and support +---------------------------------------------------------------------- + +local function is_supported() + -- Require: nixio present, gettime/nanosleep available, and /proc/uptime + -- readable at initialisation. + if type(nixio.gettime) ~= 'function' then + return false + end + if type(nixio.nanosleep) ~= 'function' then + return false + end + if not monotonic_ok then + return false + end + return true +end + +local ops = { + realtime = realtime, + + monotonic = monotonic, + + realtime_info = { + name = 'nixio.gettime', + resolution = 0.001, -- approximate; depends on platform + monotonic = false, + epoch = 'unix', + }, + + monotonic_info = { + name = '/proc/uptime', + -- /proc/uptime is typically centisecond resolution. + resolution = 0.01, + monotonic = true, + epoch = 'unspecified', + }, + + _block = _block, + + block_info = { + name = 'nixio.nanosleep', + resolution = 0.001, + clock = 'monotonic', + }, + + is_supported = is_supported, +} + + +return core.build_backend(ops) diff --git a/src/fibers/wait.lua b/src/fibers/wait.lua new file mode 100644 index 0000000..c3d1301 --- /dev/null +++ b/src/fibers/wait.lua @@ -0,0 +1,354 @@ +--- +-- Wait module. +-- +-- Internal helper utilities for building blocking primitives: +-- +-- * Waitset: keyed sets of waiting tasks with unlink tokens. +-- * waitable(register, step, wrap_fn?): build an op from +-- a step function and a registration function. +-- +-- This module is intended for backend / primitive implementations +-- (pollers, in-memory pipes, streams, timers). Normal library users +-- should not need to depend on it directly. +-- +-- Design notes: +-- - This module is exception-neutral. It does not interpret Lua +-- errors as part of op semantics. +-- - step() and register(...) are assumed to be non-blocking and +-- non-yielding. If they raise, this is treated as a bug and the +-- surrounding scope/fiber machinery will surface the failure. +---@module 'fibers.wait' + +local op = require 'fibers.op' + +local unpack = rawget(table, 'unpack') or _G.unpack +local pack = rawget(table, 'pack') or function (...) + return { n = select('#', ...), ... } +end + +local function id_wrap(...) + return ... +end + +---------------------------------------------------------------------- +-- Waitset: keyed lists of tasks with unlink tokens +---------------------------------------------------------------------- + +--- Keyed set of scheduler tasks grouped by an arbitrary key. +---@class Waitset +---@field buckets table # key -> list of scheduler tasks +local Waitset = {} +Waitset.__index = Waitset + +--- Token returned from Waitset:add. +--- unlink() removes the task from the waitset; it is idempotent. +---@class WaitToken +---@field _waitset Waitset +---@field key any +---@field task Task +---@field unlink fun(self: WaitToken): boolean # true if bucket emptied + +--- Create a new Waitset instance. +---@return Waitset +local function new_waitset() + return setmetatable({ buckets = {} }, Waitset) +end + +--- Remove element at index i by swapping with the tail. +---@param t Task[] +---@param i integer +local function remove_at(t, i) + local n = #t + t[i] = t[n] + t[n] = nil +end + +--- Add a task under a given key. +-- +-- @param key Arbitrary key (fd, object, tag, etc.). +-- @param task Scheduler task object (must have :run()). +-- +-- @return token Table with token:unlink() -> bucket_empty:boolean. +---@param key any +---@param task Task +---@return WaitToken +function Waitset:add(key, task) + local buckets = self.buckets + local list = buckets[key] + if not list then + list = {} + buckets[key] = list + end + + list[#list + 1] = task + local idx = #list + local unlinked = false + + ---@class WaitToken + local token = { + _waitset = self, + key = key, + task = task, + } + + --- Unlink this task from the waitset. + --- Best-effort: falls back to a reverse scan if the stored index + --- has been invalidated by earlier removals. + ---@param tok WaitToken + ---@return boolean bucket_empty + function token.unlink(tok) + if unlinked then + return false + end + unlinked = true + + local bs = tok._waitset.buckets + local l = bs[tok.key] + if not l or #l == 0 then + return false + end + + -- Best-effort removal; index may be stale. + if idx <= #l and l[idx] == tok.task then + remove_at(l, idx) + else + for i = #l, 1, -1 do + if l[i] == tok.task then + remove_at(l, i) + break + end + end + end + + if #l == 0 then + bs[tok.key] = nil + return true + end + return false + end + + return token +end + +--- Take and remove all waiters for a key. +--- +--- Returns the list (which the caller may iterate and discard), or nil. +---@param key any +---@return Task[]|nil +function Waitset:take_all(key) + local list = self.buckets[key] + if not list then + return nil + end + self.buckets[key] = nil + return list +end + +--- Take and remove a single waiter (LIFO) for a key. +--- +--- Returns the task or nil. +---@param key any +---@return Task|nil +function Waitset:take_one(key) + local list = self.buckets[key] + if not list or #list == 0 then + return nil + end + local idx = #list + local task = list[idx] + list[idx] = nil + if #list == 0 then + self.buckets[key] = nil + end + return task +end + +--- Return whether there are no waiters for this key. +---@param key any +---@return boolean +function Waitset:is_empty(key) + local list = self.buckets[key] + return not list or #list == 0 +end + +--- Return the number of waiters for this key. +---@param key any +---@return integer +function Waitset:size(key) + local list = self.buckets[key] + return list and #list or 0 +end + +--- Remove all waiters for a single key without notifying them. +---@param key any +function Waitset:clear_key(key) + self.buckets[key] = nil +end + +--- Remove all waiters for all keys without notifying them. +function Waitset:clear_all() + self.buckets = {} +end + +--- Notify and schedule all waiters for a key. +---@param key any +---@param scheduler Scheduler +function Waitset:notify_all(key, scheduler) + local list = self:take_all(key) + if not list then return end + for i = 1, #list do + scheduler:schedule(list[i]) + list[i] = nil + end +end + +--- Notify and schedule a single waiter (LIFO) for a key. +---@param key any +---@param scheduler Scheduler +function Waitset:notify_one(key, scheduler) + local task = self:take_one(key) + if not task then return end + scheduler:schedule(task) +end + +---------------------------------------------------------------------- +-- waitable: (register, step, wrap_fn?) -> Op +-- waitable2: (register, probe_step, run_step, wrap_fn?) -> Op +---------------------------------------------------------------------- + +-- Normalise "want" without restricting it to rd/wr/any. +-- * nil/false -> nil +-- * 'any' is treated specially by register_with_want +-- * everything else is passed through to register(...) +local function normalise_want(want) + return (want == nil or want == false) and nil or want +end + +--- Build a waitable Op from a register function and two step functions. +-- +-- probe_step() -> done:boolean, ... +-- * Must be non-blocking and must not yield. +-- * Should be side-effect neutral when returning done==false. +-- * May return (false, want) where want is any token understood by register(). +-- +-- run_step() -> done:boolean, ... +-- * Must be non-blocking and must not yield. +-- * May perform stateful progress (e.g. fill buffers, advance state machines). +-- +-- register(task, waker, want) -> token +-- * Must arrange for task:run() when progress may be possible. +-- * want is passed through (except 'any', see below). +-- * token:unlink() (if present) is called on abort to cancel registration. +-- +-- waker capability: +-- * waker:wakeup(task) +-- * waker:at_time(t, task) +-- * waker:after(dt, task) +-- +-- Special want: +-- * want == 'any' registers both ('rd' and 'wr') and unlinks both on abort. +-- +---@param register fun(task: Task, waker: table, want: any): WaitToken +---@param probe_step fun(): boolean, ... +---@param run_step fun(): boolean, ... +---@param wrap_fn? WrapFn +---@return Op +local function waitable2(register, probe_step, run_step, wrap_fn) + assert(type(register) == 'function', 'waitable2: register must be a function') + assert(type(probe_step) == 'function', 'waitable2: probe_step must be a function') + assert(type(run_step) == 'function', 'waitable2: run_step must be a function') + + wrap_fn = wrap_fn or id_wrap + + return op.guard(function () + local token, last_want, cleanup_added, waker + + local function unlink() + local t = token + token = nil + if t and t.unlink then t:unlink() end + end + + local function capture_want(step_fn) + local r = pack(step_fn()) + last_want = r[1] and nil or normalise_want(r[2]) + return r + end + + local function register_any(task, waker_) + local t1 = register(task, waker_, 'rd') + local t2 = register(task, waker_, 'wr') + return { + unlink = function () + if t1 and t1.unlink then t1:unlink() end + if t2 and t2.unlink then t2:unlink() end + return false + end, + } + end + + local function arm(task, suspension, leaf_wrap, want) + if not cleanup_added then + cleanup_added = true + suspension:add_cleanup(unlink) + end + + unlink() + + if want == 'any' then + token = register_any(task, waker) -- see note below + else + token = register(task, waker, want) + end + end + + local function try() + local r = capture_want(probe_step) + return unpack(r, 1, r.n) + end + + local function block(suspension, leaf_wrap) + waker = { + wakeup = function (_, task_) suspension:wakeup(task_) end, + at_time = function (_, t, task_) suspension:at_time(t, task_) end, + after = function (_, dt, task_) suspension:after(dt, task_) end, + } + + local task + task = { + run = function () + if not suspension:waiting() then return end + + local r = capture_want(run_step) + if r[1] then + unlink() + return suspension:complete(leaf_wrap, unpack(r, 2, r.n)) + end + + arm(task, suspension, leaf_wrap, last_want) + end, + } + + -- Use want captured by the most recent try(). + arm(task, suspension, leaf_wrap, last_want) + end + + return op.new_primitive(wrap_fn, try, block):on_abort(unlink) + end) +end + + +--- Backwards-compatible wrapper: a single step is used for both probe and run. +---@param register fun(task: Task, waker: table, want: any): WaitToken +---@param step fun(): boolean, ... +---@param wrap_fn? WrapFn +---@return Op +local function waitable(register, step, wrap_fn) + return waitable2(register, step, step, wrap_fn) +end + +return { + new_waitset = new_waitset, + waitable = waitable, + waitable2 = waitable2, +} diff --git a/src/fibers/waitgroup.lua b/src/fibers/waitgroup.lua new file mode 100644 index 0000000..fb5ebbf --- /dev/null +++ b/src/fibers/waitgroup.lua @@ -0,0 +1,88 @@ +-- fibers/waitgroup.lua +--- +-- Wait group for tracking completion of a set of tasks. +-- A waitgroup supports generations: when the counter returns to zero, +-- the current generation completes and a new one starts on the next increment. +---@module 'fibers.waitgroup' + +local op = require 'fibers.op' +local perform = require 'fibers.performer'.perform +local cond_mod = require 'fibers.cond' + +--- Waitgroup with a counter and per-generation condition. +---@class Waitgroup +---@field _counter integer +---@field _cond Cond|nil # per-generation condition; nil when there is no active generation +local Waitgroup = {} +Waitgroup.__index = Waitgroup + +--- Create a new waitgroup. +---@return Waitgroup +local function new() + return setmetatable({ + _counter = 0, + _cond = nil, -- per-generation condition; nil when idle + }, Waitgroup) +end + +--- Adjust the waitgroup counter by delta. +--- When the counter returns to zero, the current generation completes. +---@param delta integer +function Waitgroup:add(delta) + if delta == 0 then + return + end + + local old_count = self._counter + local new_count = old_count + delta + + if new_count < 0 then + error('waitgroup counter goes negative') + end + + self._counter = new_count + + if new_count == 0 then + -- This generation completes: wake any waiters and drop the condition. + if self._cond then + self._cond:signal() + self._cond = nil + end + elseif old_count == 0 and new_count > 0 then + -- Starting a new generation: create a condition for new work. + self._cond = cond_mod.new() + end +end + +--- Decrement the waitgroup counter by one. +function Waitgroup:done() + self:add(-1) +end + +--- Build an Op that completes when the current generation drains. +---@return Op +function Waitgroup:wait_op() + -- Build the op lazily at perform time. + return op.guard(function () + -- If there is nothing outstanding, fire immediately. + if self._counter == 0 then + return op.always() + end + + -- Active generation: delegate to the generation's condition. + local cond = assert(self._cond, 'waitgroup internal error: missing condition for active generation') + return cond:wait_op() + end) +end + +--- Block until the current generation completes. +---@return any ... +function Waitgroup:wait() + return perform(self:wait_op()) +end + +return { + --- Construct a new waitgroup. + ---@return Waitgroup + new = new, +} diff --git a/tests/io_backend_family.lua b/tests/io_backend_family.lua new file mode 100644 index 0000000..5c220af --- /dev/null +++ b/tests/io_backend_family.lua @@ -0,0 +1,147 @@ +-- tests/io_backend_family.lua +-- +-- Test helper for forcing a single fibers.io backend family in a fresh Lua +-- process. This deliberately prevents mixed fd/poller/exec backend selection. +-- It must be required before any fibers.io selector or higher-level fibers.io +-- module is loaded. + +local M = {} + +local families = { + ffi = { + fd = 'fibers.io.fd_backend.ffi', + poller = 'fibers.io.poller.epoll', + exec = 'fibers.io.exec_backend.pidfd', + disable = { + 'fibers.io.fd_backend.posix', + 'fibers.io.fd_backend.nixio', + 'fibers.io.poller.select', + 'fibers.io.poller.nixio', + 'fibers.io.exec_backend.sigchld', + 'fibers.io.exec_backend.posix_reaper', + 'fibers.io.exec_backend.nixio', + }, + }, + posix = { + fd = 'fibers.io.fd_backend.posix', + poller = 'fibers.io.poller.select', + exec = function () + if rawget(_G, 'jit') then + return 'fibers.io.exec_backend.posix_reaper' + end + return 'fibers.io.exec_backend.sigchld' + end, + disable = { + 'fibers.io.fd_backend.ffi', + 'fibers.io.fd_backend.nixio', + 'fibers.io.poller.epoll', + 'fibers.io.poller.nixio', + 'fibers.io.exec_backend.pidfd', + 'fibers.io.exec_backend.nixio', + }, + }, + nixio = { + fd = 'fibers.io.fd_backend.nixio', + poller = 'fibers.io.poller.nixio', + exec = 'fibers.io.exec_backend.nixio', + disable = { + 'fibers.io.fd_backend.ffi', + 'fibers.io.fd_backend.posix', + 'fibers.io.poller.epoll', + 'fibers.io.poller.select', + 'fibers.io.exec_backend.pidfd', + 'fibers.io.exec_backend.sigchld', + 'fibers.io.exec_backend.posix_reaper', + }, + }, +} + +local selectors = { + 'fibers.io.fd_backend', + 'fibers.io.poller', + 'fibers.io.exec_backend', + 'fibers.io.file', + 'fibers.io.socket', + 'fibers.io.stream', + 'fibers.io.exec', +} + +local function already_loaded(name) + return package.loaded[name] ~= nil +end + +local function fail_if_io_loaded() + for _, name in ipairs(selectors) do + assert(not already_loaded(name), + ('cannot force backend family after %s has already been loaded'):format(name)) + end +end + +local function disabled_loader(name) + return function() + error(('backend module %s disabled by io_backend_family test gate'):format(name), 0) + end +end + +function M.expected(family) + local spec = families[family] + assert(spec, ('unknown backend family %q'):format(tostring(family))) + return spec +end + +local function expected_exec(spec) + if type(spec.exec) == 'function' then + return spec.exec() + end + return spec.exec +end + +function M.force(family) + local spec = M.expected(family) + fail_if_io_loaded() + for _, name in ipairs(spec.disable) do + package.loaded[name] = nil + package.preload[name] = disabled_loader(name) + end + _G.__FIBERS_TEST_IO_BACKEND_FAMILY = family + return spec +end + +local function assert_supported(name) + local ok, mod = pcall(require, name) + assert(ok, ('failed to require %s: %s'):format(name, tostring(mod))) + assert(type(mod) == 'table', ('%s did not return a table'):format(name)) + assert(type(mod.is_supported) == 'function', ('%s has no is_supported()'):format(name)) + assert(mod.is_supported(), ('%s is not supported in this runtime'):format(name)) + return mod +end + +function M.assert_selected(family) + local spec = M.expected(family) + assert_supported(spec.fd) + assert_supported(spec.poller) + local exec_name = expected_exec(spec) + assert_supported(exec_name) + + local fd_backend = require 'fibers.io.fd_backend' + local poller = require 'fibers.io.poller' + local exec_be = require 'fibers.io.exec_backend' + + assert(fd_backend == package.loaded[spec.fd], + ('fd_backend selector did not choose %s'):format(spec.fd)) + assert(poller == package.loaded[spec.poller], + ('poller selector did not choose %s'):format(spec.poller)) + assert(exec_be == package.loaded[exec_name], + ('exec_backend selector did not choose %s'):format(exec_name)) + + for _, name in ipairs(spec.disable) do + -- The selector deliberately probes earlier candidates with pcall(require, name). + -- A disabled preload loader may therefore leave package.loaded[name] as + -- false/nil depending on the Lua implementation. What must not happen is + -- successful loading of a backend module table. + assert(type(package.loaded[name]) ~= 'table', + ('disabled backend was unexpectedly loaded: %s'):format(name)) + end +end + +return M diff --git a/tests/test.lua b/tests/test.lua index abc8374..c15984b 100644 --- a/tests/test.lua +++ b/tests/test.lua @@ -1,33 +1,38 @@ -package.path = "../?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path package.path = package.path .. ';/usr/lib/lua/?.lua;/usr/lib/lua/?/init.lua' local sep = '-' local modules = { - { 'utils', 'fixed_buffer' }, - { 'stream' }, - { 'stream', 'file' }, - { 'stream', 'mem' }, - { 'stream', 'compat' }, - { 'stream', 'socket' }, - { 'timer' }, - { 'sched' }, - { 'fiber' }, - { 'channel' }, - { 'queue' }, - { 'cond' }, - { 'sleep' }, - { 'epoll' }, - { 'pollio' }, - { 'exec' }, - { 'waitgroup' }, - { 'alarm' }, - { 'context' } + { 'utils', 'bytes' }, + { 'utils', 'dlist' }, + { 'utils', 'bytes_stress' }, + { 'io', 'file' }, + { 'io', 'mem' }, + { 'io', 'stream' }, + { 'io', 'socket' }, + { 'io', 'upload_stress' }, + { 'io', 'exec_backend' }, + { 'io', 'exec' }, + { 'io', 'backend_families' }, + { 'timer' }, + { 'alarm' }, + { 'sched' }, + { 'runtime' }, + { 'channel' }, + { 'mailbox' }, + { 'pulse' }, + { 'oneshot' }, + { 'cond' }, + { 'sleep' }, + { 'sleep', 'timer_cancel' }, + { 'waitgroup' }, + { 'scope' }, } for _, j in ipairs(modules) do - local test_file_name = "test".."_"..table.concat(j, sep)..".lua" - dofile(test_file_name) + local test_file_name = 'test' .. '_' .. table.concat(j, sep) .. '.lua' + dofile(test_file_name) end print('all tests passed!') diff --git a/tests/test_alarm.lua b/tests/test_alarm.lua index a8af3ac..86fc7a5 100644 --- a/tests/test_alarm.lua +++ b/tests/test_alarm.lua @@ -1,248 +1,392 @@ ---- Tests the Alarms implementation. -print('testing: fibers.alarm') +package.path = '../src/?.lua;' .. package.path --- look one level up -package.path = "../?.lua;" .. package.path +-- test_alarm.lua -local fiber = require 'fibers.fiber' -local alarm = require 'fibers.alarm' -local sleep = require 'fibers.sleep' -local sc = require 'fibers.utils.syscall' +print('testing: fibers.alarm') -alarm.install_alarm_handler() -alarm.clock_synced() +local fibers = require 'fibers' +local sleep_mod = require 'fibers.sleep' +local alarm_mod = require 'fibers.alarm' -local function abs_test(secs) - io.write("Starting Absolute test ... ") - io.flush() - local starttime = sc.realtime() - alarm.wait_absolute(starttime + secs) - assert(sc.realtime() - starttime < secs + 0.1) - print("complete!") -end +local sleep = sleep_mod.sleep -local function next_error() - io.write("Starting Next Error test ... ") - io.flush() - local err = alarm.wait_next({year=2000}) - assert(err) - print("complete!") +local function approx_equal(a, b, eps) + eps = eps or 1e-3 + return math.abs(a - b) <= eps end -local function next_msec_test(t) - io.write("Starting Millisecond test ... ") - io.flush() - local start = sc.realtime() - alarm.wait_next({msec=t}) - local epoch = sc.realtime() - assert(epoch%1 * 1e3 - t < 50, "alarm didn't fire within 0.05 seconds of due time") - assert(epoch - start < 1, "next Millisecond should fire within 1 second") - print("complete!") -end +------------------------------------------------------------------------ +-- Shared wall-clock for tests +------------------------------------------------------------------------ -local function next_sec_test(secs) - io.write("Starting Second test ... ") - io.flush() - local starttime = sc.realtime() - local sleep_until = starttime + secs - local t_table = os.date("*t", sleep_until) - local _, err = alarm.wait_next({sec=t_table.sec}) - assert(not err) - assert(sc.realtime() - starttime < secs + 0.1) - print("complete!") -end +-- We control "wall clock" via this variable. +local wall_time = 0 -local function next_min_test(secs) - io.write("Starting Minute test ... ") - io.flush() - local starttime = sc.realtime() - local sleep_until = starttime + secs - local t_table = os.date("*t", sleep_until) - alarm.wait_next({min=t_table.min, sec=t_table.sec}) - assert(sc.realtime() - starttime < secs + 0.1) - print("complete!") +local function now_fn() + return wall_time end -local function next_hour_test(secs) - io.write("Starting Hour test ... ") - io.flush() - local starttime = sc.realtime() - local sleep_until = starttime + secs - local t_table = os.date("*t", sleep_until) - alarm.wait_next({hour=t_table.hour, min=t_table.min, sec=t_table.sec}) - assert(sc.realtime() - starttime < secs + 0.1) - print("complete!") -end +-- Install our test time source once. Alarm code only allows this once. +alarm_mod.set_time_source(now_fn) -local function next_day_test(secs) - io.write("Starting Day test ... ") - io.flush() - local starttime = sc.realtime() - local sleep_until = starttime + secs - local t_table = os.date("*t", sleep_until) - alarm.wait_next({day=t_table.day,hour=t_table.hour, min=t_table.min, sec=t_table.sec}) - assert(sc.realtime() - starttime < secs + 0.1) - print("complete!") -end +------------------------------------------------------------------------ +-- Test 1: basic one-shot alarm fires at expected time +------------------------------------------------------------------------ -local function next_wday_test(secs) - io.write("Starting Weekday test ... ") - io.flush() - local starttime = sc.realtime() - local sleep_until = starttime + secs - local t_table = os.date("*t", sleep_until) - alarm.wait_next({wday=t_table.wday,hour=t_table.hour, min=t_table.min, sec=t_table.sec}) - assert(sc.realtime() - starttime < secs + 0.1) - print("complete!") -end +local function test_basic_alarm() + print('[test_basic_alarm] start') + + wall_time = 0 + + local calls = {} + + local function next_time(last, now) + calls[#calls + 1] = { last = last, now = now } + if last ~= nil then + -- one-shot: only fire once + return nil + end + -- Fire 0.1 seconds after "now" + return now + 0.1 + end + + local al = alarm_mod.new { next_time = next_time } -local function next_month_test(secs) - io.write("Starting Month test ... ") - io.flush() - local starttime = sc.realtime() - local sleep_until = starttime + secs - local t_table = os.date("*t", sleep_until) - alarm.wait_next({month=t_table.month, day=t_table.day,hour=t_table.hour, min=t_table.min, sec=t_table.sec}) - assert(sc.realtime() - starttime < secs + 0.1) - print("complete!") + local result = {} + + fibers.spawn(function () + local ok, alarm, fired_epoch = fibers.perform(al:wait_op()) + result.ok = ok + result.alarm = alarm + result.time = fired_epoch + end) + + -- Give the spawned fiber a chance to set up its wait + sleep(0.01) + + -- Let 0.5s of monotonic time pass, which is comfortably > 0.1 + sleep(0.5) + + assert(result.ok == true, 'basic alarm did not fire') + assert(result.alarm == al, 'basic alarm: unexpected alarm instance') + assert(approx_equal(result.time, 0.1), + ('basic alarm: expected fired_epoch ~= 0.1 (got %f)'):format(result.time)) + + assert(#calls >= 1, 'basic alarm: next_time was never called') + assert(approx_equal(calls[1].now, 0), + ('basic alarm: expected first now ~= 0 (got %f)'):format(calls[1].now)) + + print('[test_basic_alarm] ok') end -local function next_yday_test(secs) - io.write("Starting Yearday test ... ") - io.flush() - local starttime = sc.realtime() - local sleep_until = starttime + secs - local t_table = os.date("*t", sleep_until) - alarm.wait_next({yday=t_table.yday,hour=t_table.hour, min=t_table.min, sec=t_table.sec}) - assert(sc.realtime() - starttime < secs + 0.1) - print("complete!") +------------------------------------------------------------------------ +-- Test 2: pre-emptive reschedule when time_changed() is called +------------------------------------------------------------------------ + +local function test_preemptive_reschedule() + print('[test_preemptive_reschedule] start') + + wall_time = 0 + + -- Offset controls how far in the future the alarm fires. + local offset = 10 + + local calls = {} + + local function next_time(last, now) + calls[#calls + 1] = { last = last, now = now } + if last ~= nil then + -- one-shot for this test: only fire once + return nil + end + return now + offset + end + + local al = alarm_mod.new { next_time = next_time } + + local result = {} + + fibers.spawn(function () + local ok, alarm, fired_epoch = fibers.perform(al:wait_op()) + result.ok = ok + result.alarm = alarm + result.time = fired_epoch + end) + + -- Allow the spawned fiber to start its wait and schedule the initial sleep + sleep(0.01) + + -- At this point: + -- wall_time == 0 + -- next_time(nil, 0) -> 0 + offset (=10) + -- so the alarm is sleeping for dt = 10 seconds in monotonic time. + + -- Now simulate a civil-time change: + -- * new notion of "wall now", + -- * new offset (much shorter wait), + -- * and notify alarms via time_changed(). + wall_time = 100 -- new wall "now" + offset = 1 -- next firing should now be at 101 + + alarm_mod.time_changed() + + -- After time_changed(), the alarm's wait_op should: + -- * wake from the clock-change branch, + -- * clear _next_wall, + -- * recompute next_wall from next_time(nil, 100) -> 101, + -- * sleep for dt = 1, + -- * then fire at last_fired_epoch == 101. + + -- Let enough monotonic time pass for the shorter dt=1 sleep to complete. + sleep(2.0) + + assert(result.ok == true, 'preemptive alarm did not fire') + assert(result.alarm == al, 'preemptive alarm: unexpected alarm instance') + assert(approx_equal(result.time, 101), + ('preemptive alarm: expected fired_epoch ~= 101 (got %f)'):format(result.time)) + + -- Check that next_time was called at least twice: + assert(#calls >= 2, + ('preemptive alarm: expected at least 2 calls to next_time, got %d'):format(#calls)) + assert(approx_equal(calls[1].now, 0), + ('preemptive alarm: first now ~= 0 (got %f)'):format(calls[1].now)) + assert(approx_equal(calls[2].now, 100), + ('preemptive alarm: second now ~= 100 (got %f)'):format(calls[2].now)) + + print('[test_preemptive_reschedule] ok') end -local function buffer_test() - io.write("Starting Buffer test ... ") - io.flush() - -- first absolutes - easy to test - local t_sleep, t_sleep_before_realtime = 1, 2 - alarm.clock_desynced() - fiber.spawn(function () - sleep.sleep(t_sleep_before_realtime) - alarm.clock_synced() - end) - local start = sc.realtime() - alarm.wait_absolute(t_sleep) - local finish = sc.realtime() - assert(finish - start > 1.9 or finish - start < 2.1) - -- next let's do nexts - local msec_target = 333 - alarm.clock_desynced() - fiber.spawn(function () - sleep.sleep(t_sleep_before_realtime) - alarm.clock_synced() - end) - start = sc.realtime() - alarm.wait_next({msec=msec_target}) - finish = sc.realtime() - assert(finish - start > 2) -- the event shouldn't fire until clock_synced is called - assert(finish%1 - math.floor(finish) - msec_target < 50) - print("complete!") +------------------------------------------------------------------------ +-- Test 3: multiple alarms reschedule in parallel +------------------------------------------------------------------------ + +local function test_multiple_alarms_reschedule() + print('[test_multiple_alarms_reschedule] start') + + wall_time = 0 + + local offset1 = 10 + local offset2 = 20 + + local calls1, calls2 = {}, {} + + local function next_time1(last, now) + calls1[#calls1 + 1] = { last = last, now = now } + if last ~= nil then + return nil + end + return now + offset1 + end + + local function next_time2(last, now) + calls2[#calls2 + 1] = { last = last, now = now } + if last ~= nil then + return nil + end + return now + offset2 + end + + local al1 = alarm_mod.new { next_time = next_time1 } + local al2 = alarm_mod.new { next_time = next_time2 } + + local r1, r2 = {}, {} + + fibers.spawn(function () + local ok, alarm, t = fibers.perform(al1:wait_op()) + r1.ok, r1.alarm, r1.time = ok, alarm, t + end) + + fibers.spawn(function () + local ok, alarm, t = fibers.perform(al2:wait_op()) + r2.ok, r2.alarm, r2.time = ok, alarm, t + end) + + -- Let both alarms set up their initial waits. + sleep(0.01) + + -- Change civil time and offsets and notify once. + wall_time = 100 + offset1 = 1 + offset2 = 2 + + alarm_mod.time_changed() + + -- Enough monotonic time for both new sleeps (1 and 2 seconds). + sleep(3.0) + + assert(r1.ok == true, 'alarm1 did not fire') + assert(r2.ok == true, 'alarm2 did not fire') + + assert(r1.alarm == al1, 'alarm1: unexpected alarm instance') + assert(r2.alarm == al2, 'alarm2: unexpected alarm instance') + + assert(approx_equal(r1.time, 101), + ('alarm1: expected fired_epoch ~= 101 (got %f)'):format(r1.time)) + assert(approx_equal(r2.time, 102), + ('alarm2: expected fired_epoch ~= 102 (got %f)'):format(r2.time)) + + assert(#calls1 >= 2 and #calls2 >= 2, 'multiple alarms: next_time not called enough times') + + assert(approx_equal(calls1[1].now, 0), ('alarm1: first now ~= 0 (got %f)'):format(calls1[1].now)) + assert(approx_equal(calls1[2].now, 100), ('alarm1: second now ~= 100 (got %f)'):format(calls1[2].now)) + assert(approx_equal(calls2[1].now, 0), ('alarm2: first now ~= 0 (got %f)'):format(calls2[1].now)) + assert(approx_equal(calls2[2].now, 100), ('alarm2: second now ~= 100 (got %f)'):format(calls2[2].now)) + + print('[test_multiple_alarms_reschedule] ok') end -local function validate_next_table_test() - io.write("Starting validate_next_table test ... ") - io.flush() - - local tests = { - { - input = {year=2027}, - expctd_err = "year should not be specified for a relative alarm"}, - { - input = {yday=200}, - expctd_err = nil}, - { - input = {yday=200, month=7}, - expctd_err = "neither month, weekday or day of month valid for day of year alarm"}, - { - input = {month=12}, - expctd_err = nil}, - { - input = {month=6, wday=3}, - expctd_err = "day of week not valid for yearly alarm"}, - { - input = {day=15}, - expctd_err = nil}, - { - input = {min=30, sec=45}, - expctd_err = nil}, - { - input = {}, - expctd_err = "a next alarm must specify at least one of yday, month, day, wday, hour, minute, sec or msec" - } - } - - for _, test in ipairs(tests) do - local _, result_error = alarm.validate_next_table(test.input) - assert( - result_error == test.expctd_err, - string.format("expected %s, got %s", tostring(test.expctd_err), tostring(result_error)) - ) - end - print("complete!") +------------------------------------------------------------------------ +-- Test 4: repeated time_changed() while waiting +------------------------------------------------------------------------ + +local function test_repeated_time_changes() + print('[test_repeated_time_changes] start') + + wall_time = 0 + + local offset = 10 + local calls = {} + + local function next_time(last, now) + calls[#calls + 1] = { last = last, now = now } + if last ~= nil then + -- one-shot + return nil + end + return now + offset + end + + local al = alarm_mod.new { next_time = next_time } + + local result = {} + + fibers.spawn(function () + local ok, alarm, fired_epoch = fibers.perform(al:wait_op()) + result.ok = ok + result.alarm = alarm + result.time = fired_epoch + end) + + -- Allow initial wait setup (now = 0, next_wall = 10). + sleep(0.01) + + -- First change: bump wall_time to 100, keep offset = 10. + wall_time = 100 + offset = 10 + alarm_mod.time_changed() + sleep(0.01) + + -- Second change: wall_time to 200, offset = 5. + wall_time = 200 + offset = 5 + alarm_mod.time_changed() + sleep(0.01) + + -- Third change: wall_time to 300, offset = 1. + wall_time = 300 + offset = 1 + alarm_mod.time_changed() + + -- Final sleep long enough for the last dt = 1. + sleep(2.0) + + assert(result.ok == true, 'repeated-change alarm did not fire') + assert(result.alarm == al, 'repeated-change alarm: unexpected alarm instance') + assert(approx_equal(result.time, 301), + ('repeated-change alarm: expected fired_epoch ~= 301 (got %f)'):format(result.time)) + + assert(#calls >= 3, ('repeated-change alarm: expected multiple next_time calls, got %d'):format(#calls)) + + -- Check that we saw non-decreasing now values and that the last is ~300. + local last_now = calls[1].now + for i = 2, #calls do + assert(calls[i].now >= last_now, + ('repeated-change alarm: now not non-decreasing at call %d (prev=%f, now=%f)') + :format(i, last_now, calls[i].now)) + last_now = calls[i].now + end + + assert(approx_equal(last_now, 300), + ('repeated-change alarm: last now ~= 300 (got %f)'):format(last_now)) + + print('[test_repeated_time_changes] ok') end -local function test_next_calc() - io.write("Starting Next Calculation test ... ") - io.flush() - - local tests = { - { - description = "Testing Day Increment", - epoch = os.time{year=2027, month=5, day=24, hour=0, min=0, sec=0}, - test_table = {day=25, hour=0, min=0, sec=0}, - expected_time = os.time{year=2027, month=5, day=25, hour=0, min=0, sec=0} - }, - { - description = "Testing Month Wraparound", - epoch = os.time{year=3023, month=12, day=31, hour=23, min=59, sec=59}, - test_table = {month=1, day=1, hour=0, min=0, sec=0}, - expected_time = os.time{year=3024, month=1, day=1, hour=0, min=0, sec=0} - }, - { - description = "Testing Leap Year Day", - epoch = os.time{year=2024, month=1, day=1, hour=0, min=0, sec=0}, - test_table = {month=2, day=29, hour=0, min=0, sec=0}, - expected_time = os.time{year=2024, month=2, day=29, hour=0, min=0, sec=0} - }, - { - description = "Testing Weekday Adjustment", - epoch = os.time{year=2024, month=5, day=22, hour=12, min=43}, -- Wednesday - test_table = {wday=6, min=12}, -- Targeting Friday - expected_time = os.time{year=2024, month=5, day=24, hour=0, min=12, sec=0} -- The next Friday - } - } - - for _, test in ipairs(tests) do - local calculated_time, _ = alarm.calculate_next(test.test_table, test.epoch) - assert(calculated_time == test.expected_time, test.description .. ": Failed!") - end - - print("complete!") +------------------------------------------------------------------------ +-- Test 5: exhaustion behaviour still correct after reschedule +------------------------------------------------------------------------ + +local function test_exhaustion_after_reschedule() + print('[test_exhaustion_after_reschedule] start') + + wall_time = 0 + + local offset = 10 + local calls = {} + + local function next_time(last, now) + calls[#calls + 1] = { last = last, now = now } + if last ~= nil then + -- No further recurrences: one-shot alarm + return nil + end + return now + offset + end + + local al = alarm_mod.new { next_time = next_time } + + local r1, r2 = {}, {} + + fibers.spawn(function () + -- First wait: should fire once. + local ok1, alarm1, t1 = fibers.perform(al:wait_op()) + r1 = { ok = ok1, alarm = alarm1, time = t1 } + + -- Second wait: should give exhaustion notification. + local ok2, why2, alarm2, last2 = fibers.perform(al:wait_op()) + r2 = { ok = ok2, why = why2, alarm = alarm2, last = last2 } + end) + + -- Allow initial scheduling at now = 0, next_wall = 10. + sleep(0.01) + + -- Change civil time and offset before the first firing. + wall_time = 100 + offset = 1 + alarm_mod.time_changed() + + -- Enough time for the recomputed dt = 1 sleep. + sleep(2.0) + + -- Check first firing. + assert(r1.ok == true, 'exhaustion test: first firing did not occur') + assert(r1.alarm == al, 'exhaustion test: first firing alarm mismatch') + assert(approx_equal(r1.time, 101), + ('exhaustion test: expected first fired_epoch ~= 101 (got %f)'):format(r1.time)) + + -- Check exhaustion notification. + assert(r2.ok == false, 'exhaustion test: second wait should report exhaustion') + assert(r2.why == 'no_more_recurrences', + ("exhaustion test: expected reason 'no_more_recurrences', got %s"):format(tostring(r2.why))) + assert(r2.alarm == al, 'exhaustion test: second wait alarm mismatch') + assert(approx_equal(r2.last, r1.time), + ('exhaustion test: expected last == first fired time (got %f vs %f)') + :format(r2.last or -1, r1.time or -1)) + + -- Ensure next_time saw at least two calls (initial schedule and exhaustion computation). + assert(#calls >= 2, ('exhaustion test: expected at least 2 next_time calls, got %d'):format(#calls)) + + print('[test_exhaustion_after_reschedule] ok') end -fiber.spawn(function () - abs_test(2) - next_error() - next_msec_test(666) - next_sec_test(2) - next_min_test(2) - next_hour_test(2) - next_day_test(2) - next_wday_test(2) - next_month_test(2) - next_yday_test(2) - buffer_test() - validate_next_table_test() - test_next_calc() - fiber.stop() -end) +------------------------------------------------------------------------ +-- Run tests under the fibers scheduler +------------------------------------------------------------------------ -fiber.main() +fibers.run(function () + test_basic_alarm() + test_preemptive_reschedule() + test_multiple_alarms_reschedule() + test_repeated_time_changes() + test_exhaustion_after_reschedule() +end) diff --git a/tests/test_channel.lua b/tests/test_channel.lua index 0c1bbc9..b98574e 100644 --- a/tests/test_channel.lua +++ b/tests/test_channel.lua @@ -1,94 +1,134 @@ print('testing: fibers.channel') -package.path = "../?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path -local fiber = require 'fibers.fiber' +local fibers = require 'fibers' local channel = require 'fibers.channel' +local op = require 'fibers.op' +local sleep = require 'fibers.sleep' local function test_unbuffered() - local chan = channel.new() - fiber.spawn(function() chan:put(42) end) - assert(chan:get() == 42, "basic transfer") - - local received, signal = true, channel.new() - fiber.spawn(function() - received = chan:get() - signal:put(true) - end) - fiber.spawn(function() - chan:put(nil) - signal:put(true) - end) - signal:get() - signal:get() - assert(received == nil, "blocking transfer") - print("Unbuffered passed") + local chan = channel.new() + fibers.spawn(function () chan:put(42) end) + assert(chan:get() == 42, 'basic transfer') + + local received, signal = true, channel.new() + fibers.spawn(function () + received = chan:get() + signal:put(true) + end) + fibers.spawn(function () + chan:put(nil) + signal:put(true) + end) + signal:get() + signal:get() + assert(received == nil, 'blocking transfer') + print('Unbuffered passed') end local function test_buffered() - local chan, signal = channel.new(2), channel.new() - - fiber.spawn(function() - for i = 1, 4 do - chan:put(i) - end - signal:put(true) - end) - fiber.spawn(function() - for i = 1, 2 do - assert(chan:get() == i) - end - end) - - signal:get() - fiber.spawn(function() - chan:put(5) - signal:put(true) - end) - assert(chan:get() == 3) - signal:get() - assert(chan:get() == 4) - assert(chan:get() == 5) - print("Bounded buffered passed") + local chan, signal = channel.new(2), channel.new() + + fibers.spawn(function () + for i = 1, 4 do + chan:put(i) + end + signal:put(true) + end) + fibers.spawn(function () + for i = 1, 2 do + assert(chan:get() == i) + end + end) + + signal:get() + fibers.spawn(function () + chan:put(5) + signal:put(true) + end) + assert(chan:get() == 3) + signal:get() + assert(chan:get() == 4) + assert(chan:get() == 5) + print('Bounded buffered passed') end local function test_unbounded() - local chan = channel.new(math.huge) - for i = 1, 1000 do chan:put(i) end - for i = 1, 1000 do assert(chan:get() == i) end - - local blocked = true - fiber.spawn(function() - chan:get() - blocked = false - end) - assert(blocked, "get should block") - print("Unbounded passed") + local chan = channel.new(math.huge) + for i = 1, 1000 do chan:put(i) end + for i = 1, 1000 do assert(chan:get() == i) end + + local blocked = true + fibers.spawn(function () + chan:get() + blocked = false + end) + + -- At this point, there is no value available, so get() must have blocked. + assert(blocked, 'get should block') + + -- Now provide a value so the spawned fiber can complete. + chan:put(123) + + print('Unbounded passed') end local function test_concurrent() - local chan, signal, results = channel.new(1), channel.new(), {} - fiber.spawn(function() - for i = 1, 11 do chan:put(i) end - signal:put() - end) - fiber.spawn(function() - for _ = 1, 10 do table.insert(results, chan:get()) end - signal:put() - end) - signal:get() - signal:get() - for i = 1, 10 do assert(results[i] == i) end - print("Concurrent passed") + local chan, signal, results = channel.new(1), channel.new(), {} + fibers.spawn(function () + for i = 1, 11 do chan:put(i) end + signal:put() + end) + fibers.spawn(function () + for _ = 1, 10 do table.insert(results, chan:get()) end + signal:put() + end) + signal:get() + signal:get() + for i = 1, 10 do assert(results[i] == i) end + print('Concurrent passed') end -fiber.spawn(function() - test_unbuffered() - test_buffered() - test_unbounded() - test_concurrent() - print("All channel tests passed!") - fiber.stop() -end) -fiber.main() +local function test_losing_choice_send_waiter_is_unlinked() + local chan = channel.new() + + local tag = fibers.perform(op.choice( + chan:put_op('lost'):wrap(function () return 'sent' end), + sleep.sleep_op(0.01):wrap(function () return 'timeout' end) + )) + assert(tag == 'timeout', 'expected timeout arm to win') + assert(chan.putq:length() == 0, 'losing send waiter should be unlinked') + + fibers.spawn(function () chan:put('ok') end) + assert(chan:get() == 'ok', 'channel should still work after losing send cleanup') + print('Losing choice send waiter cleanup passed') +end + +local function test_losing_choice_recv_waiter_is_unlinked() + local chan = channel.new() + + local tag = fibers.perform(op.choice( + chan:get_op():wrap(function () return 'got' end), + sleep.sleep_op(0.01):wrap(function () return 'timeout' end) + )) + assert(tag == 'timeout', 'expected timeout arm to win') + assert(chan.getq:length() == 0, 'losing receive waiter should be unlinked') + + fibers.spawn(function () chan:put('ok') end) + assert(chan:get() == 'ok', 'channel should still work after losing receive cleanup') + print('Losing choice receive waiter cleanup passed') +end + +local function main() + test_unbuffered() + test_buffered() + test_unbounded() + test_concurrent() + test_losing_choice_send_waiter_is_unlinked() + test_losing_choice_recv_waiter_is_unlinked() + print('All channel tests passed!') +end + +fibers.run(main) diff --git a/tests/test_cond.lua b/tests/test_cond.lua index af27293..99305e2 100644 --- a/tests/test_cond.lua +++ b/tests/test_cond.lua @@ -2,45 +2,58 @@ print('testing: fibers.cond') -- look one level up -package.path = "../?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path local cond = require 'fibers.cond' -local fiber = require 'fibers.fiber' +local runtime = require 'fibers.runtime' local sleep = require 'fibers.sleep' -local sc = require 'fibers.utils.syscall' - -local equal = require 'fibers.utils.helper'.equal +local time = require 'fibers.utils.time' + +local function equal(x, y) + if type(x) ~= type(y) then return false end + if type(x) == 'table' then + for k, v in pairs(x) do + if not equal(v, y[k]) then return false end + end + for k, _ in pairs(y) do + if x[k] == nil then return false end + end + return true + else + return x == y + end +end local c, log = cond.new(), {} local function record(x) table.insert(log, x) end -fiber.spawn(function() - record('a'); c:wait(); record('b') +runtime.spawn_raw(function () + record('a'); c:wait(); record('b') end) -fiber.spawn(function() - record('c'); c:signal(); record('d') +runtime.spawn_raw(function () + record('c'); c:signal(); record('d') end) assert(equal(log, {})) -fiber.current_scheduler:run() +runtime.current_scheduler:run() assert(equal(log, { 'a', 'c', 'd' })) -fiber.current_scheduler:run() +runtime.current_scheduler:run() assert(equal(log, { 'a', 'c', 'd', 'b' })) -fiber.spawn(function() - local fiber_count = 1e3 - for _ = 1, fiber_count do - fiber.spawn(function() c:wait(); end) - end +runtime.spawn_raw(function () + local fiber_count = 1e3 + for _ = 1, fiber_count do + runtime.spawn_raw(function () c:wait(); end) + end - sleep.sleep(1) + sleep.sleep(1) - local start_time = sc.monotime() - c:signal() - local end_time = sc.monotime() + local start_time = time.monotonic() + c:signal() + local end_time = time.monotonic() - print("Time taken to signal fiber: ", (end_time - start_time) / fiber_count) - fiber.stop() + print('Time taken to signal fiber: ', (end_time - start_time) / fiber_count) + runtime.stop() end) -fiber.main() +runtime.main() print('test: ok') diff --git a/tests/test_context.lua b/tests/test_context.lua deleted file mode 100644 index 11ac773..0000000 --- a/tests/test_context.lua +++ /dev/null @@ -1,111 +0,0 @@ ---- Tests the Context implementation. -print('testing: fibers.context') - --- look one level up -package.path = "../?.lua;" .. package.path - -local context = require 'fibers.context' -local fiber = require 'fibers.fiber' -local sleep = require 'fibers.sleep' - --- Test Background Context -local function test_background() - local ctx = context.background() - assert(ctx:value("key") == nil, "Background context should not have any values") - print("test_background passed") -end - --- Test With Cancel Context -local function test_with_cancel() - local parent = context.background() - local ctx, cancel = context.with_cancel(parent) - local child_ctx, _ = context.with_cancel(ctx) - - local is_cancelled = false - fiber.spawn(function() - child_ctx:done_op():perform() - is_cancelled = true - end) - - cancel() - fiber.yield() -- Give time for cancellation to propagate - - assert(is_cancelled, "Child context should be cancelled when parent is cancelled") - print("test_with_cancel passed") -end - --- Test With Value Context -local function test_with_value() - local parent = context.background() - local ctx_1 = context.with_value(parent, "key", "value") - local ctx_2 = context.with_value(ctx_1, "another_key", "value") - local ctx_3 = context.with_value(ctx_2, "key", "another_value") - - assert(ctx_1:value("key") == "value", "Context should have the correct value") - assert(ctx_3:value("another_key") == "value", "Child context should inherit parent value") - assert(ctx_3:value("key") == "another_value", "Child context should have its own value") - print("test_with_value passed") -end - --- Test With Timeout Context -local function test_with_timeout() - local parent = context.background() - local ctx, _ = context.with_timeout(parent, 0.01) - - local is_cancelled = false - fiber.spawn(function() - ctx:done_op():perform() - is_cancelled = true - end) - - sleep.sleep(0.02) -- Give time for timeout to trigger - - assert(is_cancelled, "Context should be cancelled after timeout") - print("test_with_timeout passed") -end - --- Test Custom Cause -local function test_custom_cause() - local parent = context.background() - local ctx, cancel = context.with_cancel(parent) - local ctx_2, _ = context.with_cancel(ctx) - local custom_cause = "Custom Cancel Reason" - - cancel(custom_cause) - fiber.yield() -- Give time for cancellation to propagate - - assert(ctx:err() == custom_cause, "Context should have the custom cancel cause") - assert(ctx_2:err() == custom_cause, "Child context should have the custom cancel cause") - print("test_custom_cause passed") -end - -local function test_cancel_on_with_value() - local parent = context.background() - local ctx, cancel = context.with_cancel(parent) - local ctx_2, _ = context.with_value(ctx, "key", "value") - local ctx_3, _ = context.with_value(ctx_2, "key2", "value2") - - cancel('cancelled') - fiber.yield() -- Give time for cancellation to propagate - - assert(ctx:err() == 'cancelled', "Context should have cancel cause") - assert(ctx_2:err() == 'cancelled', "Child context should have cancel cause") - assert(ctx_2:value('key') == 'value', "Child context should have the value") - assert(ctx_3:err() == 'cancelled', "Child context should have cancel cause") - assert(ctx_3:value('key') == 'value', "Child context should have the value") - assert(ctx_3:value('key2') == 'value2', "Child context should have the value") -end --- Run all tests -fiber.spawn(function() - test_background() - test_with_cancel() - test_with_value() - test_with_timeout() - test_custom_cause() - test_cancel_on_with_value() - - print("All tests passed") - fiber.stop() -end) - -fiber.main() diff --git a/tests/test_epoll.lua b/tests/test_epoll.lua index fae19af..2e0195a 100644 --- a/tests/test_epoll.lua +++ b/tests/test_epoll.lua @@ -2,13 +2,26 @@ print('testing: fibers.epoll') -- look one level up -package.path = "../?.lua;" .. package.path +package.path = "../src/?.lua;" .. package.path -local epoll = require 'fibers.epoll' +local epoll = require 'fibers.io.epoll' local sc = require 'fibers.utils.syscall' local bit = rawget(_G, "bit") or require 'bit32' -local equal = require 'fibers.utils.helper'.equal +local function equal(x, y) + if type(x) ~= type(y) then return false end + if type(x) == 'table' then + for k, v in pairs(x) do + if not equal(v, y[k]) then return false end + end + for k, _ in pairs(y) do + if x[k] == nil then return false end + end + return true + else + return x == y + end +end local myepoll = assert(epoll.new()) local function poll(timeout) diff --git a/tests/test_exec.lua b/tests/test_exec.lua deleted file mode 100644 index 47d703c..0000000 --- a/tests/test_exec.lua +++ /dev/null @@ -1,257 +0,0 @@ --- test_fibers_exec.lua -package.path = "../../?.lua;../?.lua;" .. package.path - -local fiber = require 'fibers.fiber' -local sleep = require 'fibers.sleep' -local channel = require "fibers.channel" -local exec = require 'fibers.exec' -local pollio = require 'fibers.pollio' -local waitgroup = require "fibers.waitgroup" -local context = require "fibers.context" -local sc = require 'fibers.utils.syscall' - -pollio.install_poll_io_handler() - -local function count_dir_items(path) - local count - local p = io.popen('ls -1 "' .. path .. '" | wc -l') - if p then - local output = p:read("*all") - count = tonumber(output) - p:close() - end - return count -end - -local function count_zombies() - local count - local p = io.popen('ps | grep -c " Z "') - if p then - local output = p:read("*all") - count = tonumber(output) - p:close() - end - return count -end - --- Test 1: Test basic command execution -local function test_basic_execution() - local output, err = exec.command('echo', 'Hello, World!'):combined_output() - if err then error(err) end - -- remember that echo will append a new line character! - assert(output == "Hello, World!\n", "Expected 'Hello, World!' but got: " .. output) - assert(err == nil, "Expected no error but got: ", err) -end - --- Test 2: Test command error handling -local function test_command_error() - local output, err = exec.command('nonexistent_command'):combined_output() - assert(output == nil, "Expected no output but got: ", output) - assert(err ~= nil, "Expected an error!") -end - --- Test 3: Test command with arguments -local function test_command_with_args() - local output, err = exec.command('echo', 'arg1', 'arg2'):combined_output() - if err then error(err) end - assert(output == "arg1 arg2\n", "Expected 'arg1 arg2' but got: " .. output) - assert(err == nil, "Expected no error but got: ", err) -end - --- Test 4: Test command IO redirection -local function test_io_redirection() - local msgs = {"Hello", "World"} - local cmd = exec.command('cat') - local stdin_pipe = assert(cmd:stdin_pipe()) - local stdout_pipe = assert(cmd:stdout_pipe()) - local signal_chan = channel.new() - local err = cmd:start() - assert(cmd:start(), "Expected error on starting command twice") - assert(err == nil, "Expected no error but got:", err) - fiber.spawn(function () - for _, v in ipairs(msgs) do - stdin_pipe:write(v) - signal_chan:get() - end - stdin_pipe:close() - end) - for _, v in ipairs(msgs) do - assert(stdout_pipe:read_some_chars() == v) - signal_chan:put(1) - end - assert(stdout_pipe:read_some_chars() == nil) - stdout_pipe:close() - err = cmd:wait() - assert(err == nil, "Expected no error but got:", err) - assert(cmd.process.state == 0) -end - --- Test 5: Test command kill -local function test_kill() - local cmd = exec.command('/bin/sh', '-c', 'sleep 5') - cmd:setpgid(true) -- ensures that children run in a separate process group - local starttime = sc.monotime() - local err = cmd:start() - assert(err == nil, "Expected no error but got:", err) - cmd:kill() - local exit_code = cmd:wait() - assert(exit_code == sc.SIGKILL) - assert(sc.monotime()-starttime < 4, sc.monotime()-starttime) -end - --- Test 6: Testing context -local function test_context() - local ctx, _ = context.with_timeout(context.background(), 0.00001) - local cmd = exec.command_context(ctx, 'sleep', '5') - local starttime = sc.monotime() - local err = cmd:start() - assert(err == nil, "Expected no error but got:", err) - assert(cmd:wait() == sc.SIGKILL) - assert(sc.monotime()-starttime < 4, sc.monotime()-starttime) -end - --- Test 7: Cancel context during output -local function test_cancel_during_output() - local ctx, cancel = context.with_cancel(context.background()) - local cmd = exec.command_context(ctx, '/bin/sh', '-c', 'for i in $(seq 1 10000); do echo y; sleep 0.001; done') - :setpgid(true) - - fiber.spawn(function() - -- Let it run for a short moment, then cancel - sleep.sleep(0.00001) - cancel() - end) - - local err = cmd:run() - assert(err == sc.SIGKILL, "Expected error due to cancellation") -end - --- Test 8: Context already cancelled before start -local function test_cancel_before_start() - local ctx, cancel = context.with_cancel(context.background()) - cancel() - - local cmd = exec.command_context(ctx, '/bin/true') - local err = cmd:start() - assert(err ~= nil, "Expected start() to fail due to cancelled context") -end - -local function test_cleanup_on_crash(id) - local function is_process_running(pid) - local f = assert(io.popen('ps -p ' .. pid .. ' | wc -l')) - local output = f:read("*all") - f:close() - return tonumber(output) > 1 -- More than 1 means the process is running - end - local temp_script_dir = "/tmp/crash_test" .. id .. ".lua" - - local script = [[ - package.path = "../../?.lua;../?.lua;" .. package.path - local fiber = require 'fibers.fiber' - local exec = require 'fibers.exec' - local sleep = require 'fibers.sleep' - local pollio = require 'fibers.pollio' - local sc = require 'fibers.utils.syscall' - pollio.install_poll_io_handler() - - fiber.spawn(function () - local cmd = exec.command('sleep', '1') - cmd:setprdeathsig(sc.SIGKILL) - local err = cmd:start() - if err then - error(err) - end - io.stdout:write(tostring(cmd.process.pid) .. "\n") - io.stdout:flush() - sleep.sleep(0.01) - print(obj.obj) - end) - - fiber.main() - ]] - - -- Write the script to a temporary file - local file = assert(io.open(temp_script_dir, "w")) - file:write(script) - file:close() - - -- Execute the script using luajit - local cmd = exec.command('luajit', temp_script_dir) - local stdout = assert(cmd:stdout_pipe()) - assert(cmd:start() == nil, "Expected no error on start") - - -- Wait for line output from script - local lines - for _ = 1, 10 do - lines = stdout:read_line() - if lines then - break - end - sleep.sleep(0.01) -- Wait a bit for output - end - local exit_code = cmd:wait() - stdout:close() - assert(exit_code ~= 0, "Expected non-zero exit code due to crash") - - os.remove(temp_script_dir) - - -- Get the process ID of the sleep command - local pid = lines and tonumber(lines:match("^(%d+)")) - assert(pid, "Expected a valid process ID from output: " .. tostring(lines or 'nil')) - local is_running = is_process_running(pid) - - -- If we have a valid pid, make sure to cleanup the sleep command explicitly - if pid and pid > 0 and is_running then - -- Send SIGKILL to the process - local kill_cmd = assert(io.popen('kill -9 ' .. tostring(pid))) - kill_cmd:close() - - -- Give a small amount of time for the kill to take effect - sleep.sleep(0.1) - - local running = is_process_running(pid) - - assert(not running, - "Process " .. pid .. " still exists after kill") - end - - assert(not is_running, "Child sleep command is still running") -end --- Main test function -local function main() - local pid = sc.getpid() - local base_open_fds = count_dir_items("/proc/"..pid.."/fd") - local base_zombies = count_zombies() - local reps = 100 - print("testing: fibers.exec") - local tests = { - test_basic_execution = test_basic_execution, - test_command_error = test_command_error, - test_command_with_args = test_command_with_args, - test_io_redirection = test_io_redirection, - test_kill = test_kill, - test_context = test_context, - test_cancel_during_output = test_cancel_during_output, - test_cancel_before_start = test_cancel_before_start, - test_cleanup_on_crash = test_cleanup_on_crash, - } - for k, v in pairs(tests) do - local wg = waitgroup.new() - for i = 1, reps do - wg:add(1) - fiber.spawn(function () - v(i) - wg:done() - end) - end - wg:wait() - assert(base_open_fds == count_dir_items("/proc/"..pid.."/fd"), k.." left open fds!") - assert(base_zombies == count_zombies(), k.." created zombies!") - print(k..": passed!") - end - print("test: ok") - fiber.stop() -end - -fiber.spawn(main) -fiber.main() diff --git a/tests/test_fiber.lua b/tests/test_fiber.lua deleted file mode 100644 index 39513b9..0000000 --- a/tests/test_fiber.lua +++ /dev/null @@ -1,53 +0,0 @@ ---- Tests the Fiber implementation. -print('testing: fibers.fiber') - --- look one level up -package.path = "../?.lua;" .. package.path - -local fiber = require 'fibers.fiber' -local sc = require 'fibers.utils.syscall' -local equal = require 'fibers.utils.helper'.equal - -local log = {} -local function record(x) table.insert(log, x) end - -fiber.spawn(function() - record('a'); fiber.yield(); record('b'); fiber.yield(); record('c') -end) - -assert(equal(log, {})) -fiber.current_scheduler:run() -assert(equal(log, {'a'})) -fiber.current_scheduler:run() -assert(equal(log, {'a', 'b'})) -fiber.current_scheduler:run() -assert(equal(log, {'a', 'b', 'c'})) -fiber.current_scheduler:run() -assert(equal(log, {'a', 'b', 'c'})) - --- Test performance -local start_time = sc.monotime() -local fiber_count = 1e4 -local count = 0 -local function inc() - count = count + 1 -end -for _=1, fiber_count do - fiber.spawn(function() - inc(); fiber.yield(); inc(); fiber.yield(); inc() - end) -end - -local end_time = sc.monotime() -print("Fiber creation time: "..(end_time - start_time)/fiber_count) - -start_time = sc.monotime() -for _=1,3*fiber_count do -- run fibers, each fiber yields 3 times - fiber.current_scheduler:run() -end -end_time = sc.monotime() -print("Fiber operation time: "..(end_time - start_time)/(2*3*fiber_count)) - -assert(count == 3*fiber_count) - -print('test: ok') diff --git a/tests/test_io-backend_families.lua b/tests/test_io-backend_families.lua new file mode 100644 index 0000000..bd5fcf7 --- /dev/null +++ b/tests/test_io-backend_families.lua @@ -0,0 +1,57 @@ +-- tests/test_io-backend_families.lua +-- +-- Cycles the relevant fibers.io tests across the supported backend families. +-- Each family is run in a fresh Lua process. This is necessary because the +-- selector modules are cached in package.loaded after first use. + +print('testing: fibers.io backend families') + +local function split_csv(s) + local out = {} + for item in tostring(s):gmatch('[^,%s]+') do + table.insert(out, item) + end + return out +end + +local function shell_quote(s) + s = tostring(s) + return "'" .. s:gsub("'", "'\\''") .. "'" +end + +local function command_ok(a, b, c) + -- Lua 5.1/LuaJIT: os.execute returns a numeric status, usually 0 on success. + -- Lua 5.2+: returns true/nil plus exit metadata. + if type(a) == 'boolean' then + return a, c or 0 + end + if type(a) == 'number' then + return a == 0, a + end + return false, tostring(a) +end + +if os.getenv('LUA_FIBERS_SKIP_BACKEND_FAMILIES') == '1' then + print('skip - fibers.io backend families: LUA_FIBERS_SKIP_BACKEND_FAMILIES=1') + return +end + +local families = split_csv(os.getenv('LUA_FIBERS_BACKEND_FAMILIES') or 'ffi,posix,nixio') +local lua = os.getenv('LUA') or (arg and arg[-1]) or 'lua' + +assert(#families > 0, 'no backend families selected') + +for _, family in ipairs(families) do + local cmd = table.concat({ + shell_quote(lua), + shell_quote('test_io_backend_family_child.lua'), + '--family', + shell_quote(family), + }, ' ') + print(('running backend family subprocess: %s'):format(family)) + local ok, code = command_ok(os.execute(cmd)) + assert(ok, ('backend family %s failed; command=%s status=%s'):format( + family, cmd, tostring(code))) +end + +print('fibers.io backend family tests passed') diff --git a/tests/test_io-exec.lua b/tests/test_io-exec.lua new file mode 100644 index 0000000..40be8f8 --- /dev/null +++ b/tests/test_io-exec.lua @@ -0,0 +1,536 @@ +-- tests/test_io-exec.lua +-- +-- Ad hoc tests and usage examples for fibers.io.exec. +-- +-- Run as: luajit test_io-exec.lua + +print('testing: fibers.io.exec') + +-- Look one level up for src modules. +package.path = '../src/?.lua;' .. package.path + +local fibers = require 'fibers' +local exec = require 'fibers.io.exec' +local op = require 'fibers.op' +local sleep = require 'fibers.sleep' +local scope = require 'fibers.scope' +local poller = require 'fibers.io.poller' + +---------------------------------------------------------------------- +-- Helpers for shell-based FD / zombie counting +---------------------------------------------------------------------- + +local function warm_up_exec_backend() + -- Run a trivial command once to force backend initialisation + fibers.run(function () + local proc = exec.command { + 'sh', '-c', 'true', + stdin = 'null', + stdout = 'null', + stderr = 'null', + } + local status, code, _, err = fibers.perform(proc:run_op()) + assert(err == nil, 'warm-up wait error: ' .. tostring(err)) + assert(status == 'exited', 'warm-up status: ' .. tostring(status)) + assert(code == 0, 'warm-up exit code: ' .. tostring(code)) + end) +end + +local function shell_capture(cmd) + local p, perr = io.popen(cmd, 'r') + assert(p, 'io.popen failed: ' .. tostring(perr)) + local out = p:read('*a') or '' + p:close() + return out +end + +-- Force poller initialisation so its FDs are part of the baseline. +poller.get() + +-- Force exec backend initialisation (self-pipe, reaper, etc.). +warm_up_exec_backend() + +-- Count open FDs of the parent (luajit) process using /proc and $PPID. +local function get_fd_count_for_parent() + local script = [=[ +ls "/proc/$PPID/fd" 2>/dev/null | wc -l +]=] + local ok, out = pcall(shell_capture, script) + if not ok then + return nil + end + local n = out:match('(%d+)') + return n and tonumber(n) or nil +end + +-- Count zombie children (state 'Z') of the parent (luajit) process. +-- Uses /proc/*/stat and awk; best-effort only. +local function get_zombie_count_for_parent() + local script = [=[ +parent="$PPID" +count=0 +for stat in /proc/[0-9]*/stat; do + [ -r "$stat" ] || continue + set -- $(awk '{print $4, $3}' "$stat" 2>/dev/null) + ppid="$1" + state="$2" + if [ "$ppid" = "$parent" ] && [ "$state" = "Z" ]; then + count=$((count+1)) + fi +done +printf '%s\n' "$count" +]=] + local ok, out = pcall(shell_capture, script) + if not ok then + return nil + end + local n = out:match('(%d+)') + return n and tonumber(n) or nil +end + +local baseline_fd_count = get_fd_count_for_parent() +local baseline_zombie_count = get_zombie_count_for_parent() + +print(('baseline: fds=%s zombies=%s') + :format(tostring(baseline_fd_count), tostring(baseline_zombie_count))) + +---------------------------------------------------------------------- +-- Tests +---------------------------------------------------------------------- + +-- 1. Simple spawn: check we can see a non-zero exit code and no signal. +local function simple_exit_code() + print('running: simple_exit_code') + + local proc = exec.command { + 'sh', '-c', 'exit 7', + stdin = 'null', + stdout = 'null', + stderr = 'null', + } + assert(proc, 'command creation failed') + + local status, code, sig, werr = fibers.perform(proc:run_op()) + assert(werr == nil, 'wait error: ' .. tostring(werr)) + assert(status == 'exited', "expected status 'exited', got " .. tostring(status)) + assert(sig == nil, 'expected no signal, got ' .. tostring(sig)) + assert(code == 7, 'expected exit code 7, got ' .. tostring(code)) +end + +-- 2. stdin/stdout pipes: cat echoes what we send. +local function stdin_stdout_pipe_round_trip() + print('running: stdin_stdout_pipe_round_trip') + + local proc = exec.command { + 'sh', '-c', 'cat', + stdin = 'pipe', + stdout = 'pipe', + stderr = 'pipe', + } + assert(proc, 'command creation failed') + + local stdin_stream, sin_err = proc:stdin_stream() + local stdout_stream, sout_err = proc:stdout_stream() + assert(stdin_stream and not sin_err, 'expected stdin stream, got error: ' .. tostring(sin_err)) + assert(stdout_stream and not sout_err, 'expected stdout stream, got error: ' .. tostring(sout_err)) + + local msg = 'line1\nline2\n' + local n, werr = stdin_stream:write(msg) + assert(werr == nil, 'write error: ' .. tostring(werr)) + assert(n == #msg, 'short write: ' .. tostring(n)) + stdin_stream:close() -- send EOF + + local out, rerr = stdout_stream:read_all() + assert(rerr == nil, 'read_all stdout error: ' .. tostring(rerr)) + assert(out == msg, ('unexpected echo: %q'):format(out)) + + local status, code, sig, werr2 = fibers.perform(proc:run_op()) + assert(werr2 == nil, 'wait error: ' .. tostring(werr2)) + assert(status == 'exited', 'expected exited status') + assert(sig == nil, 'expected no signal') + assert(code == 0, 'expected exit 0') +end + +-- 3. stderr as separate pipe vs stderr redirected to stdout. +local function stderr_pipe_vs_stderr_is_stdout() + print('running: stderr_pipe_vs_stderr_is_stdout') + + -- Separate stderr. + local proc1 = exec.command { + 'sh', '-c', 'echo out; echo err 1>&2', + stdin = 'null', + stdout = 'pipe', + stderr = 'pipe', + } + assert(proc1, 'spawn failed (separate stderr)') + + local out_stream1, oserr1 = proc1:stdout_stream() + local err_stream1, eserr1 = proc1:stderr_stream() + assert(out_stream1 and not oserr1, 'stdout stream error: ' .. tostring(oserr1)) + assert(err_stream1 and not eserr1, 'stderr stream error: ' .. tostring(eserr1)) + + local out1, oerr1 = out_stream1:read_all() + local errout1, eerr1 = err_stream1:read_all() + assert(oerr1 == nil, 'stdout read error: ' .. tostring(oerr1)) + assert(eerr1 == nil, 'stderr read error: ' .. tostring(eerr1)) + assert(out1 == 'out\n', ('unexpected stdout: %q'):format(out1)) + assert(errout1 == 'err\n', ('unexpected stderr: %q'):format(errout1)) + + fibers.perform(proc1:run_op()) + + -- stderr merged into stdout. + local proc2 = exec.command { + 'sh', '-c', 'echo out; echo err 1>&2', + stdin = 'null', + stdout = 'pipe', + stderr = 'stdout', + } + assert(proc2, 'spawn failed (stderr=stdout)') + + local out_stream2, oserr2 = proc2:stdout_stream() + assert(out_stream2 and not oserr2, 'stdout stream error: ' .. tostring(oserr2)) + + local err_stream2, _ = proc2:stderr_stream() + -- When stderr is redirected to stdout, stderr_stream should return the same stream. + assert(err_stream2 == out_stream2, + 'expected stderr_stream to return stdout stream when redirected') + + local merged, merr = out_stream2:read_all() + assert(merr == nil, 'merged stdout read error: ' .. tostring(merr)) + assert(merged:match('out'), ("merged output missing 'out': %q"):format(merged)) + assert(merged:match('err'), ("merged output missing 'err': %q"):format(merged)) + + fibers.perform(proc2:run_op()) +end + +-- 4. output_op: convenient capture of stdout plus status. +local function output_op_normal_completion() + print('running: output_op_normal_completion') + + local proc = exec.command { + 'sh', '-c', 'echo bracket', + stdin = 'null', + stdout = 'pipe', + stderr = 'pipe', + } + assert(proc, 'command creation failed') + + local out, status, code, sig, err = fibers.perform(proc:output_op()) + assert(err == nil, 'output_op error: ' .. tostring(err)) + assert(status == 'exited', "expected status 'exited'") + assert(code == 0, 'expected exit code 0') + assert(sig == nil, 'expected no signal') + + -- /bin/sh echo will append a newline. + assert(out == 'bracket\n', ('unexpected output from output_op: %q'):format(out)) +end + +-- 5. wait_op via run_op and a simple timeout pattern using boolean_choice. +local function wait_op_with_timeout_pattern() + print('running: wait_op_with_timeout_pattern') + + local proc = exec.command { + '/bin/sh', '-c', 'sleep 1', + stdin = 'null', + stdout = 'null', + stderr = 'null', + } + assert(proc, 'command creation failed') + + -- Race process completion vs timeout. + local ev = op.boolean_choice( + proc:run_op(), + sleep.sleep_op(2.0) + ) + + local is_exit, status, code, sig, werr = fibers.perform(ev) + assert(is_exit == true, 'process did not finish before timeout') + assert(werr == nil, 'wait_op error: ' .. tostring(werr)) + assert(status == 'exited', "expected status 'exited'") + assert(code == 0, 'expected exit code 0, got ' .. tostring(code)) + -- sig may be nil; we do not insist on value. + + -- Second wait should be immediate and return the same result. + local status2, code2, sig2, werr2 = fibers.perform(proc:run_op()) + assert(status2 == status, 'status changed between waits') + assert(code2 == code, 'code changed between waits') + assert(sig2 == sig, 'signal changed between waits') + assert(werr2 == nil, 'unexpected error on second wait: ' .. tostring(werr2)) +end + +-- Regression: completed commands must detach their scope finalisers. +-- +-- Historically exec.command registered a scope finaliser that captured the +-- Command object, but successful completion left that finaliser attached until +-- the surrounding scope exited. Long-lived service scopes therefore retained +-- every completed command. The test wraps one child scope's finally method so +-- it can count finalisers registered by exec.command without adding any test +-- hooks to the library. +local function completed_commands_detach_scope_finalizers() + print('running: completed_commands_detach_scope_finalizers') + + local N = 20 + local attached = 0 + local detached = 0 + local finalisers_ran = 0 + + local st, _, primary = scope.run(function (s) + local original_finally = s.finally + + s.finally = function (self, f) + attached = attached + 1 + local detach = original_finally(self, function (...) + finalisers_ran = finalisers_ran + 1 + return f(...) + end) + + local detached_once = false + return function () + if not detached_once then + detached_once = true + detached = detached + 1 + end + return detach() + end + end + + for i = 1, N do + local expected = i % 13 + local proc = exec.command { + 'sh', '-c', ('exit %d'):format(expected), + stdin = 'null', + stdout = 'null', + stderr = 'null', + } + assert(proc, ('command creation failed at iteration %d'):format(i)) + + local status, code, sig, err = fibers.perform(proc:run_op()) + assert(err == nil, + ('wait error at iteration %d: %s'):format(i, tostring(err))) + assert(status == 'exited', + ("status not 'exited' at iteration %d: %s"):format(i, tostring(status))) + assert(code == expected, + ('exit code mismatch at iteration %d: got %s, expected %d') + :format(i, tostring(code), expected)) + assert(sig == nil, + ('signal not nil at iteration %d: %s'):format(i, tostring(sig))) + + -- The terminal result must remain cached after cleanup. This also + -- guards against regressions where cleanup releases the backend and a + -- later wait reports a synthetic failure. + local status2, code2, sig2, err2 = fibers.perform(proc:run_op()) + assert(status2 == status, + ('status changed between waits at iteration %d'):format(i)) + assert(code2 == code, + ('code changed between waits at iteration %d'):format(i)) + assert(sig2 == sig, + ('signal changed between waits at iteration %d'):format(i)) + assert(err2 == nil, + ('unexpected error on second wait at iteration %d: %s') + :format(i, tostring(err2))) + end + end) + + assert(st == 'ok', 'scope.run failed: ' .. tostring(primary)) + assert(attached == N, + ('expected %d command finalisers to be attached, got %d') + :format(N, attached)) + assert(detached == N, + ('completed commands retained scope finalisers: attached=%d detached=%d') + :format(attached, detached)) + assert(finalisers_ran == 0, + ('completed command finalisers ran at scope exit instead of being detached: %d') + :format(finalisers_ran)) +end + +-- 6. shutdown: terminate a long-running process (TERM then KILL if needed). +local function shutdown_long_running_process() + print('running: shutdown_long_running_process') + + local proc = exec.command { + 'sh', '-c', 'while true; do sleep 1; done', + stdin = 'null', + stdout = 'null', + stderr = 'null', + } + assert(proc, 'command creation failed') + + local t0 = fibers.now() + local _, _, _, err = fibers.perform(proc:shutdown_op(0.2)) + local t1 = fibers.now() + + -- Ensure we did not stall. + assert((t1 - t0) < 5.0, ('shutdown took too long: %.3fs'):format(t1 - t0)) + + -- For this ad hoc test we only insist that the process reached + -- a terminal state. shutdown_op may surface backend details in `err`, + -- which we treat as diagnostic rather than fatal here. + local state, code_or_sig = proc:status() + assert( + state == 'exited' or state == 'signalled', + ('unexpected final state: %s (detail=%s, err=%s)') + :format(tostring(state), tostring(code_or_sig), tostring(err)) + ) +end + +-- 7. Spawning as an Op for CML-shaped code (basic usage). +local function spawn_op_basic_usage() + print('running: spawn_op_basic_usage') + + -- Build an Op that, when performed, creates a Command and returns it. + local spawn_ev = op.guard(function () + local proc = exec.command { + 'sh', '-c', "printf 'via_op'", + stdin = 'null', + stdout = 'pipe', + stderr = 'pipe', + } + return op.always(proc) + end) + + local proc = fibers.perform(spawn_ev) + assert(proc, 'spawn_ev did not return a process') + + local stdout_stream, serr = proc:stdout_stream() + assert(stdout_stream and not serr, 'stdout stream error: ' .. tostring(serr)) + + local out, rerr = stdout_stream:read_all() + assert(rerr == nil, 'read_all error: ' .. tostring(rerr)) + assert(out == 'via_op', ('unexpected stdout from spawn_ev: %q'):format(out)) + + local status, code, sig, werr = fibers.perform(proc:run_op()) + assert(werr == nil, 'wait error: ' .. tostring(werr)) + assert(status == 'exited', "expected status 'exited'") + assert(sig == nil, 'expected no signal') + assert(code == 0, 'expected exit 0 from spawned process') +end + +---------------------------------------------------------------------- +-- 8. Torture: many short-lived processes in sequence. +---------------------------------------------------------------------- + +local function many_short_lived_processes_stress() + print('running: many_short_lived_processes_stress') + + local N = 50 + + for i = 1, N do + local proc = exec.command { + 'sh', '-c', ("printf 'run-%d'; exit %d"):format(i, i % 256), + stdin = 'null', + stdout = 'pipe', + stderr = (i % 2 == 0) and 'null' or 'pipe', + } + assert(proc, ('command creation failed at iteration %d'):format(i)) + + local stdout_stream, serr = proc:stdout_stream() + assert(stdout_stream and not serr, + ('stdout stream error at iteration %d: %s'):format(i, tostring(serr))) + + local out, rerr = stdout_stream:read_all() + assert(rerr == nil, + ('read_all error at iteration %d: %s'):format(i, tostring(rerr))) + assert(out == ('run-%d'):format(i), + ('unexpected stdout at iteration %d: %q'):format(i, out)) + + local status, code, sig, werr = fibers.perform(proc:run_op()) + assert(werr == nil, + ('wait error at iteration %d: %s'):format(i, tostring(werr))) + assert(status == 'exited', + ("status not 'exited' at iteration %d: %s"):format(i, tostring(status))) + assert(sig == nil, + ('signal not nil at iteration %d: %s'):format(i, tostring(sig))) + assert(code == i % 256, + ('exit code mismatch at iteration %d: got %d, expected %d') + :format(i, code, i % 256)) + end +end + +---------------------------------------------------------------------- +-- 9. Torture: large stdout via output_op. +---------------------------------------------------------------------- + +local function large_output_output_op_stress() + print('running: large_output_output_op_stress') + + local lines = 5000 + local script = ([[ +i=1 +while [ $i -le %d ]; do + echo "line-$i" + i=$((i+1)) +done +]]):format(lines) + + local proc = exec.command { + 'sh', '-c', script, + stdin = 'null', + stdout = 'pipe', + stderr = 'pipe', + } + assert(proc, 'command creation failed') + + local out, status, code, sig, err = fibers.perform(proc:output_op()) + assert(err == nil, 'output_op error: ' .. tostring(err)) + assert(status == 'exited', "expected status 'exited'") + assert(code == 0, 'expected exit code 0') + assert(sig == nil, 'expected no signal') + + local count = 0 + for line in out:gmatch('([^\n]*)\n') do + if line ~= '' then + count = count + 1 + end + end + assert(count == lines, + ('unexpected number of lines from large_output_output_op_stress: got %d, expected %d') + :format(count, lines)) + + assert(out:find('line-1', 1, true), + "large output missing 'line-1'") + assert(out:find('line-' .. tostring(lines), 1, true), + 'large output missing last line marker') +end + +---------------------------------------------------------------------- +-- Main +---------------------------------------------------------------------- + +local function main() + simple_exit_code() + stdin_stdout_pipe_round_trip() + stderr_pipe_vs_stderr_is_stdout() + output_op_normal_completion() + wait_op_with_timeout_pattern() + completed_commands_detach_scope_finalizers() + shutdown_long_running_process() + spawn_op_basic_usage() + many_short_lived_processes_stress() + large_output_output_op_stress() +end + +fibers.run(main) + +local final_fd_count = get_fd_count_for_parent() +local final_zombie_count = get_zombie_count_for_parent() + +print(('final: fds=%s zombies=%s') + :format(tostring(final_fd_count), tostring(final_zombie_count))) + +if baseline_fd_count and final_fd_count then + assert(final_fd_count == baseline_fd_count, + ('FD leak detected: baseline=%d final=%d') + :format(baseline_fd_count, final_fd_count)) +else + print('FD leak check skipped (could not read /proc or count FDs)') +end + +if baseline_zombie_count and final_zombie_count then + assert(final_zombie_count <= baseline_zombie_count, + ('zombie leak detected: baseline=%d final=%d') + :format(baseline_zombie_count, final_zombie_count)) +else + print('Zombie leak check skipped (could not read /proc or count zombies)') +end + +print('test_io-exec.lua: all assertions passed') diff --git a/tests/test_io-exec_backend.lua b/tests/test_io-exec_backend.lua new file mode 100644 index 0000000..e50e067 --- /dev/null +++ b/tests/test_io-exec_backend.lua @@ -0,0 +1,151 @@ +-- tests/test_exec_backend.lua +-- +-- Backend-level tests for fibers.io.exec_backend. +-- Uses the real backend but does not involve fibers or the scheduler. + +print('testing: fibers.io.exec_backend') + +-- look one level up +package.path = '../src/?.lua;' .. package.path + +local proc_backend = require 'fibers.io.exec_backend' +local stdlib = require 'posix.stdlib' + +---------------------------------------------------------------------- +-- ExecStreamConfig helpers +---------------------------------------------------------------------- + +local function inherit_stream() + return { mode = 'inherit' } +end + +---------------------------------------------------------------------- +-- Helpers +---------------------------------------------------------------------- + +-- Block in a simple loop on the backend's poll operation until the +-- child is finished. +-- exec_backend.core wires ops.poll(state) -> done:boolean, code|nil, signal|nil, err|nil +local function wait_blocking(backend) + while true do + local done, code, sig, err = backend._ops.poll(backend._state) + assert(err == nil, 'poll error: ' .. tostring(err)) + if done then + return code, sig + end + -- Busy wait is acceptable here: tests are short-lived and single-process. + end +end + +---------------------------------------------------------------------- +-- Test 1: simple exit code +---------------------------------------------------------------------- + +local function test_simple_exit() + local spec = { + argv = { 'sh', '-c', 'exit 7' }, + cwd = nil, + env = nil, + flags = nil, + stdin = inherit_stream(), + stdout = inherit_stream(), + stderr = inherit_stream(), + } + + -- start() now returns a ProcHandle: { backend = ExecBackend, stdin, stdout, stderr } + local handle, err = proc_backend.start(spec) + assert(handle, 'start failed: ' .. tostring(err)) + + local backend = assert(handle.backend, 'no backend in handle') + + local code, sig = wait_blocking(backend) + assert(code == 7, + ('expected exit code 7, got %s'):format(tostring(code))) + assert(sig == nil, 'expected no terminating signal') +end + +---------------------------------------------------------------------- +-- Test 2: inherited environment +---------------------------------------------------------------------- + +local function test_env_inherit() + -- Ensure a parent variable is set. + assert(stdlib.setenv('PROC_BACKEND_TEST', 'parent_inherit')) + + -- Shell script checks inherited value and exits 0 only on match. + local script = [[ + if [ "$PROC_BACKEND_TEST" = "parent_inherit" ]; then + exit 0 + else + exit 42 + fi + ]] + + local spec = { + argv = { 'sh', '-c', script }, + cwd = nil, + env = nil, -- inherit environment + flags = nil, + stdin = inherit_stream(), + stdout = inherit_stream(), + stderr = inherit_stream(), + } + + local handle, err = proc_backend.start(spec) + assert(handle, 'start failed: ' .. tostring(err)) + local backend = assert(handle.backend, 'no backend in handle') + + local code, sig = wait_blocking(backend) + assert(sig == nil, 'expected no terminating signal') + assert(code == 0, + ('expected exit code 0 from env inherit test, got %s'):format(tostring(code))) +end + +---------------------------------------------------------------------- +-- Test 3: environment override (env table) +---------------------------------------------------------------------- + +local function test_env_override() + -- Parent value that should be overridden in the child. + assert(stdlib.setenv('PROC_BACKEND_TEST', 'parent_value')) + + local script = [[ + if [ "$PROC_BACKEND_TEST" = "child_value" ]; then + exit 0 + else + exit 43 + fi + ]] + + local spec = { + argv = { 'sh', '-c', script }, + cwd = nil, + env = { PROC_BACKEND_TEST = 'child_value' }, -- override + flags = nil, + stdin = inherit_stream(), + stdout = inherit_stream(), + stderr = inherit_stream(), + } + + local handle, err = proc_backend.start(spec) + assert(handle, 'start failed: ' .. tostring(err)) + local backend = assert(handle.backend, 'no backend in handle') + + local code, sig = wait_blocking(backend) + assert(sig == nil, 'expected no terminating signal') + assert(code == 0, + ('expected exit code 0 from env override test, got %s'):format(tostring(code))) +end + +---------------------------------------------------------------------- +-- Run tests +---------------------------------------------------------------------- + +local function main() + test_simple_exit() + test_env_inherit() + test_env_override() + io.stdout:write('proc_backend tests passed\n') +end + +main() diff --git a/tests/test_io-file.lua b/tests/test_io-file.lua new file mode 100644 index 0000000..2294773 --- /dev/null +++ b/tests/test_io-file.lua @@ -0,0 +1,596 @@ +-- tests/test_io_file.lua +-- +-- Integration tests for: +-- - fibers.io.file +-- - fibers.io.fd_backend +-- - fibers.io.stream +-- +-- Uses the real scheduler, ops and wait/waitset machinery. +print('testing: fibers.io.file') + +-- look one level up +package.path = '../src/?.lua;' .. package.path + +-- Assertion-based checks for fibers.io.file and stream I/O. +-- Exercises: +-- - tmpfile round-trip +-- - pipe round-trip + EOF behaviour +-- - use-after-close errors on streams +-- - cancellation of a scope while an IO op is blocked +-- - line-based reads via read/read_op +-- - read_all and read_exactly helpers +-- - numeric read formats, including n == 0 +-- - write_op/write with multiple arguments +-- - merge_lines_op across multiple streams +-- - setvbuf, filename, rename, nonblock/block, is_stream + +local fibers = require 'fibers' +local sleep = require 'fibers.sleep' +local file_mod = require 'fibers.io.file' +local scope_mod = require 'fibers.scope' +local stream_mod = require 'fibers.io.stream' + +local perform = fibers.perform + +math.randomseed(os.time()) + +---------------------------------------------------------------------- +-- 1. tmpfile round-trip +---------------------------------------------------------------------- + +local function test_tmpfile_roundtrip() + local f, err = file_mod.tmpfile() + assert(f, 'tmpfile() failed: ' .. tostring(err)) + + local msg = 'hello, tmpfile' + local n, werr = perform(f:write_op(msg)) + assert(n == #msg, 'write_string_op wrote ' .. tostring(n) .. ' bytes, expected ' .. #msg) + assert(werr == nil, 'write_string_op returned error: ' .. tostring(werr)) + + -- Rewind to the start. + local pos, serr = f:seek('set', 0) + assert(pos ~= nil, 'seek failed: ' .. tostring(serr)) + + local s, cnt, rerr = perform(f:core_read_op { + min = #msg, + max = #msg, + eof_ok = true, + }) + + assert(rerr == nil, 'read_string_op returned error: ' .. tostring(rerr)) + assert(cnt == #msg, 'read_string_op read ' .. tostring(cnt) .. ' bytes, expected ' .. #msg) + assert(s == msg, ('read_string_op returned %q, expected %q'):format(tostring(s), tostring(msg))) + + local ok, cerr = f:close() + assert(ok, 'tmpfile:close() failed: ' .. tostring(cerr)) +end + +---------------------------------------------------------------------- +-- 2. pipe round-trip + EOF semantics +---------------------------------------------------------------------- + +local function test_pipe_roundtrip_and_eof() + local r, w = file_mod.pipe() + assert(r and w, 'pipe() did not return read and write streams') + + local msg = 'pipe-test' + local n, werr = perform(w:write_op(msg)) + assert(n == #msg, 'pipe write_string_op wrote ' .. tostring(n) .. ' bytes, expected ' .. #msg) + assert(werr == nil, 'pipe write_string_op returned error: ' .. tostring(werr)) + + local s, cnt, rerr = perform(r:core_read_op { + min = #msg, + max = #msg, + eof_ok = true, + }) + + assert(rerr == nil, 'pipe read_string_op returned error: ' .. tostring(rerr)) + assert(cnt == #msg, 'pipe read_string_op read ' .. tostring(cnt) .. ' bytes, expected ' .. #msg) + assert(s == msg, ('pipe read_string_op returned %q, expected %q'):format(tostring(s), tostring(msg))) + + -- Close the write end and ensure the reader sees EOF. + local okw, errw = w:close() + assert(okw, 'pipe write stream close failed: ' .. tostring(errw)) + + local s2, cnt2, rerr2 = perform(r:core_read_op { + min = 1, + eof_ok = true, + }) + + -- At EOF, read_string_op should return (nil, 0, nil). + assert(s2 == nil, 'expected nil at EOF, got ' .. tostring(s2)) + assert(cnt2 == 0, 'expected byte count 0 at EOF, got ' .. tostring(cnt2)) + assert(rerr2 == nil, 'expected no error at EOF, got ' .. tostring(rerr2)) + + local okr, errr = r:close() + assert(okr, 'pipe read stream close failed: ' .. tostring(errr)) +end + +---------------------------------------------------------------------- +-- 3. use-after-close error paths +---------------------------------------------------------------------- + +local function test_closed_stream_errors() + -- Use a fresh pipe for write-after-close. + local r1, w1 = file_mod.pipe() + assert(r1 and w1, 'pipe() did not return read and write streams') + + local okw1, errw1 = w1:close() + assert(okw1, 'write stream close failed: ' .. tostring(errw1)) + + -- Writing via an already-closed Stream should raise "stream is not writable". + local ok, err = pcall(function () + return perform(w1:write_op('abc')) + end) + assert(not ok, 'expected write after close to fail with an assertion') + assert(tostring(err):match('stream is not writable'), + 'unexpected write-after-close error: ' .. tostring(err)) + + -- Use a fresh pipe for read-after-close. + local r2, w2 = file_mod.pipe() + assert(r2 and w2, 'pipe() did not return read and write streams') + + local okr2, errr2 = r2:close() + assert(okr2, 'read stream close failed: ' .. tostring(errr2)) + + -- Reading via an already-closed Stream should raise "stream is not readable". + ok, err = pcall(function () + return perform(r2:core_read_op { + min = 1, + eof_ok = false, + }) + end) + assert(not ok, 'expected read after close to fail with an assertion') + assert(tostring(err):match('stream is not readable'), + 'unexpected read-after-close error: ' .. tostring(err)) + + -- Clean up the remaining write end. + local okw2, errw2 = w2:close() + assert(okw2, 'second write stream close failed: ' .. tostring(errw2)) +end + +---------------------------------------------------------------------- +-- 4. Cancellation + IO: a child scope with a blocked read +---------------------------------------------------------------------- + +local function test_cancellation_cancels_blocked_read() + -- Use scope.run to create a nested child scope whose cancellation + -- does not affect the top-level scope used by fibers.run. + local status, 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') + + -- Spawn a canceller fiber under the child scope. + child:spawn(function (s) + -- Give the reader time to block. + perform(sleep.sleep_op(0.01)) + s:cancel('test cancellation') + end) + + -- Perform a read that will block (nothing is written). + local v1, v2, v3 = perform(r:core_read_op { + min = 1, + eof_ok = true, + }) + + assert(v1 == false, + 'expected first result false from cancelled op, got ' .. tostring(v1)) + assert(v2 == 'test cancellation', + "expected cancellation reason 'test cancellation', got " .. tostring(v2)) + assert(v3 == nil, + 'expected third result nil from cancel_op, got ' .. tostring(v3)) + + local okr, errr = r:close() + local okw, errw = w:close() + assert(okr, 'reader close failed after cancellation: ' .. tostring(errr)) + assert(okw, 'writer close failed after cancellation: ' .. tostring(errw)) + end) + + if status == 'failed' then + -- rep is available for debugging if you want it; primary is the error. + error(primary) + end + + assert(status == 'cancelled', + "expected child scope status 'cancelled', got " .. tostring(status)) + 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 + +---------------------------------------------------------------------- +-- 5. Line-based reads via Stream:read / read_op +---------------------------------------------------------------------- + +local function test_read_line_variants() + local f, err = file_mod.tmpfile() + assert(f, 'tmpfile() failed: ' .. tostring(err)) + + local content = 'line1\nline2\n' + local n, werr = f:write(content) + assert(werr == nil, 'write failed in test_read_line_variants: ' .. tostring(werr)) + assert(n == #content, 'write wrote ' .. tostring(n) .. ' bytes, expected ' .. #content) + + local pos, serr = f:seek('set', 0) + assert(pos == 0, 'seek to start failed: ' .. tostring(serr)) + + -- Default / "*l": line without terminator. + local l1, e1 = f:read() -- equivalent to "*l" + assert(e1 == nil, "read('*l') returned error: " .. tostring(e1)) + assert(l1 == 'line1', "read('*l') returned " .. tostring(l1) .. ", expected 'line1'") + + -- "*L": line with terminator. + local l2, e2 = f:read('*L') + assert(e2 == nil, "read('*L') returned error: " .. tostring(e2)) + assert(l2 == 'line2\n', "read('*L') returned " .. tostring(l2) .. ", expected 'line2\\n'") + + -- EOF: another line should give nil, nil. + local l3, e3 = f:read('*l') + assert(l3 == nil, "expected nil at EOF from read('*l'), got " .. tostring(l3)) + assert(e3 == nil, "expected nil error at EOF from read('*l'), got " .. tostring(e3)) + + local ok, cerr = f:close() + assert(ok, 'tmpfile close failed in test_read_line_variants: ' .. tostring(cerr)) +end + +---------------------------------------------------------------------- +-- 6. read_all, read_exactly and numeric read formats +---------------------------------------------------------------------- + +local function test_read_all_and_exactly() + local f, err = file_mod.tmpfile() + assert(f, 'tmpfile() failed: ' .. tostring(err)) + + local content = 'abc123' + local n, werr = f:write(content) + assert(werr == nil, 'write failed in test_read_all_and_exactly: ' .. tostring(werr)) + assert(n == #content, 'write wrote ' .. tostring(n) .. ' bytes, expected ' .. #content) + + local pos, serr = f:seek('set', 0) + assert(pos == 0, 'seek to start failed: ' .. tostring(serr)) + + -- read_all should return the whole string. + local all, eall = f:read_all() + assert(eall == nil, 'read_all returned error: ' .. tostring(eall)) + assert(all == content, 'read_all returned ' .. tostring(all) .. ', expected ' .. tostring(content)) + + -- Rewind and exercise read_exactly. + pos, serr = f:seek('set', 0) + assert(pos == 0, 'seek to start failed (2): ' .. tostring(serr)) + + local s1, e1 = f:read_exactly(3) + assert(e1 == nil, 'read_exactly(3) returned error: ' .. tostring(e1)) + assert(s1 == 'abc', 'read_exactly(3) returned ' .. tostring(s1) .. ", expected 'abc'") + + local s2, e2 = f:read_exactly(3) + assert(e2 == nil, 'read_exactly(3) second returned error: ' .. tostring(e2)) + assert(s2 == '123', 'read_exactly(3) second returned ' .. tostring(s2) .. ", expected '123'") + + -- EOF: attempting to read beyond end should give "short read". + local s3, e3 = f:read_exactly(1) + assert(s3 == nil, 'expected nil from read_exactly beyond EOF, got ' .. tostring(s3)) + assert(e3 == 'short read', "expected 'short read' error, got " .. tostring(e3)) + + local ok, cerr = f:close() + assert(ok, 'tmpfile close failed in test_read_all_and_exactly: ' .. tostring(cerr)) +end + +local function test_read_numeric_formats() + local f, err = file_mod.tmpfile() + assert(f, 'tmpfile() failed: ' .. tostring(err)) + + local content = 'xyz' + local n, werr = f:write(content) + assert(werr == nil, 'write failed in test_read_numeric_formats: ' .. tostring(werr)) + assert(n == #content, 'write wrote ' .. tostring(n) .. ' bytes, expected ' .. #content) + + local pos, serr = f:seek('set', 0) + assert(pos == 0, 'seek to start failed: ' .. tostring(serr)) + + -- n == 0: should return "" immediately. + local s0, e0 = f:read(0) + assert(e0 == nil, 'read(0) returned error: ' .. tostring(e0)) + assert(s0 == '', 'read(0) returned ' .. tostring(s0) .. ', expected empty string') + + -- n > 0: read up to n bytes; EOF semantics. + local s1, e1 = f:read(1) + assert(e1 == nil, 'read(1) returned error: ' .. tostring(e1)) + assert(s1 == 'x', 'read(1) returned ' .. tostring(s1) .. ", expected 'x'") + + local s2, e2 = f:read(10) -- remaining two bytes + assert(e2 == nil, 'read(10) returned error: ' .. tostring(e2)) + assert(s2 == 'yz', 'read(10) returned ' .. tostring(s2) .. ", expected 'yz'") + + local s3, e3 = f:read(1) -- EOF now + assert(s3 == nil, 'expected nil at EOF from read(1), got ' .. tostring(s3)) + assert(e3 == nil, 'expected nil error at EOF from read(1), got ' .. tostring(e3)) + + local ok, cerr = f:close() + assert(ok, 'tmpfile close failed in test_read_numeric_formats: ' .. tostring(cerr)) +end + +---------------------------------------------------------------------- +-- 7. write_op / write wrappers +---------------------------------------------------------------------- + +local function test_write_variants() + local f, err = file_mod.tmpfile() + assert(f, 'tmpfile() failed: ' .. tostring(err)) + + -- Zero-argument write should succeed and write nothing. + local n0, e0 = f:write() + assert(e0 == nil, 'zero-arg write returned error: ' .. tostring(e0)) + assert(n0 == 0, 'zero-arg write returned ' .. tostring(n0) .. ', expected 0') + + -- Multi-argument write should tostring each argument. + local _, werr = f:write('foo', 123, true) + assert(werr == nil, 'write returned error: ' .. tostring(werr)) + + local pos, serr = f:seek('set', 0) + assert(pos == 0, 'seek to start failed: ' .. tostring(serr)) + + local s, e = f:read_all() + assert(e == nil, 'read_all returned error after write: ' .. tostring(e)) + assert(s == 'foo123true', + 'read_all returned ' .. tostring(s) .. ", expected 'foo123true'") + + local ok, cerr = f:close() + assert(ok, 'tmpfile close failed in test_write_variants: ' .. tostring(cerr)) +end + +---------------------------------------------------------------------- +-- 8. merge_lines_op across multiple streams +---------------------------------------------------------------------- + +local function test_merge_lines_op() + local r1, w1 = file_mod.pipe() + local r2, w2 = file_mod.pipe() + assert(r1 and w1 and r2 and w2, 'pipe() did not return streams') + + local named = { + a = r1, + b = r2, + } + + -- Spawn writers under the top-level scope; ignore the scope argument. + fibers.spawn(function (w) + local _, err = w:write('line-a\n') + assert(err == nil, 'writer a write error: ' .. tostring(err)) + local ok, cerr = w:close() + assert(ok, 'writer a close error: ' .. tostring(cerr)) + end, w1) + + fibers.spawn(function (w) + local _, err = w:write('line-b\n') + assert(err == nil, 'writer b write error: ' .. tostring(err)) + local ok, cerr = w:close() + assert(ok, 'writer b close error: ' .. tostring(cerr)) + end, w2) + + local op = stream_mod.merge_lines_op(named, { terminator = '\n' }) + local name, line, err = perform(op) + + assert(err == nil, 'merge_lines_op returned error: ' .. tostring(err)) + assert(name == 'a' or name == 'b', + 'merge_lines_op returned unexpected name: ' .. tostring(name)) + + if name == 'a' then + assert(line == 'line-a', "expected 'line-a' from arm 'a', got " .. tostring(line)) + else + assert(line == 'line-b', "expected 'line-b' from arm 'b', got " .. tostring(line)) + end + + -- Close the remaining reader. + local ok1, cerr1 = r1:close() + local ok2, cerr2 = r2:close() + assert(ok1, 'reader r1 close failed: ' .. tostring(cerr1)) + assert(ok2, 'reader r2 close failed: ' .. tostring(cerr2)) +end + +---------------------------------------------------------------------- +-- 9. setvbuf, filename, rename, nonblock/block, is_stream +---------------------------------------------------------------------- + +local function test_stream_properties_and_rename() + local f, err = file_mod.tmpfile() + assert(f, 'tmpfile() failed: ' .. tostring(err)) + + -- is_stream + assert(stream_mod.is_stream(f), 'is_stream did not recognise a Stream') + assert(not stream_mod.is_stream(123), 'is_stream misclassified a number') + + -- filename should be non-nil for tmpfile + local fname = f:filename() + assert(fname ~= nil, 'tmpfile:filename() returned nil') + + -- setvbuf toggles line_buffering flag + assert(f.line_buffering == false, 'expected line_buffering default false') + f:setvbuf('line') + assert(f.line_buffering == true, "setvbuf('line') did not set line_buffering") + f:setvbuf('no') + assert(f.line_buffering == false, "setvbuf('no') did not clear line_buffering") + f:setvbuf('full') + assert(f.line_buffering == false, "setvbuf('full') did not clear line_buffering") + + -- nonblock/block should be safe no-ops for fd-backed streams. + f:nonblock() + f:block() + + -- Rename the tmpfile to a stable name and ensure it persists after close. + local newname = fname .. '.renamed' + local rok, rerr = f:rename(newname) + assert(rok, 'rename failed: ' .. tostring(rerr)) + assert(f:filename() == newname, + 'filename after rename was ' .. tostring(f:filename()) .. ', expected ' .. tostring(newname)) + + local ok, cerr = f:close() + assert(ok, 'tmpfile close after rename failed: ' .. tostring(cerr)) + + -- The renamed file should now exist and be openable via file_mod.open. + local f2, oerr = file_mod.open(newname, 'r') + assert(f2, 'expected to reopen renamed file, got error: ' .. tostring(oerr)) + + local _, e = f2:read_all() + assert(e == nil, 'read_all from reopened file returned error: ' .. tostring(e)) + -- Content is not prescribed here; just ensure read_all works and the stream is valid. + + local ok2, cerr2 = f2:close() + assert(ok2, 'reopened stream close failed: ' .. tostring(cerr2)) + + -- Clean up the renamed file to avoid littering. + os.remove(newname) +end + +---------------------------------------------------------------------- +-- 10. mkdir / rename / unlink (file_mod-level functions) +---------------------------------------------------------------------- + +local function tmp_base() + return os.getenv('TMPDIR') or '/tmp' +end + +local function tmp_path(prefix) + prefix = prefix or 'fibers-test' + return ('%s/%s-%d-%d'):format(tmp_base(), prefix, os.time(), math.random(1e9)) +end + +local function test_mkdir_and_unlink() + -- Create a new directory. + local dir = tmp_path('fibers-mkdir') + + -- 0777 decimal is 511. + local ok, err = file_mod.mkdir(dir, 511) + assert(ok, 'mkdir failed: ' .. tostring(err)) + + -- Create a file inside the directory. + local p = dir .. '/x.txt' + local f, oerr = file_mod.open(p, 'w+', 'rw-r--r--') + assert(f, 'open for write in mkdir dir failed: ' .. tostring(oerr)) + + local msg = 'mkdir/unlink test' + local n, werr = f:write(msg) + assert(werr == nil, 'write failed in mkdir/unlink test: ' .. tostring(werr)) + assert(n == #msg, 'write wrote ' .. tostring(n) .. ' bytes, expected ' .. #msg) + + local c_ok, c_err = f:close() + assert(c_ok, 'close failed in mkdir/unlink test: ' .. tostring(c_err)) + + -- Unlink the file via the exposed API. + local uok, uerr = file_mod.unlink(p) + assert(uok, 'unlink failed: ' .. tostring(uerr)) + + -- Confirm the file is gone (open should fail). + local f2, oerr2 = file_mod.open(p, 'r') + assert(f2 == nil, 'expected open on unlinked file to fail') + assert(oerr2 ~= nil, 'expected an error opening unlinked file') + + -- Best-effort cleanup: remove the directory (not part of file_mod API). + os.execute(('rmdir %q'):format(dir)) +end + +local function test_filemod_rename() + -- Create a plain file (not via tmpfile:rename), then rename with file_mod.rename(). + local oldp = tmp_path('fibers-rename-old') .. '.txt' + local newp = tmp_path('fibers-rename-new') .. '.txt' + + local f, oerr = file_mod.open(oldp, 'w+') + assert(f, 'open(oldp) failed: ' .. tostring(oerr)) + + local msg = 'rename test content' + local n, werr = f:write(msg) + assert(werr == nil, 'write failed in rename test: ' .. tostring(werr)) + assert(n == #msg, 'write wrote ' .. tostring(n) .. ' bytes, expected ' .. #msg) + + local c_ok, c_err = f:close() + assert(c_ok, 'close failed in rename test: ' .. tostring(c_err)) + + -- Rename using the exposed function. + local rok, rerr = file_mod.rename(oldp, newp) + assert(rok, 'file_mod.rename failed: ' .. tostring(rerr)) + + -- Old path should not be openable; new path should contain the content. + local fold, eold = file_mod.open(oldp, 'r') + assert(fold == nil, 'expected old path to be gone after rename') + assert(eold ~= nil, 'expected error when opening old path after rename') + + local fnew, enew = file_mod.open(newp, 'r') + assert(fnew, 'expected to open renamed path: ' .. tostring(enew)) + + local got, gerr = fnew:read_all() + assert(gerr == nil, 'read_all failed on renamed file: ' .. tostring(gerr)) + assert(got == msg, ('renamed file content %q, expected %q'):format(tostring(got), tostring(msg))) + + local c2_ok, c2_err = fnew:close() + assert(c2_ok, 'close failed on renamed file: ' .. tostring(c2_err)) + + -- Cleanup using exposed unlink. + local uok, uerr = file_mod.unlink(newp) + assert(uok, 'cleanup unlink failed: ' .. tostring(uerr)) +end + +---------------------------------------------------------------------- +-- 11. mkdir_p (create nested directories) +---------------------------------------------------------------------- + +local function test_mkdir_p() + local base = tmp_path('fibers-mkdirp') + local nested = base .. '/a/b/c' + + -- Create nested path. + local ok, err = file_mod.mkdir_p(nested, 511) + assert(ok, 'mkdir_p failed: ' .. tostring(err)) + + -- Idempotent: calling again should succeed. + local ok2, err2 = file_mod.mkdir_p(nested, 511) + assert(ok2, 'mkdir_p second call failed: ' .. tostring(err2)) + + -- Create a file inside the deepest directory to prove the path exists. + local p = nested .. '/probe.txt' + local f, oerr = file_mod.open(p, 'w+', 'rw-r--r--') + assert(f, 'open in mkdir_p dir failed: ' .. tostring(oerr)) + + local msg = 'mkdir_p test' + local n, werr = f:write(msg) + assert(werr == nil, 'write failed in mkdir_p test: ' .. tostring(werr)) + assert(n == #msg, 'write wrote ' .. tostring(n) .. ' bytes, expected ' .. #msg) + + local c_ok, c_err = f:close() + assert(c_ok, 'close failed in mkdir_p test: ' .. tostring(c_err)) + + -- Cleanup file via file_mod.unlink. + local uok, uerr = file_mod.unlink(p) + assert(uok, 'unlink failed in mkdir_p test: ' .. tostring(uerr)) + + -- Best-effort cleanup: remove directories from leaf to root. + os.execute(('rmdir %q'):format(nested)) + os.execute(('rmdir %q'):format(base .. '/a/b')) + os.execute(('rmdir %q'):format(base .. '/a')) + os.execute(('rmdir %q'):format(base)) +end + +---------------------------------------------------------------------- +-- Main +---------------------------------------------------------------------- + +local function main() + test_tmpfile_roundtrip() + test_pipe_roundtrip_and_eof() + test_closed_stream_errors() + test_cancellation_cancels_blocked_read() + + test_read_line_variants() + test_read_all_and_exactly() + test_read_numeric_formats() + test_write_variants() + test_merge_lines_op() + test_stream_properties_and_rename() + test_mkdir_and_unlink() + test_filemod_rename() + test_mkdir_p() +end + +fibers.run(main) + +print('test_file.lua: all assertions passed') diff --git a/tests/test_io-mem.lua b/tests/test_io-mem.lua new file mode 100644 index 0000000..abf5e79 --- /dev/null +++ b/tests/test_io-mem.lua @@ -0,0 +1,279 @@ +-- tests/test_stream_mem.lua +-- +-- Integration tests for: +-- - fibers.io.mem_backend +-- - fibers.io.stream +-- +-- Uses the real scheduler, ops and wait/waitset machinery. +print('testing: fibers.io.mem_stream') + +-- look one level up +package.path = '../src/?.lua;' .. package.path + + +local fibers = require 'fibers' +local stream = require 'fibers.io.stream' +local mem = require 'fibers.io.mem_backend' + + +local function assert_eq(actual, expected, msg) + if actual ~= expected then + error(string.format('%s (expected: %s, got: %s)', msg or 'assert_eq failed', + tostring(expected), tostring(actual))) + end +end + +local function assert_nil(v, msg) + if v ~= nil then + error(string.format('%s (expected nil, got: %s)', msg or 'assert_nil failed', tostring(v))) + end +end + +local function assert_true(v, msg) + if not v then + error(msg or 'assert_true failed') + end +end + +---------------------------------------------------------------------- +-- Test 1: simple one-shot write and read +---------------------------------------------------------------------- + +local function test_simple_read_write() + local a_io, b_io = mem.pipe(1024) + + local a = stream.open(a_io, true, true) + local b = stream.open(b_io, true, true) + + local payload = 'hello world' + + -- Writer: write once from A to B + fibers.spawn(function () + local ev = a:write_op(payload) + local n, err = fibers.perform(ev) + assert_nil(err, 'simple write: unexpected error') + assert_eq(n, #payload, 'simple write: wrong byte count') + end) + + -- Reader: read exactly len(payload) bytes + local ev = b:core_read_op { + min = #payload, + max = #payload, + eof_ok = true, + } + + local s, cnt, err = fibers.perform(ev) + + assert_nil(err, 'simple read: unexpected error') + assert_eq(cnt, #payload, 'simple read: wrong count') + assert_eq(s, payload, 'simple read: wrong data') + + a:close() + b:close() +end + +---------------------------------------------------------------------- +-- Test 2: backpressure and partial progress +-- Small buffer so writer must make progress in steps. +---------------------------------------------------------------------- + +local function test_backpressure_and_partial() + -- Small buffer forces backpressure. + local a_io, b_io = mem.pipe(4) + + local a = stream.open(a_io, true, true) + local b = stream.open(b_io, true, true) + + local payload = 'abcdef' -- length 6, buffer capacity 4 + + -- Reader: accumulate until we have the whole payload + fibers.spawn(function () + local collected = {} + local total = 0 + + while total < #payload do + -- Read at least 1 byte, at most 3 each time. + local ev = b:core_read_op { + min = 1, + max = 3, + eof_ok = true, + } + + local s, cnt, err = fibers.perform(ev) + assert_nil(err, 'backpressure read: unexpected error') + + if s == nil then + -- EOF; should not happen before we see all bytes. + break + end + + assert_true(cnt > 0, 'backpressure read: zero-length read with data?') + table.insert(collected, s) + total = total + cnt + end + + local joined = table.concat(collected) + assert_eq(joined, payload, 'backpressure read: wrong data') + end) + + -- Writer: write the full payload as one op + local ev = a:write_op(payload) + local n, err = fibers.perform(ev) + + assert_nil(err, 'backpressure write: unexpected error') + assert_eq(n, #payload, 'backpressure write: wrong byte count') + + a:close() + b:close() +end + +---------------------------------------------------------------------- +-- Test 3: EOF behaviour +---------------------------------------------------------------------- + +local function test_eof_behaviour() + local a_io, b_io = mem.pipe(1024) + + local a = stream.open(a_io, true, true) + local b = stream.open(b_io, true, true) + + local payload = 'end' + + -- Write then close A's half. + fibers.spawn(function () + local ev = a:write_op(payload) + local n, err = fibers.perform(ev) + assert_nil(err, 'EOF write: unexpected error') + assert_eq(n, #payload, 'EOF write: wrong byte count') + a:close() + end) + + -- First read should get the payload. + local ev1 = b:core_read_op { + min = #payload, + max = #payload, + eof_ok = true, + } + + local s1, cnt1, err1 = fibers.perform(ev1) + assert_nil(err1, 'EOF read(1): unexpected error') + assert_eq(cnt1, #payload, 'EOF read(1): wrong count') + assert_eq(s1, payload, 'EOF read(1): wrong data') + + -- Second read should see EOF. For read_string_op: + -- EOF with no data → (nil, 0, err|nil) + local ev2 = b:core_read_op { + min = 1, + max = 16, + eof_ok = true, + } + + local s2, cnt2, err2 = fibers.perform(ev2) + -- EOF is not treated as an error at this layer. + assert_nil(err2, 'EOF read(2): unexpected error') + assert_nil(s2, 'EOF read(2): expected nil string at EOF') + assert_eq(cnt2, 0, 'EOF read(2): expected count == 0 at EOF') + + b:close() +end + +---------------------------------------------------------------------- +-- Test 4: line-style read using terminator +---------------------------------------------------------------------- + +local function test_line_terminator() + local a_io, b_io = mem.pipe(1024) + + local a = stream.open(a_io, true, true) + local b = stream.open(b_io, true, true) + + local data = 'line1\nline2\n' + + fibers.spawn(function () + local ev = a:write_op(data) + local n, err = fibers.perform(ev) + assert_nil(err, 'line write: unexpected error') + assert_eq(n, #data, 'line write: wrong byte count') + a:close() + end) + + -- Read up to and including first "\n" + local ev1 = b:core_read_op { + min = 1, + max = #data, + terminator = '\n', + eof_ok = true, + } + + local s1, cnt1, err1 = fibers.perform(ev1) + assert_nil(err1, 'line read(1): unexpected error') + assert_eq(s1, 'line1\n', 'line read(1): wrong data') + assert_eq(cnt1, #s1, 'line read(1): wrong count') + + -- Read up to and including second "\n" + local ev2 = b:core_read_op { + min = 1, + max = #data, + terminator = '\n', + eof_ok = true, + } + + local s2, cnt2, err2 = fibers.perform(ev2) + assert_nil(err2, 'line read(2): unexpected error') + assert_eq(s2, 'line2\n', 'line read(2): wrong data') + assert_eq(cnt2, #s2, 'line read(2): wrong count') + + -- Third read should see EOF + local ev3 = b:core_read_op { + min = 1, + max = 16, + terminator = '\n', + eof_ok = true, + } + + local s3, cnt3, err3 = fibers.perform(ev3) + assert_nil(err3, 'line read(3): unexpected error') + assert_nil(s3, 'line read(3): expected nil at EOF') + assert_eq(cnt3, 0, 'line read(3): expected count == 0 at EOF') + + b:close() +end + +---------------------------------------------------------------------- +-- Test 5: write after peer close +---------------------------------------------------------------------- + +local function test_write_after_peer_close() + local a_io, b_io = mem.pipe(1024) + + local a = stream.open(a_io, true, true) + local b = stream.open(b_io, true, true) + + -- Close B immediately. + b:close() + + -- Writing from A should report "closed" from the backend. + local ev = a:core_write_op('x') + local _, err = fibers.perform(ev) + + -- Depending on exact semantics, n may be 0 or nil; err should be "closed". + assert_eq(err, 'closed', "write after close: expected 'closed' error") + + a:close() +end + +---------------------------------------------------------------------- +-- Main test runner +---------------------------------------------------------------------- + +local function main() + test_simple_read_write() + test_backpressure_and_partial() + test_eof_behaviour() + test_line_terminator() + test_write_after_peer_close() +end + +fibers.run(main) + +print('OK\tstream + mem_backend tests passed') diff --git a/tests/test_io-poller.lua b/tests/test_io-poller.lua new file mode 100644 index 0000000..212c195 --- /dev/null +++ b/tests/test_io-poller.lua @@ -0,0 +1,53 @@ +--- Tests the File implementation. +print('testing: fibers.fd') + +-- look one level up +package.path = '../src/?.lua;' .. package.path + +local file_stream = require 'fibers.io.file' +local runtime = require 'fibers.runtime' + +local function equal(x, y) + if type(x) ~= type(y) then return false end + if type(x) == 'table' then + for k, v in pairs(x) do + if not equal(v, y[k]) then return false end + end + for k, _ in pairs(y) do + if x[k] == nil then return false end + end + return true + else + return x == y + end +end + +local log = {} +local function record(x) table.insert(log, x) end + +runtime.current_scheduler:run() +assert(equal(log, {})) + +local rd, wr = file_stream.pipe() +local message = 'hello, world\n' +runtime.spawn_raw(function () + record('rd-a') + local str = rd:read_string { min = 1, max = math.huge } + record('rd-b') + record(str) +end) +runtime.spawn_raw(function () + record('wr-a') + wr:write(message) + record('wr-b') + wr:flush() + record('wr-c') +end) + +runtime.current_scheduler:run() +assert(equal(log, { 'rd-a', 'wr-a', 'wr-b', 'wr-c' })) +runtime.current_scheduler:run() +runtime.current_scheduler:run() +assert(equal(log, { 'rd-a', 'wr-a', 'wr-b', 'wr-c', 'rd-b', message })) + +print('test: ok') diff --git a/tests/test_io-socket.lua b/tests/test_io-socket.lua new file mode 100644 index 0000000..172d8c0 --- /dev/null +++ b/tests/test_io-socket.lua @@ -0,0 +1,146 @@ +-- tests/test_io-socket.lua +-- +-- Integration tests for: +-- - fibers.io.socket +-- - fibers.io.fd_backend +-- - fibers.io.stream +-- +-- Uses the real scheduler, ops and wait/waitset machinery. +print('testing: fibers.io.socket') + +-- look one level up +package.path = '../src/?.lua;' .. package.path + +local fibers = require 'fibers' +local socket_mod = require 'fibers.io.socket' + +local perform = fibers.perform + +math.randomseed(os.time()) + +local function read_exact(stream, n, who) + local data, cnt, err = perform(stream:core_read_op { + min = n, + max = n, + eof_ok = true, + }) + assert(err == nil, (who or 'read') .. ' error: ' .. tostring(err)) + assert(cnt == n, (who or 'read') .. ' read ' .. tostring(cnt) .. ' bytes, expected ' .. tostring(n)) + return data +end + +local function write_all(stream, s, who) + local n, err = perform(stream:write_op(s)) + assert(err == nil, (who or 'write') .. ' error: ' .. tostring(err)) + assert(n == #s, (who or 'write') .. ' wrote ' .. tostring(n) .. ' bytes, expected ' .. tostring(#s)) +end + +local function close_ok(obj, who) + local ok, err = obj:close() + assert(ok, (who or 'close') .. ' failed: ' .. tostring(err)) +end + +local function pick_listen_port() + -- Avoid privileged ports; choose from a broad high range. + return math.random(30000, 55000) +end + +local function listen_inet_retry(host, tries) + tries = tries or 32 + local last_err + + for _ = 1, tries do + local port = pick_listen_port() + local s, err = socket_mod.listen_inet(host, port) + if s then + return s, port + end + last_err = err + end + + return nil, nil, ('failed to bind IPv4 listener after retries: %s'):format(tostring(last_err)) +end + +local function test_unix_socket_roundtrip(scope) + -- Construct a unique path under /tmp for this test run. + local base = os.getenv('TMPDIR') or '/tmp' + local path = string.format('%s/fibers_socket_test.%d.%d', + base, os.time(), math.random(1, 1000000)) + + -- Start listening server. + local server, err = socket_mod.listen_unix(path, { ephemeral = true }) + assert(server, 'listen_unix failed: ' .. tostring(err)) + + -- Server fibre: accept one connection, echo a response, then close. + scope:spawn(function () + local s, aerr = server:accept() + assert(s, 'server accept failed: ' .. tostring(aerr)) + + local msg = read_exact(s, 5, 'server(unix) read') + assert(msg == 'hello', ('server(unix) received %q, expected %q'):format(tostring(msg), 'hello')) + + write_all(s, 'world', 'server(unix) write') + + close_ok(s, 'server(unix) stream close') + close_ok(server, 'server(unix) socket close') + end) + + -- Client side: connect, send "hello", read "world". + local client, cerr = socket_mod.connect_unix(path) + assert(client, 'connect_unix failed: ' .. tostring(cerr)) + + write_all(client, 'hello', 'client(unix) write') + + local resp = read_exact(client, 5, 'client(unix) read') + assert(resp == 'world', ('client(unix) received %q, expected %q'):format(tostring(resp), 'world')) + + close_ok(client, 'client(unix) stream close') +end + +local function test_inet_socket_roundtrip(scope) + assert(socket_mod.AF_INET, 'AF_INET not exported by fibers.io.socket') + + local host = '127.0.0.1' + + -- Start listening server on a random high port. + local server, port, lerr = listen_inet_retry(host, 64) + assert(server, lerr or 'listen_inet failed') + + -- Server fibre: accept one connection, echo a response, then close. + scope:spawn(function () + local s, aerr = server:accept() + assert(s, 'server accept failed: ' .. tostring(aerr)) + + local msg = read_exact(s, 5, 'server(inet) read') + assert(msg == 'hello', ('server(inet) received %q, expected %q'):format(tostring(msg), 'hello')) + + write_all(s, 'world', 'server(inet) write') + + close_ok(s, 'server(inet) stream close') + close_ok(server, 'server(inet) socket close') + end) + + -- Client side: connect over loopback, explicitly binding source address. + -- bind_port=0 asks the kernel to choose an ephemeral source port. + local client, cerr = socket_mod.connect_inet(host, port, { + bind_host = '127.0.0.1', + bind_port = 0, + }) + assert(client, 'connect_inet failed: ' .. tostring(cerr)) + + write_all(client, 'hello', 'client(inet) write') + + local resp = read_exact(client, 5, 'client(inet) read') + assert(resp == 'world', ('client(inet) received %q, expected %q'):format(tostring(resp), 'world')) + + close_ok(client, 'client(inet) stream close') +end + +local function main(scope) + test_unix_socket_roundtrip(scope) + test_inet_socket_roundtrip(scope) +end + +fibers.run(main) + +print('test_io-socket.lua: all assertions passed') diff --git a/tests/test_io-stream.lua b/tests/test_io-stream.lua new file mode 100644 index 0000000..ef72697 --- /dev/null +++ b/tests/test_io-stream.lua @@ -0,0 +1,944 @@ +-- tests/test_stream_mem.lua +-- +-- Synthetic tests for fibers.io.stream using in-memory backends. +print('testing: fibers.io.stream') + +package.path = '../src/?.lua;' .. package.path + +local fibers = require 'fibers' +local stream = require 'fibers.io.stream' +local wait = require 'fibers.wait' +local runtime = require 'fibers.runtime' +local sleep = require 'fibers.sleep' +local op = require 'fibers.op' +local waitgroup = require 'fibers.waitgroup' +local perform = require 'fibers.performer'.perform + +require 'fibers.scope'.set_debug(true) + +local function with_timeout(ev, timeout_s) + -- op.boolean_choice returns: (won:boolean, ...results...) + return perform(op.boolean_choice(ev, sleep.sleep_op(timeout_s))) +end + +local function assert_eq(a, b, msg) + if a ~= b then + error((msg or 'assert_eq failed') .. (': got ' .. tostring(a) .. ', expected ' .. tostring(b)), 2) + end +end + +local function assert_truthy(v, msg) + if not v then error(msg or 'expected truthy') end +end + +local function assert_ok_or_zero(v, msg) + if v ~= true and v ~= 0 then + error((msg or 'expected true or 0') .. (': got ' .. tostring(v)), 2) + end +end + +local function assert_internal_ws_empty(s, msg) + local ws = s and s._ws + if not ws or not ws.buckets then return end + if next(ws.buckets) ~= nil then + local keys = {} + for k in pairs(ws.buckets) do keys[#keys + 1] = tostring(k) end + error((msg or 'internal waitset leaked entries') .. ': keys=' .. table.concat(keys, ','), 2) + end +end + +local function assert_closed_err(err) + assert_truthy(err == 'closed' or err == 'stream closed', 'expected close error, got ' .. tostring(err)) +end + +---------------------------------------------------------------------- +-- Backend 1: basic duplex, partial writes, only "rd" notifications +---------------------------------------------------------------------- + +local function make_stream_pair() + local shared = { + buf = '', + closed = false, + waitset = wait.new_waitset(), -- key "rd" for readability + } + + local rd_io = { shared = shared } + local wr_io = { shared = shared } + + function rd_io:read_string(max) + if #self.shared.buf == 0 then + if self.shared.closed then + return '', nil -- EOF + end + return nil, nil -- would block + end + max = max or 1 + local n = math.min(1, max, #self.shared.buf) + local s = self.shared.buf:sub(1, n) + self.shared.buf = self.shared.buf:sub(n + 1) + return s, nil + end + + function wr_io:write_string(str) + if self.shared.closed then + return nil, 'closed' + end + if #str == 0 then + return 0, nil + end + local n = 1 + local ch = str:sub(1, n) + shared.buf = shared.buf .. ch + shared.waitset:notify_all('rd', runtime.current_scheduler) + return n, nil + end + + function rd_io:on_readable(task) + return shared.waitset:add('rd', task) + end + + function wr_io:on_writable(task) + runtime.current_scheduler:schedule(task) + return { unlink = function () end } + end + + function rd_io:close() + shared.closed = true + shared.waitset:notify_all('rd', runtime.current_scheduler) + return true + end + + function wr_io:close() + shared.closed = true + shared.waitset:notify_all('rd', runtime.current_scheduler) + return true + end + + function rd_io:seek() return nil, 'not seekable' end + + function wr_io:seek() return nil, 'not seekable' end + + function rd_io:nonblock() end + + function rd_io:block() end + + function wr_io:nonblock() end + + function wr_io:block() end + + local rd = stream.open(rd_io, true, false) + local wr = stream.open(wr_io, false, true) + return rd, wr, shared +end + +---------------------------------------------------------------------- +-- Backend 2: "want='wr'" read wakeups to validate want propagation +---------------------------------------------------------------------- + +local function make_stream_pair_want_wr() + local shared = { + buf = '', + closed = false, + waitset = wait.new_waitset(), -- use key "wr" only for wakeups + rd_regs = 0, + wr_regs = 0, + } + + local rd_io = { shared = shared } + local wr_io = { shared = shared } + + function rd_io:read_string(max) + if #self.shared.buf == 0 then + if self.shared.closed then + return '', nil -- EOF + end + return nil, nil, 'wr' + end + max = max or 1 + local n = math.min(1, max, #self.shared.buf) + local s = self.shared.buf:sub(1, n) + self.shared.buf = self.shared.buf:sub(n + 1) + return s, nil + end + + function wr_io:write_string(str) + if self.shared.closed then + return nil, 'closed' + end + if #str == 0 then + return 0, nil + end + local n = 1 + local ch = str:sub(1, n) + shared.buf = shared.buf .. ch + shared.waitset:notify_all('wr', runtime.current_scheduler) + return n, nil + end + + function rd_io:on_readable(task) + shared.rd_regs = shared.rd_regs + 1 + return shared.waitset:add('rd', task) + end + + function rd_io:on_writable(task) + shared.wr_regs = shared.wr_regs + 1 + return shared.waitset:add('wr', task) + end + + function wr_io:on_writable(task) + runtime.current_scheduler:schedule(task) + return { unlink = function () end } + end + + function rd_io:close() + shared.closed = true + shared.waitset:notify_all('wr', runtime.current_scheduler) + shared.waitset:notify_all('rd', runtime.current_scheduler) + return true + end + + function wr_io:close() + shared.closed = true + shared.waitset:notify_all('wr', runtime.current_scheduler) + shared.waitset:notify_all('rd', runtime.current_scheduler) + return true + end + + function rd_io:seek() return nil, 'not seekable' end + + function wr_io:seek() return nil, 'not seekable' end + + function rd_io:nonblock() end + + function rd_io:block() end + + function wr_io:nonblock() end + + function wr_io:block() end + + local rd = stream.open(rd_io, true, false) + local wr = stream.open(wr_io, false, true) + return rd, wr, shared +end + +---------------------------------------------------------------------- +-- Backend 3: full duplex for buffered write/flush tests +---------------------------------------------------------------------- + +local function make_stream_pair_full() + local shared = { + wire = '', + closed = false, + waitset = wait.new_waitset(), -- keys: 'rd' + } + + local rd_io = { shared = shared } + local wr_io = { shared = shared } + + function rd_io:read_string(max) + if #self.shared.wire == 0 then + if self.shared.closed then + return '', nil -- EOF + end + return nil, nil -- would block + end + + max = max or 1 + local n = math.min(1, max, #self.shared.wire) + local s = self.shared.wire:sub(1, n) + self.shared.wire = self.shared.wire:sub(n + 1) + return s, nil + end + + function wr_io:write_string(str) + if self.shared.closed then + return nil, 'closed' + end + if #str == 0 then + return 0, nil + end + + local ch = str:sub(1, 1) + self.shared.wire = self.shared.wire .. ch + + self.shared.waitset:notify_all('rd', runtime.current_scheduler) + return 1, nil + end + + function rd_io:on_readable(task) + return self.shared.waitset:add('rd', task) + end + + function wr_io:on_writable(task) + runtime.current_scheduler:schedule(task) + return { unlink = function () end } + end + + function rd_io:close() + self.shared.closed = true + self.shared.waitset:notify_all('rd', runtime.current_scheduler) + return true + end + + function wr_io:close() + self.shared.closed = true + self.shared.waitset:notify_all('rd', runtime.current_scheduler) + return true + end + + function rd_io:seek() return nil, 'not seekable' end + + function wr_io:seek() return nil, 'not seekable' end + + function rd_io:nonblock() end + + function rd_io:block() end + + function wr_io:nonblock() end + + function wr_io:block() end + + local rd = stream.open(rd_io, true, false) + local wr = stream.open(wr_io, false, true) + return rd, wr, shared +end + +local function make_stream_pair_full_backpressure(cap) + cap = cap or 8 + + local shared = { + wire = '', + closed = false, + waitset = wait.new_waitset(), -- keys: 'rd', 'wr' + cap = cap, + } + + local rd_io = { shared = shared } + local wr_io = { shared = shared } + + function rd_io:read_string(max) + if #self.shared.wire == 0 then + if self.shared.closed then + return '', nil -- EOF + end + return nil, nil -- would block + end + + max = max or 1 + local n = math.min(1, max, #self.shared.wire) + local s = self.shared.wire:sub(1, n) + self.shared.wire = self.shared.wire:sub(n + 1) + + self.shared.waitset:notify_all('wr', runtime.current_scheduler) + return s, nil + end + + function wr_io:write_string(str) + if self.shared.closed then + return nil, 'closed' + end + if #str == 0 then + return 0, nil + end + + if #self.shared.wire >= self.shared.cap then + return nil, nil, 'wr' + end + + local ch = str:sub(1, 1) + self.shared.wire = self.shared.wire .. ch + + self.shared.waitset:notify_all('rd', runtime.current_scheduler) + return 1, nil + end + + function rd_io:on_readable(task) + return self.shared.waitset:add('rd', task) + end + + function wr_io:on_writable(task) + return self.shared.waitset:add('wr', task) + end + + function rd_io:close() + self.shared.closed = true + self.shared.waitset:notify_all('rd', runtime.current_scheduler) + self.shared.waitset:notify_all('wr', runtime.current_scheduler) + return true + end + + function wr_io:close() + self.shared.closed = true + self.shared.waitset:notify_all('rd', runtime.current_scheduler) + self.shared.waitset:notify_all('wr', runtime.current_scheduler) + return true + end + + function rd_io:seek() return nil, 'not seekable' end + + function wr_io:seek() return nil, 'not seekable' end + + function rd_io:nonblock() end + + function rd_io:block() end + + function wr_io:nonblock() end + + function wr_io:block() end + + local rd = stream.open(rd_io, true, false) + local wr = stream.open(wr_io, false, true) + return rd, wr, shared +end + +---------------------------------------------------------------------- +-- Backend 4: write error injection (sticky write error propagation) +---------------------------------------------------------------------- + +local function make_stream_pair_write_error(opts) + opts = opts or {} + local fail_after = opts.fail_after or 4 + + local shared = { + wire = '', + closed = false, + waitset = wait.new_waitset(), -- key 'rd' + writes = 0, + fail_after = fail_after, + } + + local rd_io = { shared = shared } + local wr_io = { shared = shared } + + function rd_io:read_string(max) + if #self.shared.wire == 0 then + if self.shared.closed then + return '', nil -- EOF + end + return nil, nil -- would block + end + max = max or 1 + local n = math.min(1, max, #self.shared.wire) + local s = self.shared.wire:sub(1, n) + self.shared.wire = self.shared.wire:sub(n + 1) + return s, nil + end + + function wr_io:write_string(str) + if self.shared.closed then + return nil, 'closed' + end + if #str == 0 then + return 0, nil + end + + self.shared.writes = self.shared.writes + 1 + if self.shared.writes >= self.shared.fail_after then + return nil, 'boom' -- injected hard error + end + + local ch = str:sub(1, 1) + self.shared.wire = self.shared.wire .. ch + self.shared.waitset:notify_all('rd', runtime.current_scheduler) + return 1, nil + end + + function rd_io:on_readable(task) + return self.shared.waitset:add('rd', task) + end + + function wr_io:on_writable(task) + runtime.current_scheduler:schedule(task) + return { unlink = function () end } + end + + function rd_io:close() + self.shared.closed = true + self.shared.waitset:notify_all('rd', runtime.current_scheduler) + return true + end + + function wr_io:close() + self.shared.closed = true + self.shared.waitset:notify_all('rd', runtime.current_scheduler) + return true + end + + function rd_io:seek() return nil, 'not seekable' end + + function wr_io:seek() return nil, 'not seekable' end + + function rd_io:nonblock() end + + function rd_io:block() end + + function wr_io:nonblock() end + + function wr_io:block() end + + local rd = stream.open(rd_io, true, false) + local wr = stream.open(wr_io, false, true) + return rd, wr, shared +end + +---------------------------------------------------------------------- +-- Tests +---------------------------------------------------------------------- + +local function test_basic_line_read() + local rd, wr, shared = make_stream_pair() + + rd:setvbuf('full') + wr:setvbuf('line') + assert_truthy(wr.line_buffering == true, "setvbuf('line') did not set line_buffering") + + local message = 'hello, world\n' + + fibers.spawn(function () + sleep.sleep(0.01) + local n, err = perform(wr:write_op(message)) + assert_eq(err, nil, 'write error') + assert_eq(n, #message, 'write length mismatch') + + local ok, cerr = perform(wr:close_op()) + assert_eq(ok, true, 'close ok expected') + assert_eq(cerr, nil, 'close err expected nil') + end) + + local line, err = perform(rd:read_line_op { keep_terminator = true }) + assert_eq(err, nil, 'read_line_op error') + assert_eq(line, message, 'read_line_op returned wrong line') + + local ok, cerr = perform(rd:close_op()) + assert_eq(ok, true, 'close ok expected') + assert_eq(cerr, nil, 'close err expected nil') + + assert_eq(shared.waitset:size('rd'), 0, 'waitset still has readers after close') +end + +local function test_close_unblocks_reader_no_crash() + local rd, wr, shared = make_stream_pair() + + fibers.spawn(function () + sleep.sleep(0.01) + local ok, cerr = perform(rd:close_op()) + assert_eq(ok, true) + assert_eq(cerr, nil) + end) + + local won, line, err = with_timeout(rd:read_line_op { keep_terminator = true }, 0.2) + assert_eq(won, true, 'timed out waiting for blocked read to resolve on close') + assert_eq(line, nil, 'expected nil line on close') + assert_closed_err(err) + + assert_eq(shared.waitset:size('rd'), 0, 'waitset still has readers after close-unblock') + + local ok, cerr = perform(wr:close_op()) + assert_eq(ok, true) + assert_eq(cerr, nil) +end + +local function test_abort_unlinks_waiters() + local rd, wr, shared = make_stream_pair() + + local won = with_timeout(rd:read_exactly_op(1), 0.02) + assert_eq(won, false, 'expected timeout branch to win') + + assert_eq(shared.waitset:size('rd'), 0, 'waitset leaked readers after abort') + + perform(rd:close_op()) + perform(wr:close_op()) +end + +local function test_want_wiring_wr() + local rd, wr, shared = make_stream_pair_want_wr() + + local message = 'x\n' + + fibers.spawn(function () + sleep.sleep(0.01) + local n, err = perform(wr:write_op(message)) + assert_eq(err, nil) + assert_eq(n, #message) + perform(wr:close_op()) + end) + + local won, line, err = with_timeout(rd:read_line_op { keep_terminator = true }, 0.2) + assert_eq(won, true, 'timed out: want="wr" registration did not wake') + assert_eq(err, nil) + assert_eq(line, message) + + assert_truthy(shared.wr_regs > 0, 'expected on_writable registrations (want="wr")') + assert_eq(shared.rd_regs, 0, 'unexpected on_readable registrations; want wiring may be ignored') + + perform(rd:close_op()) + assert_eq(shared.waitset:size('wr'), 0, 'waitset leaked wr waiters') +end + +local function test_flush_is_noop_on_readonly() + local rd, _, _ = make_stream_pair() + + local ok, err = perform(rd:flush_op()) + assert_ok_or_zero(ok, 'flush on read-only should succeed') + assert_eq(err, nil, 'flush on read-only should have nil err') + + perform(rd:close_op()) +end + +local function test_read_some_and_exactly_and_all_eof_shapes() + local rd, wr, _ = make_stream_pair_full() + + local msg = 'abcdef' + local n, werr = perform(wr:write_op(msg)) + assert_eq(werr, nil) + assert_eq(n, #msg) + + local fok, ferr = perform(wr:flush_op()) + assert_ok_or_zero(fok); assert_eq(ferr, nil) + perform(wr:close_op()) + + local s1, e1 = perform(rd:read_some_op(2)) + assert_eq(e1, nil) + assert_truthy(type(s1) == 'string' and #s1 > 0 and #s1 <= 2, 'read_some size') + + local rest_needed = #msg - #s1 + local s2, e2 = perform(rd:read_exactly_op(rest_needed)) + assert_eq(e2, nil) + assert_eq(#s2, rest_needed) + + local s3, e3 = perform(rd:read_some_op(10)) + assert_eq(s3, nil) + assert_truthy(e3 == nil or e3 == 'eof', 'expected eof indicator') + + local all, aerr = perform(rd:read_all_op()) + assert_eq(all, '') + assert_eq(aerr, nil, 'read_all should treat eof as success') + + perform(rd:close_op()) +end + +local function test_write_buffering_write_then_flush_drains() + local rd, wr, shared = make_stream_pair_full() + + local msg = ('x'):rep(256) + + local won, n, err = with_timeout(wr:write_op(msg), 0.05) + assert_eq(won, true, 'write_op should not block on backend drain') + assert_eq(err, nil) + assert_eq(n, #msg) + + local won2, ok, ferr = with_timeout(wr:flush_op(), 0.5) + assert_eq(won2, true, 'flush_op should complete') + assert_ok_or_zero(ok) + assert_eq(ferr, nil) + + perform(wr:close_op()) + + local got, rerr = perform(rd:read_all_op()) + assert_eq(rerr, nil) + assert_eq(got, msg) + + perform(rd:close_op()) + + assert_eq(shared.waitset:size('rd'), 0, 'rd waiters leaked') + assert_eq(shared.waitset:size('wr'), 0, 'wr waiters leaked') +end + +local function test_concurrent_writers_are_serialised_no_interleave() + local rd, wr, shared = make_stream_pair_full() + local wg = require('fibers.waitgroup').new() + + local a = ('A'):rep(64) + local b = ('B'):rep(64) + + wg:add(2) + + fibers.spawn(function () + local n, err = perform(wr:write_op(a)) + assert_eq(err, nil); assert_eq(n, #a) + wg:done() + end) + + fibers.spawn(function () + local n, err = perform(wr:write_op(b)) + assert_eq(err, nil); assert_eq(n, #b) + wg:done() + end) + + wg:wait() + + local ok, ferr = perform(wr:flush_op()) + assert_ok_or_zero(ok); assert_eq(ferr, nil) + + perform(wr:close_op()) + + local all, rerr = perform(rd:read_all_op()) + assert_eq(rerr, nil) + assert_eq(#all, #a + #b) + + local ab = a .. b + local ba = b .. a + assert_truthy(all == ab or all == ba, 'write interleaving detected: got=' .. tostring(all)) + + perform(rd:close_op()) + + assert_eq(shared.waitset:size('rd'), 0) +end + +local function test_abort_unlinks_write_waiters_and_does_not_deadlock() + local rd, wr, shared = make_stream_pair_full_backpressure(8) + + local msg = ('z'):rep(512) + local n, err = perform(wr:write_op(msg)) + assert_eq(err, nil); assert_eq(n, #msg) + + local won1 = with_timeout(wr:flush_op(), 0.001) + assert_eq(won1, false, 'expected timeout branch to win (flush should block under backpressure)') + + local wr1 = shared.waitset:size('wr') + local rd1 = shared.waitset:size('rd') + assert_truthy(wr1 == 0 or wr1 == 1, ('unexpected wr waiter count after abort: %d'):format(wr1)) + assert_eq(rd1, 0, ('unexpected rd waiters after abort: %d'):format(rd1)) + + local won2 = with_timeout(wr:flush_op(), 0.001) + assert_eq(won2, false, 'expected timeout branch to win again') + + local wr2 = shared.waitset:size('wr') + local rd2 = shared.waitset:size('rd') + assert_eq(rd2, 0, ('unexpected rd waiters after second abort: %d'):format(rd2)) + assert_eq(wr2, wr1, ('wr waiter count grew across aborts: %d -> %d'):format(wr1, wr2)) + + local wg = require('fibers.waitgroup').new() + wg:add(1) + + fibers.spawn(function () + local s, rerr = perform(rd:read_exactly_op(#msg)) + assert_eq(rerr, nil) + assert_eq(#s, #msg) + wg:done() + end) + + local won3, ok3, ferr3 = with_timeout(wr:flush_op(), 0.5) + assert_eq(won3, true, 'flush did not complete after reader drained') + assert_ok_or_zero(ok3) + assert_eq(ferr3, nil) + + assert_eq(shared.waitset:size('wr'), 0, 'wr waiters not cleared after successful flush') + assert_eq(shared.waitset:size('rd'), 0, 'rd waiters not cleared after successful flush') + + perform(wr:close_op()) + wg:wait() + perform(rd:close_op()) + + assert_eq(shared.waitset:size('wr'), 0, 'wr waiters leaked at end') + assert_eq(shared.waitset:size('rd'), 0, 'rd waiters leaked at end') +end + +local function test_seek_and_setvbuf_surface() + local rd, wr, _ = make_stream_pair() + + rd:setvbuf('full') + assert_eq(rd.line_buffering, false) + + wr:setvbuf('line') + assert_eq(wr.line_buffering, true) + + wr:setvbuf('no') + assert_eq(wr.line_buffering, false) + + local pos, err = rd:seek('cur', 0) + assert_eq(pos, nil) + assert_truthy(err ~= nil) + + perform(rd:close_op()) + perform(wr:close_op()) +end + +local function test_close_is_idempotent_and_unblocks_waiters() + local rd, wr, shared = make_stream_pair_full() + + fibers.spawn(function () + sleep.sleep(0.01) + perform(rd:close_op()) + end) + + local won, line, err = with_timeout(rd:read_line_op { keep_terminator = true }, 0.2) + assert_eq(won, true) + assert_eq(line, nil) + assert_closed_err(err) + + local ok2, err2 = perform(rd:close_op()) + assert_eq(ok2, true) + assert_eq(err2, nil) + + perform(wr:close_op()) + + assert_eq(shared.waitset:size('rd'), 0) + assert_eq(shared.waitset:size('wr'), 0) +end + +---------------------------------------------------------------------- +-- New close semantics tests (for latched close + prompt begin on block) +---------------------------------------------------------------------- + +local function test_close_is_side_effect_free_when_it_loses_in_choice() + local rd, wr, shared = make_stream_pair_full() + + -- First arm is immediately ready; close_op should lose without starting close. + local won, v = perform(op.boolean_choice(op.always('win'), wr:close_op())) + assert_eq(won, true) + assert_eq(v, 'win') + + assert_truthy(not wr._closing, 'close should not begin during speculative probe') + assert_truthy(not wr._closed, 'stream should not be closed after losing close arm') + + -- Stream remains usable. + local msg = 'ok\n' + local n, werr = perform(wr:write_op(msg)) + assert_eq(werr, nil) + assert_eq(n, #msg) + local okf, ferr = perform(wr:flush_op()) + assert_ok_or_zero(okf); assert_eq(ferr, nil) + + perform(wr:close_op()) + local got, rerr = perform(rd:read_all_op()) + assert_eq(rerr, nil) + assert_eq(got, msg) + perform(rd:close_op()) + + assert_eq(shared.waitset:size('rd'), 0) + assert_eq(shared.waitset:size('wr'), 0) + assert_internal_ws_empty(wr, 'wr internal waitset leak after choice-losing close') + assert_internal_ws_empty(rd, 'rd internal waitset leak after choice-losing close') +end + +local function test_close_blocks_until_flush_completes_and_starts_promptly() + local rd, wr, shared = make_stream_pair_full_backpressure(4) + + local msg = ('m'):rep(128) + local n, err = perform(wr:write_op(msg)) + assert_eq(err, nil) + assert_eq(n, #msg) + + local box = { done = false, ok = nil, err = nil } + local wg = waitgroup.new() + wg:add(1) + + fibers.spawn(function () + local ok, cerr = perform(wr:close_op()) + box.ok = ok + box.err = cerr + box.done = true + wg:done() + end) + + -- Give the close a chance to enter blocking path and begin closing. + sleep.sleep(0.01) + assert_truthy(wr._closing or wr._closed, 'close did not begin promptly once blocked') + assert_eq(box.done, false, 'close_op returned before flush drained') + + -- Drain the wire; this should allow the writer pump to make progress. + local got, rerr = perform(rd:read_exactly_op(#msg)) + assert_eq(rerr, nil) + assert_eq(#got, #msg) + + wg:wait() + assert_eq(box.ok, true, 'close_op should succeed once drained') + assert_eq(box.err, nil) + + perform(rd:close_op()) + + assert_eq(shared.waitset:size('rd'), 0) + assert_eq(shared.waitset:size('wr'), 0) + assert_internal_ws_empty(wr, 'wr internal waitset leak after blocking close') + assert_internal_ws_empty(rd, 'rd internal waitset leak after blocking close') +end + +local function test_close_aborted_in_choice_still_completes() + local rd, wr, shared = make_stream_pair_full_backpressure(4) + + local msg = ('q'):rep(128) + local n, err = perform(wr:write_op(msg)) + assert_eq(err, nil) + assert_eq(n, #msg) + + -- Start a close, but race it against a short timeout so the close arm loses. + local won = with_timeout(wr:close_op(), 0.01) + assert_eq(won, false, 'expected timeout branch to win; close should still be pending') + + -- Allow any scheduled close/pump work to run. + sleep.sleep(0.01) + + -- Drain, which should allow close to finish in the background. + local got, rerr = perform(rd:read_exactly_op(#msg)) + assert_eq(rerr, nil) + assert_eq(#got, #msg) + + -- A subsequent close should now complete (idempotent). + local won2, ok2, err2 = with_timeout(wr:close_op(), 0.5) + assert_eq(won2, true, 'close did not complete after drain') + assert_eq(ok2, true) + assert_eq(err2, nil) + + perform(rd:close_op()) + + assert_eq(shared.waitset:size('rd'), 0) + assert_eq(shared.waitset:size('wr'), 0) + assert_internal_ws_empty(wr, 'wr internal waitset leak after aborted close') + assert_internal_ws_empty(rd, 'rd internal waitset leak after aborted close') +end + +local function test_close_reports_sticky_write_error_and_terminates() + local rd, wr, shared = make_stream_pair_write_error { fail_after = 3 } + + -- Enqueue more than fail_after bytes so the pump hits the injected error. + local msg = ('x'):rep(32) + local n, werr = perform(wr:write_op(msg)) + assert_eq(werr, nil) + assert_eq(n, #msg) + + -- Allow the pump to run and observe the backend error. + sleep.sleep(0.02) + + local ok, cerr = perform(wr:close_op()) + assert_eq(ok, nil, 'close should fail when a sticky write error is present') + assert_eq(cerr, 'boom', 'unexpected close error') + + -- Writer should be terminated best-effort. + assert_truthy(wr._closed, 'writer stream not terminated after close error') + + -- Reader should be closable and should not strand waiters. + perform(rd:close_op()) + + assert_eq(shared.waitset:size('rd'), 0) + assert_internal_ws_empty(wr, 'wr internal waitset leak after close error') + assert_internal_ws_empty(rd, 'rd internal waitset leak after close error') +end + +---------------------------------------------------------------------- +-- Main +---------------------------------------------------------------------- + +local function main() + test_basic_line_read() + test_close_unblocks_reader_no_crash() + test_abort_unlinks_waiters() + test_want_wiring_wr() + + test_flush_is_noop_on_readonly() + test_read_some_and_exactly_and_all_eof_shapes() + test_write_buffering_write_then_flush_drains() + test_concurrent_writers_are_serialised_no_interleave() + test_abort_unlinks_write_waiters_and_does_not_deadlock() + test_seek_and_setvbuf_surface() + test_close_is_idempotent_and_unblocks_waiters() + + test_close_is_side_effect_free_when_it_loses_in_choice() + test_close_blocks_until_flush_completes_and_starts_promptly() + test_close_aborted_in_choice_still_completes() + test_close_reports_sticky_write_error_and_terminates() +end + +fibers.run(main) + +print('selftest: ok') diff --git a/tests/test_io-upload_stress.lua b/tests/test_io-upload_stress.lua new file mode 100644 index 0000000..225e398 --- /dev/null +++ b/tests/test_io-upload_stress.lua @@ -0,0 +1,163 @@ +-- tests/test_io-upload_stress.lua +-- +-- Small upload-shaped regression test for long-lived scopes. It exercises the +-- socket -> stream -> file path repeatedly in the same scope and asserts that +-- the scope's cancellation/fault one-shots do not retain cancelled waiters from +-- completed I/O operations. + +print('testing: fibers.io upload stress') + +package.path = '../src/?.lua;' .. package.path +package.path = package.path .. ';/usr/lib/lua/?.lua;/usr/lib/lua/?/init.lua' + +if os.getenv('LUA_FIBERS_SKIP_UPLOAD_STRESS') == '1' then + print('test_io-upload_stress.lua: skipped by LUA_FIBERS_SKIP_UPLOAD_STRESS=1') + return +end + +local fibers = require 'fibers' +local socket_mod = require 'fibers.io.socket' +local file_mod = require 'fibers.io.file' +local waitgroup = require 'fibers.waitgroup' +local safe = require 'coxpcall' + +local perform = fibers.perform + +local MB = tonumber(os.getenv('LUA_FIBERS_UPLOAD_STRESS_MB') or '4') +local REPEAT = tonumber(os.getenv('LUA_FIBERS_UPLOAD_STRESS_REPEAT') or '3') +local WRITE_CHUNK = tonumber(os.getenv('LUA_FIBERS_UPLOAD_STRESS_WRITE_CHUNK') or tostring(64 * 1024)) +local READ_CHUNK = tonumber(os.getenv('LUA_FIBERS_UPLOAD_STRESS_READ_CHUNK') or tostring(32 * 1024)) + +assert(MB and MB > 0, 'bad LUA_FIBERS_UPLOAD_STRESS_MB') +assert(REPEAT and REPEAT > 0, 'bad LUA_FIBERS_UPLOAD_STRESS_REPEAT') +assert(WRITE_CHUNK and WRITE_CHUNK > 0, 'bad LUA_FIBERS_UPLOAD_STRESS_WRITE_CHUNK') +assert(READ_CHUNK and READ_CHUNK > 0, 'bad LUA_FIBERS_UPLOAD_STRESS_READ_CHUNK') + +local TOTAL_BYTES = MB * 1024 * 1024 + +local function waiter_slots(os) + local ws = os and os.waiters + return ws and #ws or 0 +end + +local function collect_full() + collectgarbage('collect') + collectgarbage('collect') +end + +local function checked_spawn(scope, wg, errors, fn) + wg:add(1) + scope:spawn(function () + local ok, err = safe.pcall(fn) + if not ok then + errors[#errors + 1] = err + end + wg:done() + end) +end + +local function close_best_effort(obj) + if obj and obj.close then + safe.pcall(function () obj:close() end) + end +end + +local function write_all(stream, data) + local n, err = perform(stream:write_op(data)) + assert(err == nil, 'write failed: ' .. tostring(err)) + assert(n == #data, ('short write: %s of %s'):format(tostring(n), tostring(#data))) +end + +local function run_upload_once(scope, iter) + local tmpdir = os.getenv('TMPDIR') or '/tmp' + local path = string.format('%s/fibers_upload_stress.%d.%d.%d.sock', + tmpdir, os.time(), math.random(1, 1000000), iter) + + local listener, lerr = socket_mod.listen_unix(path, { ephemeral = true }) + assert(listener, 'listen_unix failed: ' .. tostring(lerr)) + + local out, ferr = file_mod.tmpfile() + assert(out, 'tmpfile failed: ' .. tostring(ferr)) + + local wg = waitgroup.new() + local errors = {} + local chunk = string.rep('u', WRITE_CHUNK) + + checked_spawn(scope, wg, errors, function () + local conn, aerr = listener:accept() + assert(conn, 'accept failed: ' .. tostring(aerr)) + + local got = 0 + while true do + local data, cnt, rerr = perform(conn:core_read_op { + min = 1, + max = READ_CHUNK, + eof_ok = true, + }) + assert(rerr == nil, 'read failed: ' .. tostring(rerr)) + if not data or cnt == 0 then + break + end + got = got + cnt + write_all(out, data) + end + + assert(got == TOTAL_BYTES, + ('server received %d bytes, expected %d'):format(got, TOTAL_BYTES)) + + close_best_effort(conn) + close_best_effort(out) + close_best_effort(listener) + end) + + checked_spawn(scope, wg, errors, function () + local client, cerr = socket_mod.connect_unix(path) + assert(client, 'connect_unix failed: ' .. tostring(cerr)) + + local sent = 0 + while sent < TOTAL_BYTES do + local n = math.min(#chunk, TOTAL_BYTES - sent) + if n == #chunk then + write_all(client, chunk) + else + write_all(client, chunk:sub(1, n)) + end + sent = sent + n + end + + close_best_effort(client) + end) + + perform(wg:wait_op()) + + if #errors > 0 then + error(('upload iteration %d failed: %s'):format(iter, tostring(errors[1]))) + end +end + +local function main(scope) + math.randomseed(os.time()) + collect_full() + + local base_cancel = waiter_slots(scope._cancel_os) + local base_fault = waiter_slots(scope._fault_os) + + for i = 1, REPEAT do + run_upload_once(scope, i) + collect_full() + + local cancel_slots = waiter_slots(scope._cancel_os) + local fault_slots = waiter_slots(scope._fault_os) + assert(cancel_slots == base_cancel, + ('scope cancel waiter slots grew after upload %d: base=%d now=%d'): + format(i, base_cancel, cancel_slots)) + assert(fault_slots == base_fault, + ('scope fault waiter slots grew after upload %d: base=%d now=%d'): + format(i, base_fault, fault_slots)) + end +end + +fibers.run(main) + +print(('test_io-upload_stress.lua: %d x %d MiB upload-shaped checks passed'): + format(REPEAT, MB)) diff --git a/tests/test_io_backend_family_child.lua b/tests/test_io_backend_family_child.lua new file mode 100644 index 0000000..3e40b4c --- /dev/null +++ b/tests/test_io_backend_family_child.lua @@ -0,0 +1,41 @@ +-- tests/test_io_backend_family_child.lua +-- +-- Runs the existing I/O tests under one forced backend family. This script is +-- launched by test_io-backend_families.lua in a fresh Lua process so selector +-- state cannot leak between families. + +package.path = '../src/?.lua;' .. package.path +package.path = './?.lua;' .. package.path +package.path = package.path .. ';/usr/lib/lua/?.lua;/usr/lib/lua/?/init.lua' + +local family +for i = 1, #arg do + if arg[i] == '--family' then + family = arg[i + 1] + end +end + +assert(family, 'usage: lua test_io_backend_family_child.lua --family ') + +local gate = require 'io_backend_family' +gate.force(family) +gate.assert_selected(family) + +print(('testing: fibers.io backend family: %s'):format(family)) + +-- These are the existing tests that are materially affected by fd_backend, +-- poller and exec_backend selection. They are intentionally re-run once per +-- family in separate processes. +local tests = { + 'test_io-file.lua', + 'test_io-socket.lua', + 'test_io-upload_stress.lua', + 'test_io-exec_backend.lua', + 'test_io-exec.lua', +} + +for _, file in ipairs(tests) do + dofile(file) +end + +print(('backend family %s: all selected I/O tests passed'):format(family)) diff --git a/tests/test_mailbox.lua b/tests/test_mailbox.lua new file mode 100644 index 0000000..d8ccc1a --- /dev/null +++ b/tests/test_mailbox.lua @@ -0,0 +1,529 @@ +print('testing: fibers.mailbox') + +package.path = '../src/?.lua;' .. package.path + +local fibers = require 'fibers' +local mailbox = require 'fibers.mailbox' +local sleep = require 'fibers.sleep' +local op = require 'fibers.op' +local wg_mod = require 'fibers.waitgroup' + +---------------------------------------------------------------------- +-- helpers +---------------------------------------------------------------------- + +local function assert_eq(a, b, msg) + if a ~= b then + error((msg or 'assert_eq failed') .. (': expected ' .. tostring(b) .. ', got ' .. tostring(a)), 2) + end +end + +local function assert_truthy(v, msg) + if not v then + error(msg or 'assert_truthy failed', 2) + end +end + +local function assert_falsy(v, msg) + if v then + error(msg or 'assert_falsy failed', 2) + end +end + +local function collect_iter(rx) + local out = {} + for v in rx:iter() do + out[#out + 1] = v + end + return out +end + +---------------------------------------------------------------------- +-- tests +---------------------------------------------------------------------- + +local function test_rendezvous_basic() + local tx, rx = mailbox.new(0) + + local wg = wg_mod.new() + wg:add(2) + + local got = {} + + fibers.spawn(function () + -- receiver: read one, then see end + got[1] = rx:recv() + got[2] = rx:recv() + wg:done() + end) + + fibers.spawn(function () + sleep.sleep(0.02) -- ensure receiver blocks first + local ok = tx:send('hello') + assert_eq(ok, true, 'send should succeed') + tx:close('done') + wg:done() + end) + + wg:wait() + + assert_eq(got[1], 'hello', 'receiver should get message') + assert_eq(got[2], nil, 'receiver should see end after close+drain') + assert_eq(rx:why(), 'done', 'receiver should see close reason') +end + +local function test_buffered_fifo_order_and_drain() + local tx, rx = mailbox.new(4) + + local ok1 = tx:send(1) + local ok2 = tx:send(2) + local ok3 = tx:send(3) + assert_eq(ok1, true) + assert_eq(ok2, true) + assert_eq(ok3, true) + + tx:close('finished') + + local xs = collect_iter(rx) + assert_eq(#xs, 3) + assert_eq(xs[1], 1) + assert_eq(xs[2], 2) + assert_eq(xs[3], 3) + + assert_eq(rx:why(), 'finished') +end + +local function test_send_after_close_is_rejected() + local tx, rx = mailbox.new(0) + + tx:close('no more') + + local ok = tx:send('x') + assert_eq(ok, nil, 'send after close should be rejected') + + local v = rx:recv() + assert_eq(v, nil, 'recv on closed+empty should return nil') + assert_eq(rx:why(), 'no more') +end + +local function test_nil_payload_is_error() + local tx, rx = mailbox.new(0) + + local ok, err = pcall(function () + tx:send(nil) + end) + + assert_falsy(ok, 'sending nil should error') + assert_truthy(err ~= nil, 'expected an error object/message') + + -- close so receiver is not left blocking if someone uses it later + tx:close('end') + assert_eq(rx:recv(), nil) +end + +local function test_multi_producer_clone_and_reason_first_non_nil_wins() + local tx, rx = mailbox.new(8) + local tx2 = tx:clone() + + local wg = wg_mod.new() + wg:add(2) + + fibers.spawn(function () + for i = 1, 3 do + assert_eq(tx:send('a' .. i), true) + end + -- close with a reason first + tx:close('r1') + wg:done() + end) + + fibers.spawn(function () + for i = 1, 2 do + assert_eq(tx2:send('b' .. i), true) + end + -- later close with another reason; should not override + sleep.sleep(0.02) + tx2:close('r2') + wg:done() + end) + + wg:wait() + + local got = collect_iter(rx) + assert_eq(#got, 5) + -- Do not assert interleaving between producers, but do assert membership. + local seen = {} + for _, v in ipairs(got) do seen[v] = (seen[v] or 0) + 1 end + assert_eq(seen['a1'], 1) + assert_eq(seen['a2'], 1) + assert_eq(seen['a3'], 1) + assert_eq(seen['b1'], 1) + assert_eq(seen['b2'], 1) + + assert_eq(rx:why(), 'r1', 'first non-nil close reason should win') +end + +local function test_choice_timeout_then_message() + local tx, rx = mailbox.new(0) + + -- First, no message available: should timeout. + local tag = fibers.perform( + op.choice( + rx:recv_op():wrap(function (v) return 'msg', v end), + sleep.sleep_op(0.03):wrap(function () return 'timeout' end) + ) + ) + assert_eq(tag, 'timeout', 'expected timeout when mailbox empty') + + -- Now arrange a message, then race again. + local wg = wg_mod.new() + wg:add(1) + fibers.spawn(function () + sleep.sleep(0.01) + tx:send('ok') + tx:close('done') + wg:done() + end) + + local tag2, v2 = fibers.perform( + op.choice( + rx:recv_op():wrap(function (v) return 'msg', v end), + sleep.sleep_op(0.20):wrap(function () return 'timeout', nil end) + ) + ) + assert_eq(tag2, 'msg') + assert_eq(v2, 'ok') + + -- Drain end-of-stream. + assert_eq(rx:recv(), nil) + assert_eq(rx:why(), 'done') + wg:wait() +end + +local function test_choice_cancels_blocked_send_and_does_not_deliver() + local tx, rx = mailbox.new(0) + + -- Start a choice that would block on send, but we time it out. + local tag = fibers.perform( + op.choice( + tx:send_op('BAD'):wrap(function (ok) return 'sent', ok end), + sleep.sleep_op(0.03):wrap(function () return 'timeout' end) + ) + ) + assert_eq(tag, 'timeout') + + -- Now send a real message with a receiver; must not receive 'BAD'. + local wg = wg_mod.new() + wg:add(2) + + local got + + fibers.spawn(function () + got = rx:recv() + wg:done() + end) + + fibers.spawn(function () + sleep.sleep(0.02) + assert_eq(tx:send('GOOD'), true) + tx:close('end') + wg:done() + end) + + wg:wait() + + assert_eq(got, 'GOOD', 'cancelled send must not be delivered later') + assert_eq(rx:recv(), nil) +end + +local function test_close_wakes_blocked_receiver() + local tx, rx = mailbox.new(0) + + local wg = wg_mod.new() + wg:add(1) + + local got + + fibers.spawn(function () + got = rx:recv() -- should block until close + wg:done() + end) + + sleep.sleep(0.02) + tx:close('closed without messages') + + wg:wait() + + assert_eq(got, nil, 'blocked receiver should wake and see end-of-stream') + assert_eq(rx:why(), 'closed without messages') +end + +local function test_close_wakes_blocked_sender() + local tx, rx = mailbox.new(0) + local tx2 = tx:clone() + + local wg = wg_mod.new() + wg:add(1) + + local send_ok + + fibers.spawn(function () + -- No receiver: this blocks until mailbox closes. + send_ok = tx2:send('will-not-deliver') + wg:done() + end) + + -- Ensure sender is blocked. + sleep.sleep(0.03) + + -- Closing both handles causes mailbox closure; blocked send should return nil. + tx:close('shutdown') + tx2:close('shutdown') + + wg:wait() + + assert_eq(send_ok, nil, 'blocked send should be rejected when mailbox closes') + assert_eq(rx:recv(), nil, 'receiver should see end-of-stream') + assert_eq(rx:why(), 'shutdown') +end + +local function test_clone_after_close_is_inert() + local tx, rx = mailbox.new(0) + tx:close('done') + + local tx2 = tx:clone() + assert_eq(tx2:send('x'), nil, 'clone after close should not send') + assert_eq(rx:recv(), nil) + assert_eq(rx:why(), 'done') +end + +---------------------------------------------------------------------- +-- new tests: full policies +---------------------------------------------------------------------- + +local function test_full_policy_block_still_blocks_when_buffer_full() + local tx, rx = mailbox.new(1, { full = 'block' }) + + assert_eq(tx:send('x'), true) + + local wg = wg_mod.new() + wg:add(1) + + local sent_done = false + local sent_ok + + fibers.spawn(function () + -- Must block until 'x' is received. + sent_ok = tx:send('y') + sent_done = true + -- close-for-send + tx:close('done') + wg:done() + end) + + sleep.sleep(0.03) + assert_falsy(sent_done, 'expected sender to still be blocked when buffer is full under block policy') + + -- Drain one value, unblocking the sender. + assert_eq(rx:recv(), 'x') + wg:wait() + + assert_truthy(sent_done, 'sender should complete once space becomes available') + assert_eq(sent_ok, true, 'blocked send should succeed once unblocked') + + assert_eq(rx:recv(), 'y') + assert_eq(rx:recv(), nil) + assert_eq(rx:why(), 'done') +end + +local function test_full_policy_reject_newest_buffered_drops_and_counts() + local tx, rx = mailbox.new(2, { full = 'reject_newest' }) + + assert_eq(tx:send('a'), true) + assert_eq(tx:send('b'), true) + + -- Now full: these should not block and should be dropped. + assert_eq(tx:send('c'), false) + assert_eq(tx:send('d'), false) + + -- Close and drain. + tx:close('done') + + local xs = collect_iter(rx) + assert_eq(#xs, 2) + assert_eq(xs[1], 'a') + assert_eq(xs[2], 'b') + + -- Drop accounting should reflect 2 discarded sends. + assert_eq(tx:dropped(), 2) + assert_eq(rx:dropped(), 2) + assert_eq(rx:why(), 'done') +end + +local function test_full_policy_drop_oldest_buffered_replaces_oldest_and_counts() + local tx, rx = mailbox.new(2, { full = 'drop_oldest' }) + + assert_eq(tx:send(1), true) + assert_eq(tx:send(2), true) + + -- Full: these should evict 1 then 2, leaving {3,4}. + assert_eq(tx:send(3), true) + assert_eq(tx:send(4), true) + + tx:close('done') + + local xs = collect_iter(rx) + assert_eq(#xs, 2) + assert_eq(xs[1], 3) + assert_eq(xs[2], 4) + + assert_eq(tx:dropped(), 2) + assert_eq(rx:dropped(), 2) + assert_eq(rx:why(), 'done') +end + +local function test_full_policy_reject_newest_rendezvous_drops_without_receiver() + local tx, rx = mailbox.new(0, { full = 'reject_newest' }) + + -- No receiver waiting: should not block, should drop. + assert_eq(tx:send('lost'), false) + + assert_eq(tx:dropped(), 1) + assert_eq(rx:dropped(), 1) + + -- Confirm nothing was delivered: recv should timeout. + local tag = fibers.perform( + op.choice( + rx:recv_op():wrap(function (v) return 'msg', v end), + sleep.sleep_op(0.03):wrap(function () return 'timeout' end) + ) + ) + assert_eq(tag, 'timeout') + + tx:close('done') + assert_eq(rx:recv(), nil) + assert_eq(rx:why(), 'done') +end + +local function test_full_policy_drop_oldest_rendezvous_behaves_like_reject_newest() + local tx, rx = mailbox.new(0, { full = 'drop_oldest' }) + + -- No receiver waiting: should not block, should drop (oldest == newest for rendezvous). + assert_eq(tx:send('lost'), false) + assert_eq(tx:dropped(), 1) + + local tag = fibers.perform( + op.choice( + rx:recv_op():wrap(function (v) return 'msg', v end), + sleep.sleep_op(0.03):wrap(function () return 'timeout' end) + ) + ) + assert_eq(tag, 'timeout') + + tx:close('done') + assert_eq(rx:recv(), nil) + assert_eq(rx:why(), 'done') +end + +local function test_full_policy_drop_does_not_drop_when_receiver_waiting() + local tx, rx = mailbox.new(0, { full = 'reject_newest' }) + + local wg = wg_mod.new() + wg:add(2) + + local got + + fibers.spawn(function () + got = rx:recv() + wg:done() + end) + + fibers.spawn(function () + sleep.sleep(0.02) -- ensure receiver is waiting + assert_eq(tx:send('delivered'), true) + tx:close('done') + wg:done() + end) + + wg:wait() + + assert_eq(got, 'delivered') + assert_eq(tx:dropped(), 0) + assert_eq(rx:dropped(), 0) + assert_eq(rx:recv(), nil) + assert_eq(rx:why(), 'done') +end + + +local function test_losing_choice_send_waiter_is_unlinked() + local tx, rx = mailbox.new(0) + + local tag = fibers.perform(op.choice( + tx:send_op('lost'):wrap(function () return 'sent' end), + sleep.sleep_op(0.01):wrap(function () return 'timeout' end) + )) + assert_eq(tag, 'timeout') + assert_eq(tx._st.putq:length(), 0, 'losing send waiter should be unlinked') + + fibers.spawn(function () tx:send('ok') end) + assert_eq(rx:recv(), 'ok') + tx:close('done') + assert_eq(rx:recv(), nil) +end + +local function test_losing_choice_recv_waiter_is_unlinked() + local tx, rx = mailbox.new(0) + + local tag = fibers.perform(op.choice( + rx:recv_op():wrap(function () return 'got' end), + sleep.sleep_op(0.01):wrap(function () return 'timeout' end) + )) + assert_eq(tag, 'timeout') + assert_eq(rx._st.getq:length(), 0, 'losing receive waiter should be unlinked') + + fibers.spawn(function () tx:send('ok') end) + assert_eq(rx:recv(), 'ok') + tx:close('done') + assert_eq(rx:recv(), nil) +end + +local function test_on_message_unlink_removes_task_waiter() + local _, rx = mailbox.new(0) + local task = { run = function () end } + local waker = { wakeup = function () end } + local token = rx:on_message(task, waker) + assert_eq(rx._st.taskq:length(), 1) + assert_eq(token:unlink(), true) + assert_eq(rx._st.taskq:length(), 0) + assert_eq(token:unlink(), false) +end + +---------------------------------------------------------------------- +-- main +---------------------------------------------------------------------- + +local function main() + test_rendezvous_basic() + test_buffered_fifo_order_and_drain() + test_send_after_close_is_rejected() + test_nil_payload_is_error() + test_multi_producer_clone_and_reason_first_non_nil_wins() + test_choice_timeout_then_message() + test_choice_cancels_blocked_send_and_does_not_deliver() + test_close_wakes_blocked_receiver() + test_close_wakes_blocked_sender() + test_clone_after_close_is_inert() + test_full_policy_block_still_blocks_when_buffer_full() + test_full_policy_reject_newest_buffered_drops_and_counts() + test_full_policy_drop_oldest_buffered_replaces_oldest_and_counts() + test_full_policy_reject_newest_rendezvous_drops_without_receiver() + test_full_policy_drop_oldest_rendezvous_behaves_like_reject_newest() + test_full_policy_drop_does_not_drop_when_receiver_waiting() + test_losing_choice_send_waiter_is_unlinked() + test_losing_choice_recv_waiter_is_unlinked() + test_on_message_unlink_removes_task_waiter() + + print('All mailbox tests passed!') +end + +fibers.run(main) diff --git a/tests/test_oneshot.lua b/tests/test_oneshot.lua new file mode 100644 index 0000000..af8efa9 --- /dev/null +++ b/tests/test_oneshot.lua @@ -0,0 +1,234 @@ +--- Tests the Oneshot implementation. +print('testing: fibers.oneshot') + +-- look one level up +package.path = '../src/?.lua;' .. package.path + +local oneshot = require 'fibers.oneshot' + +local fibers = require 'fibers' +local op = require 'fibers.op' +local cond = require 'fibers.cond' +local runtime = require 'fibers.runtime' +local safe = require 'coxpcall' + +local function count_live_waiters(os) + -- Patched oneshot: waiters are records { fn = function|nil }. + -- Older oneshot (pre-patch): waiters were bare functions. + local ws = os.waiters or {} + local n = 0 + for i = 1, #ws do + local v = ws[i] + if type(v) == 'function' then + n = n + 1 + elseif type(v) == 'table' and type(v.fn) == 'function' then + n = n + 1 + end + end + return n +end + +local function assert_equal(actual, expected, msg) + if actual ~= expected then + error((msg or 'assert_equal failed') .. + (': expected %s, got %s'):format(tostring(expected), tostring(actual)), 2) + end +end + +local function assert_true(v, msg) + if not v then error(msg or 'assert_true failed', 2) end +end + +local function assert_false(v, msg) + if v then error(msg or 'assert_false failed', 2) end +end + +local function run_test(name, fn) + local ok, err = safe.pcall(fn) + if ok then + print(name .. ': ok') + return + end + error(name .. ': FAILED\n' .. tostring(err), 0) +end + +local function test_waiters_run_on_signal_and_clear() + local os = oneshot.new() + local log = {} + + os:add_waiter(function () log[#log + 1] = 'w1' end) + os:add_waiter(function () log[#log + 1] = 'w2' end) + + assert_equal(#log, 0, 'waiters should not run before signal') + assert_false(os:is_triggered(), 'should not be triggered before signal') + + os:signal() + + assert_true(os:is_triggered(), 'should be triggered after signal') + assert_equal(#log, 2, 'expected two waiter runs') + assert_equal(log[1], 'w1') + assert_equal(log[2], 'w2') + assert_equal(count_live_waiters(os), 0, 'no live waiters should remain after signal') +end + +local function test_add_waiter_after_signal_runs_immediately() + local os = oneshot.new() + os:signal() + + local ran = false + os:add_waiter(function () ran = true end) + + assert_true(ran, 'waiter should run immediately after signal') + assert_equal(count_live_waiters(os), 0, 'no live waiters should be stored after signal') +end + +local function test_signal_is_idempotent() + local os = oneshot.new() + local n = 0 + + os:add_waiter(function () n = n + 1 end) + + os:signal() + os:signal() + os:signal() + + assert_equal(n, 1, 'waiter must run only once') +end + +local function test_on_after_signal_runs_after_waiters() + local log = {} + local os = oneshot.new(function () + log[#log + 1] = 'after' + end) + + os:add_waiter(function () log[#log + 1] = 'w1' end) + os:add_waiter(function () log[#log + 1] = 'w2' end) + + os:signal() + + assert_equal(#log, 3) + assert_equal(log[1], 'w1') + assert_equal(log[2], 'w2') + assert_equal(log[3], 'after') +end + +local function test_add_waiter_returns_canceller_and_cancel_prevents_run() + local os = oneshot.new() + local ran = false + + local cancel = os:add_waiter(function () ran = true end) + assert_true(type(cancel) == 'function', 'expected add_waiter to return a canceller function') + + -- idempotent + cancel() + cancel() + + os:signal() + + assert_false(ran, 'cancelled waiter must not run on signal') + assert_equal(count_live_waiters(os), 0, 'no live waiters should remain after signal') +end + + +local function test_cancelled_waiters_are_unlinked() + -- Regression for long-lived scopes: losing choice arms cancel many one-shot + -- waiters. It is not enough to clear rec.fn; the records themselves must + -- be unlinked so the waiter array does not grow for the lifetime of the + -- scope's cancellation/fault one-shots. + local os = oneshot.new() + local cancels = {} + for i = 1, 1000 do + cancels[i] = os:add_waiter(function () end) + end + assert_equal(#os.waiters, 1000, 'expected waiters to be registered') + for i = 1, #cancels do + cancels[i]() + end + assert_equal(#os.waiters, 0, 'cancelled waiters must be physically removed') + assert_equal(count_live_waiters(os), 0, 'cancelled waiters must not remain live') +end + +local function test_add_waiter_after_signal_returns_noop_canceller() + local os = oneshot.new() + os:signal() + + local ran = false + local cancel = os:add_waiter(function () ran = true end) + + assert_true(ran, 'waiter should run immediately') + assert_true(type(cancel) == 'function', 'expected a canceller function') + cancel() + cancel() +end + +local function test_reentrant_add_waiter_during_signal() + local os = oneshot.new() + local log = {} + + os:add_waiter(function () + log[#log + 1] = 'a' + os:add_waiter(function () + log[#log + 1] = 'b' + end) + end) + + os:signal() + + assert_equal(#log, 2) + assert_equal(log[1], 'a') + assert_equal(log[2], 'b') +end + +local function test_integration_choice_cleans_losing_cond_waiter() + -- This asserts the specific regression you were targeting: if a wait is abandoned, + -- its waiter closure should not remain referenced by the oneshot. + local c1 = cond.new() + local c2 = cond.new() + + local chosen = nil + + fibers.spawn(function () + chosen = fibers.perform(op.choice( + c1:wait_op():wrap(function () return 'c1' end), + c2:wait_op():wrap(function () return 'c2' end) + )) + end) + + -- Allow the spawned fiber to run and block, registering both waiters. + runtime.yield() + + assert_equal(count_live_waiters(c1._os), 1, 'expected c1 to have 1 live waiter while blocked') + assert_equal(count_live_waiters(c2._os), 1, 'expected c2 to have 1 live waiter while blocked') + + -- Complete one arm. + c1:signal() + + -- Allow completion to propagate. + runtime.yield() + + assert_equal(chosen, 'c1', 'expected choice winner to be c1') + + -- Winner and loser should not retain live waiter closures after completion. + assert_equal(count_live_waiters(c1._os), 0, 'expected c1 to have 0 live waiters after signal') + assert_equal(count_live_waiters(c2._os), 0, 'expected c2 to have 0 live waiters after losing the choice') +end + +local function main() + local tests = { + { 'Basic signal behaviour', test_waiters_run_on_signal_and_clear }, + { 'add_waiter after signal runs immediately', test_add_waiter_after_signal_runs_immediately }, + { 'signal idempotence', test_signal_is_idempotent }, + { 'on_after_signal ordering', test_on_after_signal_runs_after_waiters }, + { 'canceller prevents run', test_add_waiter_returns_canceller_and_cancel_prevents_run }, + { 'cancelled waiters are unlinked', test_cancelled_waiters_are_unlinked }, + { 'noop canceller after signal', test_add_waiter_after_signal_returns_noop_canceller }, + { 're-entrant add_waiter during signal', test_reentrant_add_waiter_during_signal }, + { 'integration: choice cleans losing waiter', test_integration_choice_cleans_losing_cond_waiter }, + } + + for _, t in ipairs(tests) do + run_test(t[1], t[2]) + end +end + +fibers.run(main) diff --git a/tests/test_op.lua b/tests/test_op.lua index b09f260..061d10e 100644 --- a/tests/test_op.lua +++ b/tests/test_op.lua @@ -1,45 +1,550 @@ ---- Tests the Op implementation. +-- fibers/op compact but comprehensive test (no poll) print('testing: fibers.op') -- look one level up -package.path = "../?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path -local op = require 'fibers.op' -local fiber = require 'fibers.fiber' +local op = require 'fibers.op' +local runtime = require 'fibers.runtime' -local function task(val) - local wrap_fn = function(x) return x end - local try_fn = function() return true, val end - local block_fn = function() end - return op.new_base_op(wrap_fn, try_fn, block_fn) +local perform = require 'fibers.performer'.perform +local choice = op.choice +local always = op.always +local never = op.never + +------------------------------------------------------------ +-- Helpers +------------------------------------------------------------ + +local function async_task(val) + local tries = 0 + + local function try_fn() + tries = tries + 1 + return false + end + + local function block_fn(suspension, wrap_fn) + local t = suspension:complete_task(wrap_fn, val) + suspension.sched:schedule(t) + end + + local ev = op.new_primitive(nil, try_fn, block_fn) + return ev, function () return tries end end --- Test base op -fiber.spawn(function() - local baseOp = task(1) - assert(baseOp:perform() == 1, "Base operation failed") - fiber.stop() -end) -fiber.main() +local function assert_error(label, fn) + local ok = pcall(fn) + assert(ok == false, label .. ': expected error') +end --- Test choice op -fiber.spawn(function() - local choiceOp = op.choice(task(1), task(2), task(3)) - assert(choiceOp:perform() >= 1 and choiceOp:perform() <= 3, "Choice operation failed") - fiber.stop() -end) -fiber.main() +------------------------------------------------------------ +-- Run all tests inside a single top-level fibre +------------------------------------------------------------ + +runtime.spawn_raw(function () + + -------------------------------------------------------- + -- 1) Base op: perform, or_else, wrap + -------------------------------------------------------- + do + local base = always(1) + assert(perform(base) == 1, 'base: perform failed') + + local ev1 = always(2):or_else(function () return 9 end) + assert(perform(ev1) == 2, 'base: or_else should use op result') + + local ev2 = never():or_else(function () return 99 end) + assert(perform(ev2) == 99, + "base: or_else should use fallback when op can't commit") + + do + local fallback_called = false + local ev_async, tries = async_task(123) + + local ev = ev_async:or_else(function () + fallback_called = true + return -1 + end) + + assert(perform(ev) == -1, + 'or_else(async): expected fallback to win') + assert(tries() == 1, + 'async_task: try_fn should be called exactly once') + assert(fallback_called == true, + 'or_else(async): fallback should run when op is not ready') + end + + local ev3 = always(5) + :wrap(function (x) return x + 1 end) + :wrap(function (y) return y * 2 end) + + assert(perform(ev3) == 12, 'nested wrap: wrong result') + end + + -------------------------------------------------------- + -- 2) Blocking path: async_task + -------------------------------------------------------- + do + local ev, tries = async_task(42) + assert(perform(ev) == 42, 'async_task: wrong result') + assert(tries() == 1, 'async_task: try_fn not called exactly once') + end + + -------------------------------------------------------- + -- 3) Choice: varargs, tables, empty choices, flattening + -------------------------------------------------------- + do + local choice_ev = choice(always(1), always(2), always(3)) + + for _ = 1, 5 do + local v = perform(choice_ev) + assert(v == 1 or v == 2 or v == 3, + 'choice(ready varargs): result not in {1,2,3}') + end + + local table_ev = choice({ always('a'), always('b') }) + do + local v = perform(table_ev) + assert(v == 'a' or v == 'b', + 'choice(table): result not in {a,b}') + end + + local mixed_ev = choice( + never(), + { always('x'), never() }, + { { always('y') } } + ) + + do + local v = perform(mixed_ev) + assert(v == 'x' or v == 'y', + 'choice(mixed vararg/table): wrong result') + end + + local nested_ev = choice( + choice(always('inner-a'), always('inner-b')), + always('outer') + ) + + do + local v = perform(nested_ev) + assert( + v == 'inner-a' or v == 'inner-b' or v == 'outer', + 'choice(nested flatten): wrong result' + ) + end + + local empty1 = choice():or_else(function () return 'empty-varargs' end) + assert(perform(empty1) == 'empty-varargs', + 'choice(): should behave as never-ready') + + local empty2 = choice({}):or_else(function () return 'empty-table' end) + assert(perform(empty2) == 'empty-table', + 'choice({}): should behave as never-ready') + + local empty_nested = choice(choice(), always('win')) + assert(perform(empty_nested) == 'win', + 'choice(empty choice, ready op): empty nested choice should disappear') + + local wrapped = choice(always(1), always(2)):wrap(function (x) + return x * 10 + end) + + do + local v = perform(wrapped) + assert(v == 10 or v == 20, 'wrap(choice): wrong result') + end + end + + -------------------------------------------------------- + -- 4) Choice validation regressions + -------------------------------------------------------- + do + assert_error('choice(non-op)', function () + choice(123) + end) + + assert_error('choice(nil)', function () + choice(nil) + end) + + assert_error('choice(named table)', function () + choice({ foo = always(1) }) + end) + + assert_error('choice(sparse table)', function () + choice({ + [1] = always(1), + [3] = always(3), + }) + end) + + assert_error('choice(table with non-op)', function () + choice({ always(1), 'bad' }) + end) + end + + -------------------------------------------------------- + -- 5) Guard: basic + in choice + with with_nack + -------------------------------------------------------- + do + local calls = 0 + + local ev = op.guard(function () + calls = calls + 1 + return always(42) + end) + + assert(perform(ev) == 42, 'guard basic: wrong result') + assert(calls == 1, 'guard basic: builder not called once') + + local calls2 = 0 + local guarded = op.guard(function () + calls2 = calls2 + 1 + return always(10) + end) + + local choice_ev = choice(guarded, always(20)) + local runs = 5 + + for _ = 1, runs do + local r = perform(choice_ev) + assert(r == 10 or r == 20, + 'guard in choice: result not in {10,20}') + end + + assert(calls2 == runs, 'guard in choice: builder call mismatch') + + local guard_calls, cancelled = 0, false + + local guarded_nack = op.guard(function () + guard_calls = guard_calls + 1 + + return op.with_nack(function (nack_ev) + runtime.spawn_raw(function () + perform(nack_ev) + cancelled = true + end) + + return never() + end) + end) + + local ev2 = choice(guarded_nack, always('OK')) + assert(perform(ev2) == 'OK', 'guard+with_nack: wrong winner') + + runtime.yield() + assert(cancelled == true, 'guard+with_nack: nack not fired') + assert(guard_calls == 1, 'guard+with_nack: builder not once') + end + + -------------------------------------------------------- + -- 6) or_else on composite ops + -------------------------------------------------------- + do + local comp_ready = choice(always(1), always(2)) + local r1 = perform(comp_ready:or_else(function () return 99 end)) + + assert(r1 == 1 or r1 == 2, + 'or_else(composite ready): wrong result') + + local comp_never = choice(never(), never()) + local r2 = perform(comp_never:or_else(function () return 42 end)) + + assert(r2 == 42, + 'or_else(composite none): fallback not used') + + local empty = choice():or_else(function () return 'fallback' end) + assert(perform(empty) == 'fallback', + 'or_else(empty choice): fallback not used') + + local table_empty = choice({}):or_else(function () return 'fallback2' end) + assert(perform(table_empty) == 'fallback2', + 'or_else(empty table choice): fallback not used') + end + + -------------------------------------------------------- + -- 7) Higher-level choice helpers over empty sets + -------------------------------------------------------- + do + local named_empty = op.named_choice({}):or_else(function () + return 'named-empty' + end) + + assert(perform(named_empty) == 'named-empty', + 'named_choice({}): should behave as never-ready') + + local race_empty = op.race({}, function () + return 'unreachable' + end):or_else(function () + return 'race-empty' + end) + + assert(perform(race_empty) == 'race-empty', + 'race({}): should behave as never-ready') + + local first_empty = op.first_ready({}):or_else(function () + return 'first-empty' + end) + + assert(perform(first_empty) == 'first-empty', + 'first_ready({}): should behave as never-ready') + end + + -------------------------------------------------------- + -- 8) with_nack: winner vs loser, basic nesting + -------------------------------------------------------- + do + do + local cancelled = false + + local with_nack_ev = op.with_nack(function (nack_ev) + runtime.spawn_raw(function () + perform(nack_ev) + cancelled = true + end) + + return always('WIN') + end) + + assert(perform(choice(with_nack_ev, never())) == 'WIN', + 'with_nack win: wrong winner') + + runtime.yield() + assert(cancelled == false, + 'with_nack win: nack fired unexpectedly') + end + + do + local cancelled = false + + local with_nack_ev = op.with_nack(function (nack_ev) + runtime.spawn_raw(function () + perform(nack_ev) + cancelled = true + end) + + return never() + end) + + assert(perform(choice(with_nack_ev, always('OTHER'))) == 'OTHER', + 'with_nack loss: wrong winner') + + runtime.yield() + assert(cancelled == true, + 'with_nack loss: nack did not fire') + end + + do + local outer_cancelled, inner_cancelled = false, false + + local outer = op.with_nack(function (outer_nack_ev) + runtime.spawn_raw(function () + perform(outer_nack_ev) + outer_cancelled = true + end) + + return op.with_nack(function (inner_nack_ev) + runtime.spawn_raw(function () + perform(inner_nack_ev) + inner_cancelled = true + end) + + return always('INNER_WIN') + end) + end) + + assert(perform(choice(outer, never())) == 'INNER_WIN', + 'nested with_nack: wrong result') + + runtime.yield() + + assert(outer_cancelled == false, + 'nested with_nack: outer nack fired unexpectedly') + assert(inner_cancelled == false, + 'nested with_nack: inner nack fired unexpectedly') + end + end + + -------------------------------------------------------- + -- 9) on_abort and empty-choice regression + -------------------------------------------------------- + do + local aborted = false + + local losing = choice():on_abort(function () + aborted = true + end) + + assert(perform(choice(losing, always('winner'))) == 'winner', + 'on_abort(empty choice): wrong winner') + + assert(aborted == true, + 'on_abort(empty choice): abort handler should run when losing') + end + + -------------------------------------------------------- + -- 10) bracket: RAII-style resource management over ops + -------------------------------------------------------- + do + do + local acq_count = 0 + local rel_count = 0 + local use_count = 0 + local last_res, last_aborted + + local ev = op.bracket( + function () + acq_count = acq_count + 1 + return 'RESOURCE' + end, + + function (res, aborted) + rel_count = rel_count + 1 + last_res = res + last_aborted = aborted + end, + + function (res) + use_count = use_count + 1 + assert(res == 'RESOURCE', + 'bracket basic: wrong resource') + return always(99) + end + ) + + assert(perform(ev) == 99, 'bracket basic: wrong result') + assert(acq_count == 1, 'bracket basic: acquire not once') + assert(use_count == 1, 'bracket basic: use not once') + assert(rel_count == 1, 'bracket basic: release not once') + assert(last_res == 'RESOURCE', + 'bracket basic: wrong res in release') + assert(last_aborted == false, + 'bracket basic: aborted flag should be false on success') + end + + do + local acq_count, use_count, rel_count = 0, 0, 0 + local last_aborted + + local bracket_ev = op.bracket( + function () + acq_count = acq_count + 1 + return 'R' + end, + + function (_, aborted) + rel_count = rel_count + 1 + last_aborted = aborted + end, + + function (r) + use_count = use_count + 1 + assert(r == 'R') + return never() + end + ) + + assert(perform(choice(bracket_ev, always('WIN'))) == 'WIN', + 'bracket choice: wrong winner') + + assert(acq_count == 1, 'bracket choice: acquire not once') + assert(use_count == 1, 'bracket choice: use not once') + assert(rel_count == 1, 'bracket choice: release not once') + assert(last_aborted == true, + 'bracket choice: aborted flag should be true when losing') + end + + do + local acq_count, use_count, rel_count = 0, 0, 0 + local last_aborted, fallback_called + + local bracket_ev = op.bracket( + function () + acq_count = acq_count + 1 + return 'R' + end, + + function (_, aborted) + rel_count = rel_count + 1 + last_aborted = aborted + end, + + function (r) + use_count = use_count + 1 + assert(r == 'R') + return never() + end + ) + + local ev = bracket_ev:or_else(function () + fallback_called = true + return 'FALLBACK' + end) + + assert(perform(ev) == 'FALLBACK', + 'bracket+or_else: expected fallback result') + assert(fallback_called == true, + 'bracket+or_else: fallback thunk not called') + assert(acq_count == 1, 'bracket+or_else: acquire once') + assert(use_count == 1, 'bracket+or_else: use once') + assert(rel_count == 1, 'bracket+or_else: release once') + assert(last_aborted == true, + 'bracket+or_else: aborted flag should be true when losing') + end + end + + -------------------------------------------------------- + -- 11) finally: cleanup on success and on abort + -------------------------------------------------------- + do + do + local calls = {} + + local ev = always(7):finally(function (aborted) + calls[#calls + 1] = aborted + end) + + assert(perform(ev) == 7, 'finally(success): wrong result') + assert(#calls == 1, 'finally(success): cleanup not called once') + assert(calls[1] == false, + 'finally(success): aborted should be false') + end + + do + local calls = {} + + local base = never():finally(function (aborted) + calls[#calls + 1] = aborted + end) + + assert(perform(choice(base, always('WIN'))) == 'WIN', + 'finally(abort): wrong winner') + assert(#calls == 1, + 'finally(abort): cleanup not called once') + assert(calls[1] == true, + 'finally(abort): aborted should be true') + end + + do + local calls = {} + + local base = choice():finally(function (aborted) + calls[#calls + 1] = aborted + end) --- Test perform_alt -fiber.spawn(function() - local baseOp = task(1) - assert(baseOp:perform_alt(function() return 2 end) == 1, "perform_alt operation failed") + assert(perform(choice(base, always('WIN'))) == 'WIN', + 'finally(empty choice abort): wrong winner') + assert(#calls == 1, + 'finally(empty choice abort): cleanup not called once') + assert(calls[1] == true, + 'finally(empty choice abort): aborted should be true') + end + end - local choiceOp = op.choice(task(1), task(2), task(3)) - assert(choiceOp:perform_alt(function() return 4 end) >= 1 and choiceOp:perform_alt(function() return 4 end) <= 3, - "Choice operation perform_alt failed") - fiber.stop() + print('fibers.op tests: ok') + runtime.stop() end) -fiber.main() -print('test: ok') +runtime.main() diff --git a/tests/test_pollio.lua b/tests/test_pollio.lua deleted file mode 100644 index 4e1ac66..0000000 --- a/tests/test_pollio.lua +++ /dev/null @@ -1,44 +0,0 @@ ---- Tests the File implementation. -print('testing: fibers.fd') - --- look one level up -package.path = "../?.lua;" .. package.path - -local pollio = require 'fibers.pollio' -local file_stream = require 'fibers.stream.file' -local fiber = require 'fibers.fiber' - -local equal = require 'fibers.utils.helper'.equal -local log = {} -local function record(x) table.insert(log, x) end - --- local handler = new_poll_io_handler() --- file.set_blocking_handler(handler) --- fiber.current_scheduler:add_task_source(handler) -pollio.install_poll_io_handler() - -fiber.current_scheduler:run() -assert(equal(log, {})) - -local rd, wr = file_stream.pipe() -local message = "hello, world\n" -fiber.spawn(function() - record('rd-a') - local str = rd:read_some_chars() - record('rd-b') - record(str) -end) -fiber.spawn(function() - record('wr-a') - wr:write(message) - record('wr-b') - wr:flush() - record('wr-c') -end) - -fiber.current_scheduler:run() -assert(equal(log, { 'rd-a', 'wr-a', 'wr-b', 'wr-c' })) -fiber.current_scheduler:run() -assert(equal(log, { 'rd-a', 'wr-a', 'wr-b', 'wr-c', 'rd-b', message })) - -print('test: ok') diff --git a/tests/test_pulse.lua b/tests/test_pulse.lua new file mode 100644 index 0000000..f0d482b --- /dev/null +++ b/tests/test_pulse.lua @@ -0,0 +1,107 @@ +-- fibers/pulse - basic behavioural tests +print('testing: fibers.op') + +-- look one level up +package.path = '../src/?.lua;' .. package.path + +local fibers = require "fibers" +local pulse = require "fibers.pulse" +local mailbox = require "fibers.mailbox" +local sleep = require "fibers.sleep" + +local function assert_eq(a, b, msg) + if a ~= b then + error((msg or "assert_eq failed") .. (": got " .. tostring(a) .. ", want " .. tostring(b)), 2) + end +end + +local function assert_true(v, msg) + if not v then + error(msg or "assert_true failed", 2) + end +end + +fibers.run(function (_scope) + local p = pulse.new() + assert_eq(p:version(), 0, "initial version") + + local tx, rx = mailbox.new(16) -- block policy is fine for a test harness + + -------------------------------------------------------------------------- + -- 1) changed_op blocks and then coalesces to latest version + -------------------------------------------------------------------------- + + fibers.spawn(function () + local v, why = fibers.perform(p:changed_op(0)) + assert_true(tx:send({ tag = "w1", v = v, why = why }) == true, "send w1") + end) + + fibers.spawn(function () + local v0 = p:version() + local v, why = fibers.perform(p:changed_op(v0)) + assert_true(tx:send({ tag = "w2", v = v, why = why }) == true, "send w2") + end) + + -- Give the waiters a chance to start and block. + sleep.sleep(0.001) + + -- Signal twice without yielding; waiters should observe the latest snapshot. + assert_eq(p:signal(), 1, "signal -> 1") + assert_eq(p:signal(), 2, "signal -> 2") + + local got = {} + for _ = 1, 2 do + local r = rx:recv() + assert_true(r ~= nil, "expected waiter result") + got[r.tag] = r + end + + assert_eq(got.w1.v, 2, "w1 coalesces to latest version") + assert_eq(got.w1.why, nil, "w1 no close reason") + assert_eq(got.w2.v, 2, "w2 coalesces to latest version") + assert_eq(got.w2.why, nil, "w2 no close reason") + + -------------------------------------------------------------------------- + -- 2) next_op waits from 'now' + -------------------------------------------------------------------------- + + fibers.spawn(function () + local v, why = fibers.perform(p:next_op()) + assert_true(tx:send({ tag = "w3", v = v, why = why }) == true, "send w3") + end) + + sleep.sleep(0.001) + assert_eq(p:signal(), 3, "signal -> 3") + + local w3 = rx:recv() + assert_true(w3 and w3.tag == "w3", "expected w3") + assert_eq(w3.v, 3, "w3 sees next version") + assert_eq(w3.why, nil, "w3 no close reason") + + -------------------------------------------------------------------------- + -- 3) close wakes waiters; further signal is ignored; changed_op completes to closed + -------------------------------------------------------------------------- + + fibers.spawn(function () + local v, why = fibers.perform(p:next_op()) + assert_true(tx:send({ tag = "w4", v = v, why = why }) == true, "send w4") + end) + + sleep.sleep(0.001) + p:close("done") + + local w4 = rx:recv() + assert_true(w4 and w4.tag == "w4", "expected w4") + assert_eq(w4.v, nil, "w4 sees closed") + assert_eq(w4.why, "done", "w4 close reason propagated") + + assert_eq(p:signal(), nil, "signal after close returns nil") + + local v5, why5 = fibers.perform(p:changed_op(p:version())) + assert_eq(v5, nil, "changed_op after close returns nil") + assert_eq(why5, "done", "changed_op after close returns close reason") + + tx:close() + + io.write("test_pulse.lua: ok\n") +end) diff --git a/tests/test_queue.lua b/tests/test_queue.lua deleted file mode 100644 index b0822ac..0000000 --- a/tests/test_queue.lua +++ /dev/null @@ -1,40 +0,0 @@ ---- Tests the Queue implementation. -print('testing: fibers.queue') - --- look one level up -package.path = "../?.lua;" .. package.path - -local queue = require 'fibers.queue' -local fiber = require 'fibers.fiber' -local helper = require 'fibers.utils.helper' -local equal = helper.equal - -local log = {} -local function record(x) table.insert(log, x) end - -fiber.spawn(function() - local q = queue.new() - record('a') - q:put('b') - record('c') - q:put('d') - record('e') - record(q:get()) - q:put('f') - record('g') - record(q:get()) - record('h') - record(q:get()) -end) - -local function run(...) - log = {} - fiber.current_scheduler:run() - assert(equal(log, { ... })) -end - --- With the new buffered channel implementation, the behavior is direct: -run('a', 'c', 'e', 'b', 'g', 'd', 'h', 'f') -for _ = 1, 20 do run() end - -print('test: ok') diff --git a/tests/test_runtime.lua b/tests/test_runtime.lua new file mode 100644 index 0000000..be2f17f --- /dev/null +++ b/tests/test_runtime.lua @@ -0,0 +1,67 @@ +--- Tests the Fiber implementation. +print('testing: fibers.fiber') + +-- look one level up +package.path = '../src/?.lua;' .. package.path + +local runtime = require 'fibers.runtime' +local time = require 'fibers.utils.time' + +local function equal(x, y) + if type(x) ~= type(y) then return false end + if type(x) == 'table' then + for k, v in pairs(x) do + if not equal(v, y[k]) then return false end + end + for k, _ in pairs(y) do + if x[k] == nil then return false end + end + return true + else + return x == y + end +end + +local log = {} +local function record(x) table.insert(log, x) end + +runtime.spawn_raw(function () + record('a'); runtime.yield(); record('b'); runtime.yield(); record('c') +end) + +assert(equal(log, {})) +runtime.current_scheduler:run() +assert(equal(log, { 'a' })) +runtime.current_scheduler:run() +assert(equal(log, { 'a', 'b' })) +runtime.current_scheduler:run() +assert(equal(log, { 'a', 'b', 'c' })) +runtime.current_scheduler:run() +assert(equal(log, { 'a', 'b', 'c' })) + +-- Test performance +local start_time = time.monotonic() +local fiber_count = 1e4 +local count = 0 +local function inc() + count = count + 1 +end +for _ = 1, fiber_count do + runtime.spawn_raw(function () + inc(); runtime.yield(); inc(); runtime.yield(); inc() + end) +end + +local end_time = time.monotonic() +print('Fiber creation time: ' .. (end_time - start_time) / fiber_count) + +start_time = time.monotonic() +for _ = 1, 3 * fiber_count do -- run fibers, each fiber yields 3 times + runtime.current_scheduler:run() +end +end_time = time.monotonic() +print('Fiber operation time: ' .. (end_time - start_time) / (2 * 3 * fiber_count)) + +assert(count == 3 * fiber_count) + +print('test: ok') diff --git a/tests/test_sched.lua b/tests/test_sched.lua index 4ec7990..802d2a7 100644 --- a/tests/test_sched.lua +++ b/tests/test_sched.lua @@ -1,50 +1,50 @@ --- Tests the Scheduler implementation. -print("test: fibers.sched") +print('test: fibers.sched') -- look one level up -package.path = "../?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path local sched = require 'fibers.sched' -local sc = require 'fibers.utils.syscall' +local time = require 'fibers.utils.time' -- Measure initialization time -local start_time = sc.monotime() +local start_time = time.monotonic() local scheduler = sched.new() -local end_time = sc.monotime() -print("Scheduler initialization time: " .. (end_time - start_time)) +local end_time = time.monotonic() +print('Scheduler initialization time: ' .. (end_time - start_time)) local count = 0 local totalImprecision = 0 local function task_run(task) - local now = scheduler:now() - local t = task.scheduled - count = count + 1 - local imprecision = math.abs(t - now) - totalImprecision = totalImprecision + imprecision + local now = scheduler:now() + local t = task.scheduled + count = count + 1 + local imprecision = math.abs(t - now) + totalImprecision = totalImprecision + imprecision end local event_count = 1e4 local t = scheduler:now() -- Measure task scheduling time -start_time = sc.monotime() -for _=1,event_count do - local dt = math.random()/1e2 - t = t + dt - scheduler:schedule_at_time(t, {run=task_run, scheduled=t}) +start_time = time.monotonic() +for _ = 1, event_count do + local dt = math.random() / 1e2 + t = t + dt + scheduler:schedule_at_time(t, { run = task_run, scheduled = t }) end -end_time = sc.monotime() -print("Task scheduling time: " .. (end_time - start_time)/event_count) +end_time = time.monotonic() +print('Task scheduling time: ' .. (end_time - start_time) / event_count) while count < event_count do - local nextEventTime = scheduler:next_wake_time() - scheduler:run(nextEventTime) + local nextEventTime = scheduler:next_wake_time() + scheduler:run(nextEventTime) end assert(count == event_count) local averageImprecision = totalImprecision / event_count -print("Average imprecision: " .. averageImprecision) +print('Average imprecision: ' .. averageImprecision) -print("test: ok") \ No newline at end of file +print('test: ok') diff --git a/tests/test_scope.lua b/tests/test_scope.lua new file mode 100644 index 0000000..bcb3777 --- /dev/null +++ b/tests/test_scope.lua @@ -0,0 +1,739 @@ +--- 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') + +package.path = '../src/?.lua;' .. package.path + +local runtime = require 'fibers.runtime' +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' + +-- 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 +------------------------------------------------------------------------------- + +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 + +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() behaviour (policy test; keep minimal) +------------------------------------------------------------------------------- + +local function test_outside_fiber_current_is_root() + local r = scope_mod.root() + assert(scope_mod.current() == r, 'outside fibers, current() should be root scope') +end + +------------------------------------------------------------------------------- +-- 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 fiber should see current() == root') + + local outer_ref, inner_ref + + 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, rep2, a, b, c = scope_mod.run(function (s2) + inner_ref = s2 + assert(scope_mod.current() == s2, 'inside nested run, current() should be nested child') + return 1, 2, 3 + end) + + 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 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 run returns, current() should restore to root') + + 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. + 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 and join ordering (attachment order) +------------------------------------------------------------------------------- + +local function test_structural_lifetime_children_joined_in_order() + local child1_ref, child2_ref + local child1_done, child2_done = false, false + + 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 + + local c2, err2 = s:child() + assert(c2, 'expected child2, got err: ' .. tostring(err2)) + child2_ref = c2 + + local ok1 = c1:spawn(function () + runtime.yield() + child1_done = true + end) + assert(ok1, 'child1:spawn should be admitted') + + local ok2 = c2:spawn(function () + runtime.yield() + child2_done = true + end) + assert(ok2, 'child2:spawn should be admitted') + + return 'parent-ok' + end) + + assert(st == 'ok', 'parent scope should be ok') + 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 wait for attached children work') + + -- 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' and rep.children[2].status == 'ok', 'children should end ok') + + -- 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() rejects spawn/child; close_op is idempotent +------------------------------------------------------------------------------- + +local function test_admission_close_and_close_op_idempotent() + local st, rep, body_val = scope_mod.run(function (s) + local ast = s:admission() + assert(ast == 'open', 'new scope admission should be open') + + s:close() + s:close('closed-later') -- policy: allow setting reason after close if none set + + local ast2, areason2 = s:admission() + 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 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 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 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', 'run should succeed') + assert_is_report(rep, 'report should be present') + assert(body_val == 'ok', 'body result should be returned') +end + +------------------------------------------------------------------------------- +-- 4. cancel(): rejects new work; cancel_op idempotent; cancel reason policy +------------------------------------------------------------------------------- + +local function test_cancel_and_cancel_op_idempotent() + local st, rep, primary = scope_mod.run(function (s) + s:cancel('bye') + end) + + assert(st == 'cancelled' and primary == 'bye', 'explicit cancel yields cancelled + reason') + assert_is_report(rep, 'cancelled scope should return a report') + + -- 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(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 behaviour (perform raises sentinel; try returns status) +------------------------------------------------------------------------------- + +local function test_cancellation_sentinel_and_try_semantics() + local st, rep, primary = scope_mod.run(function (s) + local c = cond_mod.new() + + s:spawn(function () + runtime.yield() + s:cancel('cancel-reason') + end) + + local ok, err = safe.pcall(function () + s:perform(c:wait_op()) + end) + + assert(ok == false, 'perform should raise on cancellation') + 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(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: first fault cancels scope; siblings observe failure (not cancellation) +------------------------------------------------------------------------------- + +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() + + s:spawn(function () + observed_st, observed_primary = s:try(c:wait_op()) + end) + + s:spawn(function () + error('boom') + end) + + runtime.yield() + end) + + assert(st == 'failed', 'scope should fail on first fault') + assert_contains(tostring(primary), 'boom', 'primary should mention boom') + + assert(observed_st == 'failed', 'blocked sibling should observe failed on fault') + assert_contains(tostring(observed_primary), 'boom', 'blocked sibling should observe primary fault') + + assert_is_report(rep, 'report must be present') + assert(#rep.extra_errors == 0, 'extra_errors should be empty when no further faults') +end + +------------------------------------------------------------------------------- +-- 7. Failure takes precedence over cancellation (race) +------------------------------------------------------------------------------- + +local function test_failure_precedes_cancellation() + local st, rep, primary = scope_mod.run(function (s) + s:spawn(function () + runtime.yield() + s:cancel('cancelling') + end) + s:spawn(function () + runtime.yield() + error('failing') + end) + runtime.yield() + runtime.yield() + end) + + 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 yields failed') + assert_contains(tostring(oval), 'fault-here', 'fault_op yields 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(st1 == 'failed', 'scope should be failed on fault') + assert_contains(tostring(primary1), 'fault-here') + assert_is_report(rep1, 'failed report must be present') +end + +------------------------------------------------------------------------------- +-- 9. Finalisers: LIFO order; join non-interruptible (finalisers run under cancel) +------------------------------------------------------------------------------- + +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' 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') + + -- (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 (cs) + cs:finally(function () + ran = true + end) + + -- This should be interrupted by cancellation (via ch:perform semantics). + ch:perform(blocker:wait_op()) + 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) + + -- 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 + +------------------------------------------------------------------------------- +-- 10. Finaliser cancellation sentinel becomes fault (policy test) +------------------------------------------------------------------------------- + +local function test_cancel_sentinel_in_finaliser_becomes_fault() + local st, rep, primary = scope_mod.run(function (s) + s:finally(function () + error(scope_mod.cancelled('finaliser-cancel')) + end) + return 'ok' + 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 + +------------------------------------------------------------------------------- +-- 11. Reports: nested child report structure (depth 2) +------------------------------------------------------------------------------- + +local function test_reports_nested_children_depth_two() + local child_ref, grand_ref + + 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 + + gch:spawn(function () + error('grandchild-fault') + end) + + 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 + +------------------------------------------------------------------------------- +-- 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 = assert(s:child()) + + local blocker = cond_mod.new() + local join_started = cond_mod.new() + + child:spawn(function () + child:perform(blocker:wait_op()) + end) + + s:spawn(function () + join_started:signal() + op.perform_raw(child:join_op()) + end) + + 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 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 rejection should mention joining') + + blocker:signal() + return 'ok' + end) + + assert(st == 'ok' and body_val == 'ok', 'join/admission test should complete ok') + assert_is_report(rep, 'report must be present') +end + +------------------------------------------------------------------------------- +-- 13. run_op: basic success, confinement of failure, and abort on choice loss +------------------------------------------------------------------------------- + +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') + 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_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 parent in this fiber') + local cst, cprimary = child_ref:status() + assert(cst == 'ok' and cprimary == nil, 'child scope should be ok after success') + + -- 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(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(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_loss() + local child_ref + + local st, rep, outer_val = scope_mod.run(function (s) + local ready = cond_mod.new() + + -- 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 + child:perform(op.never()) + 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 select right arm once ready') + return res + end) + + 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 create child scope on blocking path') + local cst, cprimary = child_ref:status() + assert(cst == 'cancelled', 'aborted run_op child should be cancelled') + assert(cprimary == 'aborted', "aborted child cancellation reason should be 'aborted'") +end + +------------------------------------------------------------------------------- +-- 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 == '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') + assert_is_report(rep, 'outer report must be present') +end + +------------------------------------------------------------------------------- +-- 15. Uncaught runtime fiber error attribution (best-effort) +------------------------------------------------------------------------------- + +local function test_uncaught_raw_fiber_is_unscoped_by_default() + -- Reset capture. + unscoped.n = 0 + unscoped.last = nil + + local st, rep, body_val = scope_mod.run(function (_) + runtime.spawn_raw(function () + error('raw-fiber-boom') + end) + runtime.yield() + return 'ok' + end) + + -- 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') + + -- 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 + +local function test_finaliser_receives_cancellation_reason() + local seen_aborted, seen_status, seen_primary + + local st, rep, primary = scope_mod.run(function (s) + s:finally(function (aborted, status, final_primary) + seen_aborted = aborted + seen_status = status + seen_primary = final_primary + end) + + s:cancel('catalogue_changed') + end) + + assert(st == 'cancelled', 'scope should end cancelled') + assert(primary == 'catalogue_changed', 'scope boundary should return cancellation reason') + assert_is_report(rep, 'cancelled scope should return report') + + assert(seen_aborted == true, 'finaliser should see aborted=true') + assert(seen_status == 'cancelled', 'finaliser should see status=cancelled') + assert(seen_primary == 'catalogue_changed', + 'finaliser should receive the cancellation reason as primary') +end + +local function test_child_finaliser_receives_parent_cancellation_reason() + local seen_status, seen_primary + + local st, rep, primary = scope_mod.run(function (s) + local ch = assert(s:child()) + + ch:spawn(function (cs) + cs:finally(function (_, status, final_primary) + seen_status = status + seen_primary = final_primary + end) + + cs:perform(op.never()) + end) + + runtime.yield() + s:cancel('catalogue_changed') + end) + + assert(st == 'cancelled', 'parent should end cancelled') + assert(primary == 'catalogue_changed', 'parent should preserve cancellation reason') + assert_is_report(rep, 'cancelled parent should return report') + + assert(seen_status == 'cancelled', 'child finaliser should see cancelled') + assert(seen_primary == 'catalogue_changed', + 'child finaliser should receive propagated cancellation reason') +end + +------------------------------------------------------------------------------- +-- Suite entry +------------------------------------------------------------------------------- + +local function run_all_tests() + test_outside_fiber_current_is_root() + + test_current_scope_run_restores() + test_structural_lifetime_children_joined_in_order() + + test_admission_close_and_close_op_idempotent() + test_cancel_and_cancel_op_idempotent() + + test_cancellation_sentinel_and_try_semantics() + test_fail_fast_and_siblings_observe_failed() + test_failure_precedes_cancellation() + + test_fault_ops_and_not_ok_precedence() + + test_finalisers_lifo_and_join_non_interruptible() + test_cancel_sentinel_in_finaliser_becomes_fault() + + test_reports_nested_children_depth_two() + + test_join_closes_admission_and_rejects_new_work() + + 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() + + test_finaliser_receives_cancellation_reason() + test_child_finaliser_receives_parent_cancellation_reason() +end + +------------------------------------------------------------------------------- +-- Main +------------------------------------------------------------------------------- + +run_in_root(function () + io.stdout:write('Running revised scope tests...\n') + run_all_tests() + io.stdout:write('OK\n') +end) diff --git a/tests/test_sleep-timer_cancel.lua b/tests/test_sleep-timer_cancel.lua new file mode 100644 index 0000000..8f4eb4a --- /dev/null +++ b/tests/test_sleep-timer_cancel.lua @@ -0,0 +1,57 @@ +--- Regression test for losing sleep arms in choices. +--- +--- A sleep operation that loses a choice must cancel and remove its scheduled +--- timer task immediately. Otherwise the timer heap retains the CompleteTask, +--- which retains the Suspension and its captured values until the deadline. +print('testing: fibers.sleep timer cancellation') + +package.path = '../src/?.lua;' .. package.path + +local fibers = require 'fibers' +local op = require 'fibers.op' +local sleep = require 'fibers.sleep' +local runtime = require 'fibers.runtime' + +local count = 2000 + +local function next_turn_op(value) + return op.new_primitive(nil, + function () + return false + end, + function (suspension, wrap_fn) + suspension:wakeup(suspension:complete_task(wrap_fn, value)) + end + ) +end + +local completed = 0 + +fibers.run(function () + local wheel = runtime.current_scheduler.wheel + assert(wheel.heap.size == 0, 'timer heap unexpectedly non-empty at start') + + for i = 1, count do + local value = fibers.perform(fibers.choice( + next_turn_op(i), + sleep.sleep_op(1e6) + )) + + assert(value == i) + completed = completed + 1 + + assert(wheel.heap.size == 0, + ('losing sleep_op left %d timer entries after iteration %d') + :format(wheel.heap.size, i)) + + if i % 250 == 0 then + collectgarbage('collect') + assert(wheel.heap.size == 0, + ('timer heap retained entries after GC at iteration %d'):format(i)) + end + end +end) + +assert(completed == count) + +print('test: ok') diff --git a/tests/test_sleep.lua b/tests/test_sleep.lua index 210e396..5a13ebb 100644 --- a/tests/test_sleep.lua +++ b/tests/test_sleep.lua @@ -2,35 +2,28 @@ print('testing: fibers.sleep') -- look one level up -package.path = "../?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path local sleep = require 'fibers.sleep' -local fiber = require 'fibers.fiber' +local runtime = require 'fibers.runtime' local done = 0 -- local wakeup_times = {} local count = 1e3 for _ = 1, count do - local function fn() - local start, dt = fiber.now(), math.random() - sleep.sleep(dt) - local wakeup_time = fiber.now() - assert(wakeup_time >= start + dt) - done = done + 1 - -- table.insert(wakeup_times, wakeup_time - (start + dt)) - end - fiber.spawn(fn) + local function fn() + local start, dt = runtime.now(), math.random() + sleep.sleep(dt) + local wakeup_time = runtime.now() + assert(wakeup_time >= start + dt) + done = done + 1 + -- table.insert(wakeup_times, wakeup_time - (start + dt)) + end + runtime.spawn_raw(fn) end -for t = fiber.now(), fiber.now() + 1.5, 0.01 do - fiber.current_scheduler:run(t) +for t = runtime.now(), runtime.now() + 1.5, 0.01 do + runtime.current_scheduler:run(t) end assert(done == count) --- -- Calculate maximum error --- local max_error = 0 --- for _, error in ipairs(wakeup_times) do --- if error > max_error then max_error = error end --- end --- print("Maximum sleep error: ", max_error) - print('test: ok') diff --git a/tests/test_stream-compat.lua b/tests/test_stream-compat.lua deleted file mode 100644 index 0a836ce..0000000 --- a/tests/test_stream-compat.lua +++ /dev/null @@ -1,17 +0,0 @@ ---- Tests the Stream Compat implementation. -print('testing: fibers.stream.compat') - --- look one level up -package.path = "../?.lua;" .. package.path - -local compat = require 'fibers.stream.compat' - -print('selftest: lib.stream.compat') - -_G.io.write('before\n') -compat.install() -_G.io.write('after\n') -assert(_G.io == io) -compat.uninstall() - -print('test: ok') diff --git a/tests/test_stream-file.lua b/tests/test_stream-file.lua deleted file mode 100644 index 4539283..0000000 --- a/tests/test_stream-file.lua +++ /dev/null @@ -1,234 +0,0 @@ ---- Tests the Stream File implementation. -print('testing: fibers.stream.file') - --- look one level up -package.path = "../?.lua;" .. package.path - -local fiber = require 'fibers.fiber' -local waitgroup = require "fibers.waitgroup" -local op = require 'fibers.op' -local sleep = require 'fibers.sleep' -local channel = require 'fibers.channel' -local file = require 'fibers.stream.file' - -local function test() - local rd, wr = file.pipe() - local message = "hello, world\n" - - wr:setvbuf('line') - wr:write(message) - local message2 = rd:read_some_chars() - assert(message == message2) - wr:close() - assert(rd:read_some_chars() == nil) - rd:close() - - local subprocess = file.popen('echo "hello"; echo "world"', 'r') - local lines = {} - while true do - local line, err = subprocess:read_line() - assert(not err) - if line then table.insert(lines, line) else break end - end - local res, exit_type, code = subprocess:close() - assert(res) - assert(exit_type == "exit") - assert(code == 0) - assert(#lines == 2) - assert(lines[1] == 'hello') - assert(lines[2] == 'world') -end - -local function test_long_read_first() - local rd, wr = file.pipe() - local message = string.rep("a", 2^24) - - fiber.spawn(function () - wr:write_chars(message) - wr:close() - end) - - local message2 = rd:read_all_chars() - assert(#message2 == #message) - assert(message2 == message) - - rd:close() -end - -local function test_read_op() - local msg1 = "hello\n" - local msg2 = "world\n" - local rd, wr = file.pipe() - wr:setvbuf('line') -- new lines will automatically be flushed - - local wg = waitgroup.new() - wg:add(1) - - fiber.spawn(function () - local count, err = wr:write_chars(msg1) - assert(count==#msg1 and not err) - sleep.sleep(0.05) - count, err = wr:write_chars(msg2) - assert(count==#msg2 and not err) - wr:close() - wg:done() - end) - - local chars, err = op.choice( - rd:read_all_chars_op(), - sleep.sleep_op(0.01):wrap(function () return nil, 'timeout' end) - ):perform() - - assert(not chars and err == 'timeout') - - local did_read, read_string = rd:partial_read() - assert(did_read == #msg1 and read_string == msg1) - - rd:reset_partial_read() - assert(not rd._part_read) - - chars, err = rd:read_all_chars() - assert(not err and chars==msg2) - - assert(not rd._part_read) - - rd:close() - wg:wait() -end - -local function test_write_op() - local msg = string.rep("a", 2^16) -- needed because Linux has a 64KB buffer for a pipe - local msg2 = "hello world\n" - local rd, wr = file.pipe() - wr:setvbuf('no') -- no output buffering - - local wg = waitgroup.new() - wg:add(1) - - fiber.spawn(function () - sleep.sleep(0.1) - local chars, err = rd:read_chars(2^16) - assert(chars==string.rep("a", 2^16) and not err) - chars, err = rd:read_all_chars() - assert(chars==msg2 and not err) - rd:close() - wg:done() - end) - - local written, err = op.choice( - wr:write_chars_op(msg..msg2), - sleep.sleep_op(0.01):wrap(function () return nil, 'timeout' end) - ):perform() - - assert(not written and err=='timeout') - - local did_write = wr:partial_write() - assert(did_write == 2^16) - - wr:reset_partial_write() - assert(not wr._part_write) - - did_write, err = wr:write_chars(msg2) - assert(not err and did_write==#msg2) - - assert(not wr._part_write) - - wr:close() - wg:wait() -end - -local function test_long_write_first() - local rd, wr = file.pipe() - local chan = channel.new() - local message = string.rep("a", 2^24) - - local message2 - - fiber.spawn(function () - message2 = rd:read_all_chars() - rd:close() - chan:put(1) - end) - - wr:write_chars(message) - wr:close() - - chan:get() - - assert(#message2 == #message) - assert(message2 == message) -end - -local function test_tiny_writes() - local rd, wr = file.pipe() - local message = string.rep("a", 2^16) - - fiber.spawn(function () - for c in message:gmatch"." do - wr:write(c) - end - wr:close() - end) - - local message2 = rd:read_all_chars() - assert(#message2 == #message) - assert(message2 == message) - - rd:close() -end - -local function test_single() - local rd, wr = file.pipe() - local message = "aa" - - fiber.spawn(function () - wr:write_chars(message) - wr:close() - end) - - assert(string.byte(rd:read_char()) == 97) - assert(rd:read_char() == "a") - assert(rd:read_char() == nil) - - rd:close() -end - -local function test_lua() - local msg1 = "It\n" - local msg2 = "was\n" - local msg3 = "the\n" - local msg4 = "best\n" - local msg5 = "of\n" - local msg6 = "times" - - local rd, wr = file.pipe() - wr:setvbuf('no') - - assert(wr:write(msg1, msg2, msg3, msg4, msg5, msg6)) - wr:close() - - assert(rd:read("*l")==string.sub(msg1,1,#msg1-1)) - assert(rd:read("*L")==msg2) - assert(rd:read(#msg3)==msg3) - assert(rd:read(#msg4)==msg4) - assert(rd:read("*a")==msg5..msg6) - assert(rd:read("*a")=="") -- on EOF read("*a") returns the empty string - assert(rd:read("*l")==nil) -- on EOF read("*l") returns nil - - rd:close() -end - -fiber.spawn(function () - test() - test_read_op() - test_write_op() - test_long_read_first() - test_long_write_first() - test_tiny_writes() - test_single() - test_lua() - fiber.stop() -end) -fiber.main() - -print('test: ok') diff --git a/tests/test_stream-mem.lua b/tests/test_stream-mem.lua deleted file mode 100644 index cffec82..0000000 --- a/tests/test_stream-mem.lua +++ /dev/null @@ -1,30 +0,0 @@ ---- Tests the Stream Mem implementation. -print('testing: fibers.stream.mem') - --- look one level up -package.path = "../?.lua;" .. package.path - -local mem = require 'fibers.stream.mem' -local sc = require 'fibers.utils.syscall' - -local str = "hello, world!" -local stream = mem.open_input_string(str) -assert(stream:seek() == 0) -assert(stream:seek(sc.SEEK_END) == #str) -assert(stream:seek() == #str) -assert(stream:seek(sc.SEEK_SET) == 0) -assert(stream:read_all_chars() == str) -assert(not pcall(stream.write_chars, stream, "more chars")) -assert(stream:seek() == #str) -stream:close() - -stream = mem.tmpfile() -assert(stream:seek() == 0) -assert(stream:seek(sc.SEEK_END) == 0) -stream:write_chars(str) -stream:flush() -assert(stream:seek() == #str) -assert(stream:seek(sc.SEEK_SET) == 0) -assert(stream:read_all_chars() == str) -stream:close() -print('selftest: ok') diff --git a/tests/test_stream-socket.lua b/tests/test_stream-socket.lua deleted file mode 100644 index 4e68b59..0000000 --- a/tests/test_stream-socket.lua +++ /dev/null @@ -1,42 +0,0 @@ ---- Tests the Stream Socket implementation. -print('testing: fibers.stream.socket') - --- look one level up -package.path = "../?.lua;" .. package.path - -local fiber = require 'fibers.fiber' -local socket = require 'fibers.stream.socket' -local sc = require 'fibers.utils.syscall' - -local function test() - local sockname = '/tmp/test-socket' - sc.unlink(sockname) - - local server = socket.listen_unix(sockname) - local client = socket.connect_unix(sockname) - local peer = server:accept() - - local messages = { "hello\n", "world\n" } - for _, msg in ipairs(messages) do - client:write(msg) - client:flush_output() - local res = peer:read_some_chars() - assert(msg == res) - end - client:close() - assert(peer:read_some_chars() == nil) - peer:close() - - server:close() - - sc.unlink(sockname) -end - - -fiber.spawn(function () - test() - fiber.stop() -end) -fiber.main() - -print('test: ok') diff --git a/tests/test_stream.lua b/tests/test_stream.lua deleted file mode 100644 index b7236e6..0000000 --- a/tests/test_stream.lua +++ /dev/null @@ -1,42 +0,0 @@ ---- Tests the Stream implementation. -print('testing: fibers.stream') - --- look one level up -package.path = "../?.lua;" .. package.path - -local fiber = require 'fibers.fiber' -local stream = require 'fibers.stream' - -local function test() - local rd_io, wr_io = {}, {} - local rd, wr = stream.open(rd_io, true, false), stream.open(wr_io, false, true) - - function rd_io:close() end - - function rd_io:read() return 0 end - - function wr_io:write(buf, count) - rd.rx:write(buf, count) - return count - end - - function wr_io:close() end - - local message = "hello, world\n" - wr:setvbuf('line') - wr:write(message) - local message2 = rd:read_some_chars() - assert(message == message2) - assert(rd:read_some_chars() == nil) - - rd:close(); wr:close() -end - -fiber.spawn(function () - test() - fiber.stop() -end) - -fiber.main() - -print('selftest: ok') diff --git a/tests/test_timer.lua b/tests/test_timer.lua index 1455ba7..fecb743 100644 --- a/tests/test_timer.lua +++ b/tests/test_timer.lua @@ -1,88 +1,90 @@ --- Tests the Timer implementation. -print("test: fibers.timer") +print('test: fibers.timer') -- look one level up -package.path = "../?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path local timer = require 'fibers.timer' -local sc = require 'fibers.utils.syscall' +local time = require 'fibers.utils.time' + +local noop_sched = { schedule = function () end } local function test_advance_time() - local wheel = timer.new(10) - local hour = 60 * 60 - local start_time = sc.monotime() - wheel:advance(hour) - local end_time = sc.monotime() - print("Time to advance wheel by an hour: "..(end_time - start_time).." seconds") + local wheel = timer.new(10) + local hour = 60 * 60 + local start_time = time.monotonic() + wheel:advance(hour, noop_sched) + local end_time = time.monotonic() + print('Time to advance wheel by an hour: ' .. (end_time - start_time) .. ' seconds') end local function test_event_scheduling(event_count) - local wheel = timer.new(sc.monotime()) - local t = wheel.now - local start_time = sc.monotime() - for _=1, event_count do - local dt = math.random() - t = t + dt - wheel:add_absolute(t, t) - end - local end_time = sc.monotime() - print("Time to add "..event_count.." events: "..(end_time - start_time).." seconds") + local wheel = timer.new(time.monotonic()) + local t = wheel.now + local start_time = time.monotonic() + for _ = 1, event_count do + local dt = math.random() + t = t + dt + wheel:add_absolute(t, t) + end + local end_time = time.monotonic() + print('Time to add ' .. event_count .. ' events: ' .. (end_time - start_time) .. ' seconds') end local function test_event_expiration(event_count) - local wheel = timer.new(sc.monotime()) - local last = 0 - local count = 0 - local check = {} - local t = wheel.now + local wheel = timer.new(time.monotonic()) + local last = 0 + local count = 0 + local check = {} + local t = wheel.now - function check:schedule(tw) - local now = wheel.now - assert(last <= tw) - last, count = tw, count + 1 - assert(now - 1e-3 < tw) - assert(tw < now + 1e-3) - end + function check:schedule(tw) + local now = wheel.now + assert(last <= tw) + last, count = tw, count + 1 + assert(now - 1e-3 < tw) + assert(tw < now + 1e-3) + end - for _=1, event_count do - local dt = math.random() - t = t + dt - wheel:add_absolute(t, t) - end + for _ = 1, event_count do + local dt = math.random() + t = t + dt + wheel:add_absolute(t, t) + end - local start_time = sc.monotime() - wheel:advance(t + 1, check) - local end_time = sc.monotime() - print("Time to advance wheel to expire "..event_count.." events: "..(end_time - start_time).." seconds") - assert(count == event_count) + local start_time = time.monotonic() + wheel:advance(t + 1, check) + local end_time = time.monotonic() + print('Time to advance wheel to expire ' .. event_count .. ' events: ' .. (end_time - start_time) .. ' seconds') + assert(count == event_count) end local function test_large_intervals() - local wheel = timer.new(sc.monotime()) - local far_future = 1e6 -- Far future time - local event_triggered = false - wheel:add_absolute(wheel.now + far_future, 'far_future_event') - wheel:advance(wheel.now + far_future + 1e-3, {schedule = function() event_triggered = true end}) - assert(event_triggered, "Far future event was not triggered") + local wheel = timer.new(time.monotonic()) + local far_future = 1e6 -- Far future time + local event_triggered = false + wheel:add_absolute(wheel.now + far_future, 'far_future_event') + wheel:advance(wheel.now + far_future + 1e-3, { schedule = function () event_triggered = true end }) + assert(event_triggered, 'Far future event was not triggered') end local function test_small_intervals() - local wheel = timer.new(sc.monotime()) - local very_near_future = 1e-6 -- Very near future time - local event_triggered = false - wheel:add_absolute(wheel.now + very_near_future, 'near_future_event') - wheel:advance(wheel.now + very_near_future + 1e-3, {schedule = function() event_triggered = true end}) - assert(event_triggered, "Near future event was not triggered") + local wheel = timer.new(time.monotonic()) + local very_near_future = 1e-6 -- Very near future time + local event_triggered = false + wheel:add_absolute(wheel.now + very_near_future, 'near_future_event') + wheel:advance(wheel.now + very_near_future + 1e-3, { schedule = function () event_triggered = true end }) + assert(event_triggered, 'Near future event was not triggered') end local function test_advance_now_update() - local wheel = timer.new(sc.monotime()) - local advance_time = 100 -- Advance by 100 seconds - local start_time = wheel.now - wheel:advance(start_time + advance_time) - local expected_time = start_time + advance_time - assert(wheel.now == expected_time, "Advance method failed to update 'now' correctly") - print("Test for 'now' update in advance method passed") + local wheel = timer.new(time.monotonic()) + local advance_time = 100 -- Advance by 100 seconds + local start_time = wheel.now + wheel:advance(start_time + advance_time, noop_sched) + local expected_time = start_time + advance_time + assert(wheel.now == expected_time, "Advance method failed to update 'now' correctly") + print("Test for 'now' update in advance method passed") end -- Run tests @@ -93,4 +95,4 @@ test_large_intervals() test_small_intervals() test_advance_now_update() -print("All tests passed") +print('All tests passed') diff --git a/tests/test_tricky.lua b/tests/test_tricky.lua new file mode 100644 index 0000000..972cb7d --- /dev/null +++ b/tests/test_tricky.lua @@ -0,0 +1,345 @@ +-- tests/tricky_cases.lua +-- +-- Exercises tricky cases in the op/scope runtime using sleep + channel. +-- Intended to run as a plain Lua script: +-- lua tests/tricky_cases.lua +-- + +--- Tests the Timer implementation. +print('test: tricky') + +-- look one level up +package.path = '../src/?.lua;' .. package.path + + +local fibers = require 'fibers' +local op = require 'fibers.op' +local sleep = require 'fibers.sleep' +local channel = require 'fibers.channel' + +---------------------------------------------------------------------- +-- Minimal assertions / harness +---------------------------------------------------------------------- + +local function fail(msg) + error(msg or 'test failed', 0) +end + +local function assert_true(v, msg) + if not v then fail(msg or 'expected true') end +end + +local function assert_eq(a, b, msg) + if a ~= b then + fail((msg or 'unexpected value') .. (': got ' .. tostring(a) .. ', want ' .. tostring(b))) + end +end + +local function assert_contains(hay, needle, msg) + hay = tostring(hay) + if not hay:find(needle, 1, true) then + fail((msg or 'expected substring') .. (': "' .. needle .. '" not in "' .. hay .. '"')) + end +end + +local tests = {} +local function test(name, fn) + tests[#tests + 1] = { name = name, fn = fn } +end + +---------------------------------------------------------------------- +-- Tests +---------------------------------------------------------------------- + +-- 1) Late completion should be harmless (losing completion task fires later). +test('choice: late completion of losing timer does not crash', function (_) + local ev = op.choice( + sleep.sleep_op(0.02):wrap(function () return 'fast' end), + sleep.sleep_op(0.08):wrap(function () return 'slow' end) + ) + + local v = fibers.perform(ev) + assert_eq(v, 'fast', 'expected fast arm to win') + + -- Allow the losing arm’s scheduled completion task to run later. + sleep.sleep(0.12) +end) + +-- 2) Abort handler should run exactly once for the losing arm. +test('choice: abort handler runs once', function (_) + local abort_count = 0 + + local ev = op.choice( + sleep.sleep_op(0.02):wrap(function () return 'win' end), + sleep.sleep_op(0.08) + :on_abort(function () abort_count = abort_count + 1 end) + :wrap(function () return 'lose' end) + ) + + local v = fibers.perform(ev) + assert_eq(v, 'win', 'expected winning arm') + + sleep.sleep(0.12) + assert_eq(abort_count, 1, 'expected abort handler to run once') + + -- Ensure it does not run again later. + sleep.sleep(0.10) + assert_eq(abort_count, 1, 'abort handler ran more than once') +end) + +-- 3) with_nack: losing arm’s nack becomes ready; winner’s nack does not. +test('with_nack: loser nack fires, winner nack does not', function (_) + local c1 = channel.new() -- rendezvous channel for arm1 + local c2 = channel.new() -- rendezvous channel for arm2 (never signalled) + local events = channel.new(10) -- buffered: reports nack firings + local stop = channel.new(10) -- buffered: stops observers + + local function arm(label, ch) + return op.with_nack(function (nack_op) + -- Observer fibre waits for either nack or stop. + fibers.spawn(function () + local which = fibers.perform(op.choice( + nack_op:wrap(function () return 'nack' end), + stop:get_op():wrap(function () return 'stop' end) + )) + + if which == 'nack' then + events:put(label) + end + end) + + -- Main arm blocks on channel receive. + return ch:get_op():wrap(function (v) return label, v end) + end) + end + + -- Arrange arm1 to win later (ensures we take the blocking choice path). + fibers.spawn(function () + sleep.sleep(0.02) + c1:put('msg1') + end) + + local winner, payload = fibers.perform(op.choice( + arm('arm1', c1), + arm('arm2', c2) + )) + + assert_eq(winner, 'arm1', 'expected arm1 to win') + assert_eq(payload, 'msg1', 'expected arm1 payload') + + -- Give nack signalling time to propagate. + sleep.sleep(0.02) + + -- Stop both observers (winner’s observer should exit via stop, not nack). + stop:put(true) + stop:put(true) + + sleep.sleep(0.02) + + -- Drain events non-blockingly. + local e1 = fibers.perform(events:get_op():or_else(function () return nil end)) + local e2 = fibers.perform(events:get_op():or_else(function () return nil end)) + + assert_eq(e1, 'arm2', 'expected loser nack to fire for arm2') + assert_true(e2 == nil, 'unexpected extra nack event: ' .. tostring(e2)) +end) + +-- 4) bracket/finally: release/cleanup happens once, with correct aborted flag. +test('bracket: release(true) on abort, release(false) on success', function (_) + -- Abort case. + do + local calls = {} + + local function acquire() return {} end + local function release(_, aborted) + calls[#calls + 1] = aborted + end + local function use(_) + return sleep.sleep_op(0.08) + end + + local bracketed = op.bracket(acquire, release, use):wrap(function () return 'bracket' end) + + local v = fibers.perform(op.choice( + sleep.sleep_op(0.02):wrap(function () return 'fast' end), + bracketed + )) + + assert_eq(v, 'fast', 'expected non-bracket arm to win') + + sleep.sleep(0.12) + assert_eq(#calls, 1, 'release should be called once (abort case)') + assert_eq(calls[1], true, 'release should be called with aborted=true (abort case)') + end + + -- Success case. + do + local calls = {} + + local function acquire() return {} end + local function release(_, aborted) + calls[#calls + 1] = aborted + end + local function use(_) + return sleep.sleep_op(0.02) + end + + local bracketed = op.bracket(acquire, release, use):wrap(function () return 'bracket' end) + + local v = fibers.perform(op.choice( + bracketed, + sleep.sleep_op(0.10):wrap(function () return 'slow' end) + )) + + assert_eq(v, 'bracket', 'expected bracket arm to win') + + sleep.sleep(0.06) + assert_eq(#calls, 1, 'release should be called once (success case)') + assert_eq(calls[1], false, 'release should be called with aborted=false (success case)') + end +end) + +-- 5) Scope fail-fast: first fault cancels siblings; cancelled sibling observes cancellation. +test('scope: fail-fast cancels siblings', function (_) + -- Run the scenario in a nested scope boundary, and assert on its outcome. + local st, _, primary = fibers.run_scope(function (s2) + local events = channel.new(10) + + local ok1, err1 = s2:spawn(function () + sleep.sleep(0.02) + error('boom', 0) + end) + assert_true(ok1, 'spawn child1 failed: ' .. tostring(err1)) + + local ok2, err2 = s2:spawn(function () + -- This should be interrupted by scope cancellation (status-first). + local cst, reason = fibers.try_perform(sleep.sleep_op(10)) + + -- Report out using raw op.perform_raw so cancellation does not prevent reporting. + op.perform_raw(events:put_op({ cst, reason })) + end) + assert_true(ok2, 'spawn child2 failed: ' .. tostring(err2)) + + -- Wait for the sibling’s cancellation report without being interrupted by cancellation. + local msg = op.perform_raw(events:get_op()) + assert_eq(msg[1], 'cancelled', 'expected sibling to observe cancellation') + assert_contains(msg[2], 'boom', 'expected cancellation reason to include primary failure') + end) + + assert_eq(st, 'failed', 'expected nested scope to fail') + assert_contains(primary, 'boom', 'expected primary failure to include "boom"') +end) + +-- 6) Late completion via channel rendezvous: losing receiver is queued then later matched. +test('choice: late completion of losing channel receive does not crash', function (_) + local c = channel.new() + local done = channel.new(1) -- buffered so the reporter can't block + + local fast = sleep.sleep_op(0.02):wrap(function () return 'fast' end) + local slow_get = c:get_op():wrap(function (v) return 'got', v end) + + -- Run the choice: slow_get will enqueue then lose. + local v = fibers.perform(op.choice(fast, slow_get)) + assert_eq(v, 'fast', 'expected timer arm to win') + + -- After the winner, attempt a send. This will call put_op.try(), + -- which will scan/drop stale getq entries; but it must not block. + fibers.spawn(function () + sleep.sleep(0.06) + local r = fibers.perform( + c:put_op('late'):or_else(function () return 'would_block' end) + ) + done:put(r) + end) + + local r = done:get() + assert_eq(r, 'would_block', 'expected late send to be non-blocking and report would_block') + + -- Allow any queued tasks to run. + sleep.sleep(0.05) +end) + +-- 7) Stress pop_active / cleanup: many losing waiters then many late senders. +test('channel: stale queue entries are skipped under load', function (_) + local c = channel.new() -- unbuffered + local n_stale = 200 + local m = 50 + + -- Create many stale receiver entries by having get_op lose to a fast timer. + for _ = 1, n_stale do + local fast = sleep.sleep_op(0.001):wrap(function () return true end) + local lose = c:get_op():wrap(function () return false end) + local ok = fibers.perform(op.choice(fast, lose)) + assert_eq(ok, true, 'expected fast arm to win') + end + + local events = channel.new(m) -- buffered so receiver can report without blocking + local ack = channel.new() -- unbuffered rendezvous for pacing + + -- Receiver: take m values, report them, ack each one. + fibers.spawn(function () + for _ = 1, m do + local v = c:get() + events:put(v) + ack:put(true) + end + end) + + -- Sender: send 1..m, waiting for ack each time. + fibers.spawn(function () + for i = 1, m do + c:put(i) + ack:get() + end + end) + + -- Verify we actually received the sequence. + for i = 1, m do + local v = events:get() + assert_eq(v, i, 'unexpected value received after stale-queue stress') + end +end) + + +---------------------------------------------------------------------- +-- Runner (single fibres.run invocation) +---------------------------------------------------------------------- + +local function run_all() + local passed, failed = 0, 0 + local failures = {} + + for i = 1, #tests do + local t = tests[i] + + -- Each test runs in its own child scope boundary so one failure does not stop the rest. + local st, _, primary = fibers.run_scope(function (s) + t.fn(s) + end) + + if st == 'ok' then + passed = passed + 1 + io.stdout:write(('ok %s\n'):format(t.name)) + else + failed = failed + 1 + local msg = tostring(primary or 'unknown failure') + failures[#failures + 1] = ('not ok %s: %s'):format(t.name, msg) + io.stdout:write(('not ok %s\n'):format(t.name)) + end + end + + io.stdout:write(('\n%d passed, %d failed\n'):format(passed, failed)) + + if failed > 0 then + io.stdout:write(table.concat(failures, '\n') .. '\n') + error(('test failures: %d'):format(failed), 0) + end +end + +-- Keep everything under a single scheduler lifetime. +fibers.run(function (_) + -- Optional: reduce non-determinism if you have tests that depend on probe order. + math.randomseed(1) + + run_all() +end) diff --git a/tests/test_utils-bytes.lua b/tests/test_utils-bytes.lua new file mode 100644 index 0000000..4dbe0af --- /dev/null +++ b/tests/test_utils-bytes.lua @@ -0,0 +1,204 @@ +print('testing: fibers.utils.bytes') + +-- look one level up +package.path = '../src/?.lua;' .. package.path + +-- Tests for fibers.utils.bytes +-- - always exercises the pure Lua backend +-- - additionally exercises the FFI backend if available +-- - also exercises the top-level shim (whatever it chooses) +-- - all backends are tested via the unified string-level API: +-- RingBuf: new, read_avail, write_avail, is_empty, put, take, +-- tostring, find, reset +-- LinearBuf:new, append, tostring, reset +-- plus an extra FFI-only test for reserve/commit on LinearBuf, +-- but only if the object actually supports reserve/commit and an +-- ffi/cffi library is available. + +local function get_ffi() + local is_LuaJIT = rawget(_G, 'jit') and true or false + local ok, ffi_mod + if is_LuaJIT then + ok, ffi_mod = pcall(require, 'ffi') + else + ok, ffi_mod = pcall(require, 'cffi') + end + if not ok then return nil end + return ffi_mod +end + +local bytes_shim = require 'fibers.utils.bytes' +local lua_backend = require 'fibers.utils.bytes.lua' + +local ok_ffi_backend, ffi_backend = pcall(require, 'fibers.utils.bytes.ffi') +if not ok_ffi_backend then + ffi_backend = nil +end + +local ffi_obj = get_ffi() + +local function assert_eq(actual, expected, msg) + if actual ~= expected then + error(string.format('%s (expected %q, got %q)', + msg or 'assert_eq failed', + tostring(expected), + tostring(actual))) + end +end + +------------------------------------------------------------ +-- Backend-independent tests (string-level API) +------------------------------------------------------------ + +local function test_ring_basic(impl) + local RingBuf = impl.RingBuf + print(' RingBuf basic...') + + local rb = RingBuf.new(16) + + assert_eq(rb:read_avail(), 0, 'read_avail at start') + assert_eq(rb:write_avail(), 16, 'write_avail at start') + assert(rb:is_empty(), 'is_empty at start') + assert(not rb:is_full(), 'is_full at start') + + -- Write "hello" + rb:put('hello') + assert_eq(rb:read_avail(), 5, "read_avail after put('hello')") + assert_eq(rb:write_avail(), 11, "write_avail after put('hello')") + + -- tostring should not consume + local s_view = rb:tostring() + assert_eq(s_view, 'hello', "tostring after put 'hello'") + assert_eq(rb:read_avail(), 5, 'read_avail unchanged after tostring') + + -- Take back + local s = rb:take(5) + assert_eq(s, 'hello', "take(5) returns 'hello'") + assert_eq(rb:read_avail(), 0, 'read_avail after full take') + assert(rb:is_empty(), 'is_empty after draining') + + rb:reset() + assert_eq(rb:read_avail(), 0, 'read_avail after reset') + assert(rb:is_empty(), 'is_empty after reset') +end + +local function test_ring_wrap(impl) + local RingBuf = impl.RingBuf + print(' RingBuf wrap-around...') + + local rb = RingBuf.new(8) -- small to force wrap + + -- Sequence: put 6, take 4, put 4 → buffer should contain "efWXYZ" + rb:put('abcdef') + assert_eq(rb:read_avail(), 6, 'wrap: read_avail after first put(6)') + + local s1 = rb:take(4) + assert_eq(s1, 'abcd', 'wrap: first take(4)') + assert_eq(rb:read_avail(), 2, 'wrap: read_avail after first take') + + rb:put('WXYZ') + assert_eq(rb:read_avail(), 6, 'wrap: read_avail after second put(4)') + + local s_all = rb:tostring() + assert_eq(s_all, 'efWXYZ', 'wrap: content after wrap sequence') + + local off = rb:find('WX') + assert_eq(off, 2, "wrap: find('WX') offset") +end + +local function test_linear_basic(impl) + local LinearBuf = impl.LinearBuf + print(' LinearBuf basic...') + + local lb = LinearBuf.new(32) + + lb:append('hello') + lb:append(' world') + assert_eq(lb:tostring(), 'hello world', 'LinearBuf tostring after appends') + + lb:reset() + assert_eq(lb:tostring(), '', 'LinearBuf tostring after reset') +end + +------------------------------------------------------------ +-- Optional FFI test for reserve/commit on LinearBuf +------------------------------------------------------------ + +local function test_linear_reserve_commit_ffi(impl, ffi_mod) + -- Only applicable if we have an ffi/cffi module. + if not ffi_mod then + return + end + if not impl or not impl.LinearBuf or not impl.LinearBuf.new then + return + end + + local lb = impl.LinearBuf.new(32) + + -- Only run if this backend actually exposes a pointer-level API. + if type(lb.reserve) ~= 'function' or type(lb.commit) ~= 'function' then + return + end + + print(' LinearBuf reserve/commit (FFI)...') + + -- reserve/commit path + local p = lb:reserve(5) + ffi_mod.copy(p, 'hello', 5) + lb:commit(5) + assert_eq(lb:tostring(), 'hello', 'LinearBuf tostring after reserve/commit') + + -- append path on top + lb:append(' world') + assert_eq(lb:tostring(), 'hello world', 'LinearBuf tostring after append') +end + +------------------------------------------------------------ +-- Run tests for a given backend +------------------------------------------------------------ + +local function run_backend_tests(name, impl, ffi_mod) + print(('testing bytes backend: %s'):format(name)) + + if not impl or not impl.RingBuf or not impl.LinearBuf then + error(('backend %s missing RingBuf/LinearBuf'):format(name)) + end + + test_ring_basic(impl) + test_ring_wrap(impl) + test_linear_basic(impl) + test_linear_reserve_commit_ffi(impl, ffi_mod) + + print(('backend %s: OK\n'):format(name)) +end + +------------------------------------------------------------ +-- Main +------------------------------------------------------------ + +print('testing fibers.utils.bytes') + +-- Always test pure Lua backend +if not lua_backend or not lua_backend.is_supported or not lua_backend.is_supported() then + error('Lua bytes backend not available or not supported') +end +run_backend_tests('lua', lua_backend, nil) + +-- Test FFI backend if present and supported +if ffi_backend and ffi_backend.is_supported and ffi_backend.is_supported() then + if not ffi_obj then + print('ffi backend available but no ffi/cffi library; skipping pointer-level FFI test') + end + run_backend_tests('ffi', ffi_backend, ffi_obj) +else + print('ffi backend not available or not supported; skipping FFI backend tests\n') +end + +-- Test the top-level shim (whatever it chose) +if bytes_shim and bytes_shim.RingBuf and bytes_shim.LinearBuf then + run_backend_tests('shim', bytes_shim, ffi_obj) +else + error('bytes shim backend missing RingBuf/LinearBuf') +end + +print('all bytes tests done') diff --git a/tests/test_utils-bytes_stress.lua b/tests/test_utils-bytes_stress.lua new file mode 100644 index 0000000..e6a3592 --- /dev/null +++ b/tests/test_utils-bytes_stress.lua @@ -0,0 +1,211 @@ +-- +-- Stress tests for fibers.utils.bytes +-- - always exercises the pure Lua backend +-- - also exercises the FFI backend if available +-- - also exercises the top-level shim (default backend) +-- +-- Uses a simple reference model (Lua strings) to verify correctness +-- under randomised workloads, via the unified string-level interface: +-- RingBuf:put(str), RingBuf:take(n), RingBuf:tostring() +-- LinearBuf:append(str), LinearBuf:tostring() +-- + +print('testing: fibers.utils.bytes stress') + +-- look one level up +package.path = '../src/?.lua;' .. package.path + +local bytes_shim = require 'fibers.utils.bytes' +local lua_backend = require 'fibers.utils.bytes.lua' + +local ok_ffi_backend, ffi_backend = pcall(require, 'fibers.utils.bytes.ffi') +if not ok_ffi_backend then + ffi_backend = nil +end + +local function assert_eq(actual, expected, msg) + if actual ~= expected then + error(string.format( + '%s (expected %q, got %q)', + msg or 'assert_eq failed', + tostring(expected), + tostring(actual) + )) + end +end + +---------------------------------------------------------------------- +-- Helpers +---------------------------------------------------------------------- + +local function random_bytes(len) + -- Deterministic pseudo-random ASCII payload; simple but fine for testing. + local t = {} + for i = 1, len do + local b = 32 + ((i * 37) % 90) -- printable range + t[i] = string.char(b) + end + return table.concat(t) +end + +---------------------------------------------------------------------- +-- RingBuf stress: random reads/writes checked against string model +---------------------------------------------------------------------- + +local function stress_ring(impl, label) + local RingBuf = impl.RingBuf + print((' RingBuf stress (%s)...'):format(label)) + + -- Use a modest capacity so wrap-around / full conditions are exercised. + local cap = 4096 + local rb = RingBuf.new(cap) + + -- Reference model: Lua string containing unread data. + local ref = '' + + math.randomseed(12345) + + local iterations = 20000 + + for step = 1, iterations do + local do_write + if #ref == 0 then + do_write = true + elseif rb:write_avail() == 0 then + do_write = false + else + do_write = (math.random() < 0.5) + end + + if do_write then + -- Write between 1 and 64 bytes, limited by write_avail. + local max_len = math.min(64, rb:write_avail()) + if max_len > 0 then + local len = math.random(1, max_len) + local chunk = random_bytes(len) + + rb:put(chunk) + ref = ref .. chunk + end + else + -- Read between 1 and 32 bytes, limited by read_avail/ref length. + local avail = rb:read_avail() + if avail > 0 then + local max_len = math.min(32, avail, #ref) + local len = math.random(1, max_len) + + local got = rb:take(len) + local exp = ref:sub(1, len) + if got ~= exp then + error(string.format( + 'RingBuf stress mismatch at step %d: expected %q, got %q', + step, exp, got + )) + end + ref = ref:sub(len + 1) + end + end + + -- Occasionally cross-check the snapshot. + if step % 5000 == 0 then + local snap = rb:tostring() + if snap ~= ref then + error(string.format('RingBuf snapshot mismatch at step %d', step)) + end + end + end + + -- Final consistency check. + local snap = rb:tostring() + assert_eq(snap, ref, 'RingBuf final snapshot mismatch') +end + +---------------------------------------------------------------------- +-- LinearBuf stress: random appends and resets +---------------------------------------------------------------------- + +local function stress_linear(impl, label) + local LinearBuf = impl.LinearBuf + print((' LinearBuf stress (%s)...'):format(label)) + + -- FFI backend may honour a capacity; Lua backend may ignore it. + local cap = 128 + local lb = LinearBuf.new(cap) + + local ref = '' + math.randomseed(54321) + + local iterations = 10000 + + for step = 1, iterations do + -- Occasionally reset to exercise that path as well. + if step % 2000 == 0 then + lb:reset() + ref = '' + end + + local len = math.random(0, 128) + if len == 0 then + -- Occasionally just check without changing. + local snap = lb:tostring() + if snap ~= ref then + error(string.format('LinearBuf snapshot mismatch at step %d', step)) + end + else + local chunk = random_bytes(len) + lb:append(chunk) + ref = ref .. chunk + end + + if step % 2500 == 0 then + local snap = lb:tostring() + if snap ~= ref then + error(string.format('LinearBuf snapshot mismatch at step %d', step)) + end + end + end + + local snap = lb:tostring() + assert_eq(snap, ref, 'LinearBuf final snapshot mismatch') +end + +---------------------------------------------------------------------- +-- Run backends +---------------------------------------------------------------------- + +local function run_backend(label, impl) + print(('testing bytes backend (stress): %s'):format(label)) + + if not impl or not impl.RingBuf or not impl.LinearBuf then + error(('backend %s missing RingBuf/LinearBuf'):format(label)) + end + + stress_ring(impl, label) + stress_linear(impl, label) + + print(('backend %s: OK\n'):format(label)) +end + +print('testing fibers.utils.bytes (stress)') + +-- Always test pure Lua backend +if not lua_backend or not lua_backend.is_supported or not lua_backend.is_supported() then + error('Lua bytes backend not available or not supported') +end +run_backend('lua', lua_backend) + +-- Test FFI backend if present and supported +if ffi_backend and ffi_backend.is_supported and ffi_backend.is_supported() then + run_backend('ffi', ffi_backend) +else + print('ffi backend not available or not supported; skipping FFI stress\n') +end + +-- Also test the shim (which chooses a default backend) +if bytes_shim and bytes_shim.RingBuf and bytes_shim.LinearBuf then + run_backend('shim', bytes_shim) +else + error('bytes shim backend missing RingBuf/LinearBuf') +end + +print('all bytes stress tests done') diff --git a/tests/test_utils-dlist.lua b/tests/test_utils-dlist.lua new file mode 100644 index 0000000..a305c15 --- /dev/null +++ b/tests/test_utils-dlist.lua @@ -0,0 +1,75 @@ +print('testing: fibers.utils.dlist') + +package.path = '../src/?.lua;' .. package.path + +local dlist = require 'fibers.utils.dlist' + +local function assert_eq(a, b, msg) + if a ~= b then + error((msg or 'assert_eq failed') .. (': expected ' .. tostring(b) .. ', got ' .. tostring(a)), 2) + end +end + +local function test_push_pop_length_and_empty() + local q = dlist.new() + assert_eq(q:empty(), true) + assert_eq(q:length(), 0) + + q:push_tail('a') + q:push_tail('b') + assert_eq(q:empty(), false) + assert_eq(q:length(), 2) + assert_eq(q:peek_head(), 'a') + assert_eq(q:pop_head(), 'a') + assert_eq(q:length(), 1) + assert_eq(q:pop_head(), 'b') + assert_eq(q:length(), 0) + assert_eq(q:empty(), true) + assert_eq(q:pop_head(), nil) +end + +local function test_remove_middle_preserves_order() + local q = dlist.new() + q:push_tail('a') + local b = q:push_tail('b') + q:push_tail('c') + + assert_eq(b:remove(), true) + assert_eq(q:length(), 2) + assert_eq(q:pop_head(), 'a') + assert_eq(q:pop_head(), 'c') + assert_eq(q:pop_head(), nil) +end + +local function test_remove_front_and_tail() + local q = dlist.new() + local a = q:push_tail('a') + local b = q:push_tail('b') + local c = q:push_tail('c') + + assert_eq(a:remove(), true) + assert_eq(c:remove(), true) + assert_eq(q:length(), 1) + assert_eq(q:pop_head(), 'b') + assert_eq(q:empty(), true) + assert_eq(b:remove(), false) +end + +local function test_double_remove_is_harmless() + local q = dlist.new() + local node = q:push_tail('x') + assert_eq(node:remove(), true) + assert_eq(node:remove(), false) + assert_eq(q:length(), 0) + assert_eq(q:empty(), true) +end + +local function main() + test_push_pop_length_and_empty() + test_remove_middle_preserves_order() + test_remove_front_and_tail() + test_double_remove_is_harmless() + print('All dlist tests passed!') +end + +main() diff --git a/tests/test_utils-fixed_buffer.lua b/tests/test_utils-fixed_buffer.lua deleted file mode 100644 index 45fef02..0000000 --- a/tests/test_utils-fixed_buffer.lua +++ /dev/null @@ -1,94 +0,0 @@ ---- Tests the fixed_buffer implementation. -print('testing: fibers.utils.fixed_buffer') - -package.path = "../?.lua;" .. package.path - -local buffer = require 'fibers.utils.fixed_buffer' -local sc = require 'fibers.utils.syscall' -local ffi = sc.is_LuaJIT and require 'ffi' or require 'cffi' - -local equal = require 'fibers.utils.helper'.equal - -local function assert_throws(f, ...) - local success, ret = pcall(f, ...) - assert(not success, "expected failure but got " .. tostring(ret)) -end - -local function assert_avail(b, readable, writable) - assert(b:read_avail() == readable) - assert(b:write_avail() == writable) -end - -local function write_str(b, str) - local scratch = ffi.new('uint8_t[?]', #str) - ffi.copy(scratch, str, #str) - b:write(scratch, #str) -end - -local function read_str(b, count) - local scratch = ffi.new('uint8_t[?]', count) - b:read(scratch, count) - return ffi.string(scratch, count) -end - -local function test_basic() - assert_throws(buffer.new, 0) - - local b = buffer.new(16) - assert_avail(b, 0, 16) - - for _ = 1, 10 do - local s = "hello" - write_str(b, s) - assert(read_str(b, #s) == s) - assert_avail(b, 0, 16) - end - - -- Peek and skip - write_str(b, "abc") - local ptr, len = b:peek() - assert(len >= 3) - assert(ffi.string(ptr, 3) == "abc") - b:advance_read(3) - assert(b:is_empty()) -end - -local function test_find() - local b = buffer.new(64) - write_str(b, "abcdef\n12345") - assert(b:find("\n123") == 6) - b:advance_read(7) - assert(read_str(b, 5) == "12345") -end - -local function test_large_buffer() - local size = 2 ^ 20 -- 1 MB - local data = ffi.new('uint8_t[?]', size) - for i = 0, size - 1 do - data[i] = i % 256 - end - - local b = buffer.new(size) - - local start = sc.monotime() - b:write(data, size) - local write_time = sc.monotime() - start - print(string.format("Wrote %d MB in %d microseconds", size / 2^20, write_time * 1e6)) - - local read_buf = ffi.new('uint8_t[?]', size) - start = sc.monotime() - b:read(read_buf, size) - local read_time = sc.monotime() - start - print(string.format("Read %d MB in %d microseconds", size / 2^20, read_time * 1e6)) - - start = sc.monotime() - assert(equal(data, read_buf), "Data mismatch") - local verify_time = sc.monotime() - start - print(string.format("Verified in %d microseconds", verify_time * 1e6)) -end - -test_basic() -test_find() -test_large_buffer() - -print("test: ok") diff --git a/tests/test_wait.lua b/tests/test_wait.lua new file mode 100644 index 0000000..2b637a8 --- /dev/null +++ b/tests/test_wait.lua @@ -0,0 +1,143 @@ +-- tests/test_wait.lua +print('testing: fibers.wait') + +-- look one level up +package.path = '../src/?.lua;' .. package.path + +local wait = require 'fibers.wait' +local op = require 'fibers.op' +local fibers = require 'fibers' + +---------------------------------------------------------------------- +-- Simple assertion helper +---------------------------------------------------------------------- + +local function check(name, fn) + io.write(name, ' ... ') + fn() + io.write('ok\n') +end + +---------------------------------------------------------------------- +-- Waitset tests +---------------------------------------------------------------------- + +check('Waitset add/unlink/notify_all', function () + local ws = wait.new_waitset() + + local scheduled = {} + local fake_sched = { + schedule = function (self, task) + scheduled[#scheduled + 1] = task + end, + } + + local t1 = { run = function () end } + local t2 = { run = function () end } + + local tok1 = ws:add('k', t1) + local _ = ws:add('k', t2) + + assert(ws:size('k') == 2, 'size after add should be 2') + + -- Unlink first token; bucket should still be non-empty. + local emptied = tok1:unlink() + assert(emptied == false, 'unlink of first waiter should not empty bucket') + assert(ws:size('k') == 1, 'size after unlink should be 1') + + -- notify_all should schedule remaining task and clear bucket. + ws:notify_all('k', fake_sched) + assert(#scheduled == 1 and scheduled[1] == t2, 'notify_all should schedule remaining waiter') + assert(ws:is_empty('k'), 'bucket should be empty after notify_all') + + -- Unlink on already-unlinked token is a no-op. + emptied = tok1:unlink() + assert(emptied == false, 'unlink on stale token should be benign') +end) + +check('Waitset notify_one', function () + local ws = wait.new_waitset() + + local scheduled = {} + local fake_sched = { + schedule = function (self, task) + scheduled[#scheduled + 1] = task + end, + } + + local t1 = { run = function () end } + local t2 = { run = function () end } + + ws:add('k', t1) + ws:add('k', t2) + + ws:notify_one('k', fake_sched) + assert(#scheduled == 1, 'notify_one should schedule exactly one task') + assert(ws:size('k') == 1, 'one waiter should remain after notify_one') + + ws:notify_one('k', fake_sched) + assert(#scheduled == 2, 'second notify_one should schedule second task') + assert(ws:is_empty('k'), 'bucket should be empty after second notify_one') +end) + +---------------------------------------------------------------------- +-- waitable tests +---------------------------------------------------------------------- + +-- 1. Fast path: step completes immediately; register() is never called. +check('waitable fast path (no blocking)', function () + local step_calls = 0 + + local function step() + step_calls = step_calls + 1 + -- done == true, plus two result values. + return true, 'ok', 42 + end + + local register_called = false + local function register(_, _, _) + register_called = true + error('register should not be called on fast path') + end + + local ev = wait.waitable(register, step) + + local a, b = op.perform_raw(ev) + assert(step_calls == 1, 'step should be called once on fast path') + assert(a == 'ok' and b == 42, 'results should be returned from step') + assert(not register_called, 'register should not be called on fast path') +end) + +-- 2. Blocking path: first step() returns done=false, second returns done=true. +-- We run this under the scheduler so the suspension path is exercised. +check('waitable blocking path under scheduler', function () + local step_calls = 0 + + local function step() + step_calls = step_calls + 1 + if step_calls == 1 then + -- Not ready on first probe. + return false + end + -- Ready on second probe. + return true, 'ready' + end + + -- register(): in a real backend this would arrange for task:run() + -- once the external condition changes. For this test we just run + -- the task immediately, which still exercises the suspension logic. + local function register(task, _, _) + task:run() + return { unlink = function () end } + end + + local ev = wait.waitable(register, step) + + fibers.run(function () + local res = fibers.perform(ev) + assert(res == 'ready', "waitable should eventually return 'ready'") + assert(step_calls == 2, 'step should be called twice (try + one wake)') + end) +end) + +io.write('All wait.lua tests completed\n') diff --git a/tests/test_waitgroup.lua b/tests/test_waitgroup.lua index 1b87a10..3cc5f5d 100644 --- a/tests/test_waitgroup.lua +++ b/tests/test_waitgroup.lua @@ -2,90 +2,126 @@ print('testing: fibers.waitgroup') -- look one level up -package.path = "../?.lua;" .. package.path +package.path = '../src/?.lua;' .. package.path -- test_waitgroup.lua -local fiber = require 'fibers.fiber' +local fibers = require 'fibers' local sleep = require 'fibers.sleep' -local op = require 'fibers.op' local waitgroup = require 'fibers.waitgroup' -local sc = require 'fibers.utils.syscall' +local time = require 'fibers.utils.time' + +local perform, choice = fibers.perform, fibers.choice local function test_nowait() - local wg = waitgroup.new() - wg:wait_op():perform_alt(function() - error("blocked on empty waitgroup") - end) - print("No wait test: ok") + local wg = waitgroup.new() + local task = wg:wait_op():or_else(function () + error('blocked on empty waitgroup') + end) + perform(task) + print('No wait test: ok') end local function test_simple() - local wg = waitgroup.new() - local numFibers = 5 - - -- Spawn fibers and add to the waitgroup - for _ = 1, numFibers do - wg:add(1) - fiber.spawn(function() - sleep.sleep(math.random()) -- Simulate some work - wg:done() - end) - end - - wg:wait_op():wrap(function() - error("waitgroup didn't block when it should have") - end):perform_alt(function() end) - - wg:wait() - print("Simple test: ok") + local wg = waitgroup.new() + local numFibers = 5 + + -- Spawn fibers and add to the waitgroup + for _ = 1, numFibers do + wg:add(1) + fibers.spawn(function () + sleep.sleep(0.1) -- Simulate some work + wg:done() + end) + end + + perform( + wg:wait_op():wrap(function () + error("waitgroup didn't block when it should have") + end) + :or_else(function () end) + ) + + wg:wait() + print('Simple test: ok') +end + +local function test_reuse() + local wg = waitgroup.new() + -- Spawn fibers and add to the waitgroup + wg:add(1) + fibers.spawn(function () + sleep.sleep(0.1) -- Simulate some work + wg:done() + end) + + wg:wait() + + wg:add(1) + local blocked = false + fibers.spawn(function () + sleep.sleep(0.1) -- Simulate some work + wg:done() + end) + + perform( + wg:wait_op() + :or_else(function () blocked = true end) + ) + assert(blocked, 'Reused Waitgroup should block.') + + wg:wait() + + print('Reuse test: ok') end + local function test_complex() - local wg = waitgroup.new() - local numFibers = 5 - - local function one_sec_work(w) - w:add(1) - fiber.spawn(function() - sleep.sleep(1) -- Simulate some work - w:done() - end) - end - - local start = sc.monotime() - - -- Spawn fibers and add to the waitgroup - for _ = 1, numFibers do one_sec_work(wg) end - - local done = false - - local extra_work_done = false - local function extra_work() - if not extra_work_done then - extra_work_done = true - one_sec_work(wg) - end - end - - while not done do - op.choice( - wg:wait_op():wrap(function() done = true end), - sleep.sleep_op(0.9):wrap(extra_work) - ):perform() - end - - assert(sc.monotime() - start > 1.5) - print("Complex test: ok") + local wg = waitgroup.new() + local numFibers = 5 + + local function one_sec_work(w) + w:add(1) + fibers.spawn(function () + sleep.sleep(0.1) -- Simulate some work + w:done() + end) + end + + local start = time.monotonic() + + -- Spawn fibers and add to the waitgroup + for _ = 1, numFibers do one_sec_work(wg) end + + local done = false + + local extra_work_done = false + local function extra_work() + if not extra_work_done then + extra_work_done = true + one_sec_work(wg) + end + end + + while not done do + perform( + choice( + wg:wait_op():wrap(function () done = true end), + sleep.sleep_op(0.09):wrap(extra_work) + ) + ) + end + + assert(time.monotonic() - start > 0.05) + print('Complex test: ok') end local function main() - test_nowait() - test_simple() - test_complex() - fiber.stop() + test_nowait() + test_simple() + test_reuse() + test_complex() end -- Start the main function in fiber context -fiber.spawn(main) -fiber.main() +fibers.run(main)