diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21bf4fb..a014fa6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,23 @@ env: jobs: test: runs-on: ubuntu-latest + strategy: + # Both legs must report: a debug-flavour failure is a real memory bug and + # cancelling the release leg over it (or vice versa) hides half the story. + fail-fast: false + matrix: + # Which flavour of librayforce.a to link. `debug` compiles the core with + # -DDEBUG, arming the engine's stale retain/release detector (DFD) — see + # rayforce-sys/build.rs. ASan cannot see into the engine's mmap-backed + # pool allocator, so DFD is the only tool for this bug class. + core: [release, debug] + env: + # Job-scoped, not step-scoped: the core binary is built in its own step and + # must use the same flavour, or build.rs would see a flavour flip at + # `cargo test` time and `make clean` the binary away. Empty on the release + # leg — build.rs and the core's dfd_enabled() both read empty as off. + RAYFORCE_CORE_DEBUG: ${{ matrix.core == 'debug' && '1' || '' }} + RAY_DFD: ${{ matrix.core == 'debug' && '1' || '' }} steps: # The C core and the rayforce-q client are submodules under # rayforce-sys/vendor/, so this one checkout brings the whole build. @@ -45,14 +62,88 @@ jobs: ~/.cargo/registry ~/.cargo/git target - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.toml') }} + key: ${{ runner.os }}-cargo-${{ matrix.core }}-${{ hashFiles('**/Cargo.toml') }} + # Lints are flavour-independent; running them twice only doubles the wait. - name: Format check + if: matrix.core == 'release' run: cargo fmt --all -- --check - name: Clippy + if: matrix.core == 'release' run: cargo clippy --workspace --all-targets -- -D warnings + # tests/ipc.rs spawns a real server to exercise TcpClient — the path + # Runtime's poll teardown lives on. Without a binary it returns early and + # reports as a pass, so that coverage was silently absent from CI. + # + # Ordering matters: `cargo build` runs build.rs, which stages the vendored + # core into OUT_DIR and runs `make lib` there at this leg's flavour. That + # staged tree is what `make release` is pointed at, so it reuses those + # objects untouched and only adds main.c plus the link — a quarter second, + # not a second engine build. On the debug leg the engine objects carry DFD, + # so the server checks its own side of every exchange. (main.c itself is + # compiled at the Makefile's default RELEASE_CFLAGS either way. `-DDEBUG` + # reaches no struct in the public header — only the RAY_ASSERT_VALUE macro + # at rayforce.h:216 — so the two agree on layout.) + # + # The three make variables are read back out of build.rs rather than + # restated, the way scripts/check-vendored-pin.sh already reads them: the + # vendored build drops -Werror on purpose, and `git` resolving RAY_VERSION + # from inside OUT_DIR would stamp this repository's HEAD into the server + # binary instead of the core's. + - name: Build the core server binary + run: | + cargo build --workspace --tests + # Absolute: cargo runs each test binary with the cwd set to its own + # crate root, so a workspace-relative RAYFORCE_BINARY would not resolve. + CORE=$(ls -dt "$PWD"/target/debug/build/rayforce-sys-*/out/core 2>/dev/null | head -1) + if [ ! -f "$CORE/librayforce.a" ]; then + echo "::error::no staged core under target/debug/build — did build.rs run?"; exit 1 + fi + field() { sed -n "s/^const $1: &str = \"\(.*\)\";\$/\1/p" rayforce-sys/build.rs; } + make -C "$CORE" release \ + WARNS="$(field CORE_WARNS)" \ + RAY_VERSION="$(field CORE_VERSION)" \ + GIT_HASH="$(field CORE_COMMIT)" + echo "RAYFORCE_BINARY=$CORE/rayforce" >> "$GITHUB_ENV" + echo "RAYFORCE_REQUIRE_SERVER=1" >> "$GITHUB_ENV" + echo "RAYFORCE_CORE_DIR=$CORE" >> "$GITHUB_ENV" + + # The debug leg's whole purpose is that -DDEBUG reached the archive, and + # nothing checked that it did. A break in the RAYFORCE_CORE_DEBUG plumbing + # — an env rename, a build.rs refactor, a change in how Actions evaluates + # `&& '1' || ''` — would turn this leg into a second release run that stays + # green forever: the same shape as the tests/ipc.rs early return the step + # above closes. + # + # Both legs assert, in opposite directions. Without the release-leg + # control, a grep that matched nothing anywhere would pass too, which is + # precisely the failure being guarded against. + # + # This proves the detector is compiled in, not that it is armed. Arming is + # dfd_enabled() reading RAY_DFD (the core's src/mem/heap.c); the only proof + # of that is a deliberate double release, which aborts the process and so + # cannot live in the suite. + - name: Check the DFD detector matches the core flavour + run: | + # `grep -c`, not `grep -q`: `-q` exits on the first match, `nm` then + # takes SIGPIPE, and under `pipefail` the pipeline reports failure — + # so a present symbol reads as absent. That inverts this check into + # one that passes for the wrong reason on the release leg, which is + # the bug class the whole step exists to catch. Observed locally. + n=$(nm "$RAYFORCE_CORE_DIR/librayforce.a" | grep -c ray_dfd_check_live || true) + if [ "${{ matrix.core }}" = debug ]; then + if [ "$n" -eq 0 ]; then + echo "::error::debug archive carries no ray_dfd_check_live — DFD did not compile in, so this leg detects nothing" + exit 1 + fi + elif [ "$n" -ne 0 ]; then + echo "::error::release archive carries ray_dfd_check_live ($n) — this leg built the debug flavour" + exit 1 + fi + echo "${{ matrix.core }} archive: $n ray_dfd_check_live symbols, as expected" + - name: Test run: cargo test --workspace diff --git a/README.md b/README.md index 966a120..1b98ff8 100644 --- a/README.md +++ b/README.md @@ -51,28 +51,30 @@ copied element by element. ```rust use rayforce::{col, Runtime, Table, Value}; -let _rt = Runtime::new()?; // one live runtime per process - -let quotes = Table::new( - &["symbol", "bid", "ask"], - &[ - Value::sym_vec(&["AAPL", "AAPL", "AAPL", "GOOG", "GOOG", "GOOG"]), - Value::vec(&[100.0f64, 101.0, 102.0, 200.0, 201.0, 202.0]), - Value::vec(&[110.0f64, 111.0, 112.0, 210.0, 211.0, 212.0]), - ], -)?; - -let result = quotes - .select() - .agg("max_bid", col("bid").max()) - .agg("min_bid", col("bid").min()) - .agg("avg_ask", col("ask").avg()) - .agg("count", col("bid").count()) - .filter(col("bid").ge(110.0).and(col("ask").gt(100.0))) - .by("symbol") - .execute()?; - -println!("{result}"); +// One live runtime per process; the scope brackets its whole life. +Runtime::scope(|_rt| { + let quotes = Table::new( + &["symbol", "bid", "ask"], + &[ + Value::sym_vec(&["AAPL", "AAPL", "AAPL", "GOOG", "GOOG", "GOOG"]), + Value::vec(&[100.0f64, 101.0, 102.0, 200.0, 201.0, 202.0]), + Value::vec(&[110.0f64, 111.0, 112.0, 210.0, 211.0, 212.0]), + ], + )?; + + let result = quotes + .select() + .agg("max_bid", col("bid").max()) + .agg("min_bid", col("bid").min()) + .agg("avg_ask", col("ask").avg()) + .agg("count", col("bid").count()) + .filter(col("bid").ge(110.0).and(col("ask").gt(100.0))) + .by("symbol") + .execute()?; + + println!("{result}"); + Ok(()) +})?; ``` ```text @@ -146,6 +148,12 @@ export RAYFORCE_SRC=/path/to/rayforce export RAYFORCE_Q_SRC=/path/to/rayforce-q ``` +Such a checkout is built in place, so incremental state is preserved — except across a +core-flavour switch. Release and debug objects share every filename, so the first build +after `RAYFORCE_CORE_DEBUG` changes drops every object under `src/` and the +`librayforce.a` beside them, and records the flags in an untracked `.stamp`. Nothing +tracked by git is touched. + `bindgen` locates `libclang` via `LIBCLANG_PATH`. This is deliberately **not** set in the repo's `.cargo/config.toml`. If bindgen can't auto-detect libclang, set it yourself: diff --git a/docs/docs/content/CHANGELOG.md b/docs/docs/content/CHANGELOG.md index b1691cb..a1dd126 100644 --- a/docs/docs/content/CHANGELOG.md +++ b/docs/docs/content/CHANGELOG.md @@ -3,6 +3,116 @@ All notable changes to `rayforce` are documented here. This project adheres to [Semantic Versioning](https://semver.org). +## Unreleased + +### Added + +- **CI runs the suite against a debug-flavour engine.** Set + `RAYFORCE_CORE_DEBUG=1` and `rayforce-sys` builds `librayforce.a` with + `-DDEBUG`, which compiles in the core's invariant checks and its stale + retain/release detector; arm it at runtime with `RAY_DFD=1`. This is the only + tool that sees a use-after-free inside the engine's `mmap`-backed pool + allocator — AddressSanitizer and Valgrind track `malloc`, which the engine + never calls, and Miri cannot execute the C library at all. The `test` job now + runs both flavours; the debug leg reproduces the `Value`-outliving-`Runtime` + crash below on the commit before its fix. Both legs then assert the archive + they built: `ray_dfd_check_live` must be present on the debug leg and absent on + the release one. Without that pair, a break in the `RAYFORCE_CORE_DEBUG` + plumbing would turn the debug leg into a second release run that stays green. + +- **The IPC tests run in CI.** `tests/ipc.rs` drives `TcpClient` against a + spawned server and was returning early for want of one — which reports as a + pass, so the gap was invisible. CI now builds the server binary, and + `RAYFORCE_REQUIRE_SERVER=1` turns a missing one into a failure rather than a + skip. `tests/q_real.rs` still opts out via `RAYFORCE_Q_ADDR`: it needs a real + `q` server, which cannot be provisioned on a runner. + +### Changed + +- **A core-flavour switch rebuilds a `RAYFORCE_SRC` checkout from scratch.** + Release and debug objects share every filename and `make` tracks headers but + not flags, so a flavour flip would otherwise archive a mixed library. The + build script now drops every object under the core's `src/` and the + `librayforce.a` beside them on the first build after the flags change, and + records them in an untracked `.stamp` file — in your own checkout as well as + under `OUT_DIR`, which previously had the only such check. Nothing tracked by + git is touched. + +- **Breaking: `Runtime::scope` replaces `Runtime::new`.** `Runtime::new` is + private; the only way to a runtime is + `Runtime::scope(|rt| { … })`, which creates it, hands the closure a + `&Runtime` you cannot drop or move out of, and tears it down when the closure + returns — on the error path and on unwind alike. A nested scope errors rather + than starting a second runtime. Migration is mechanical: delete + `let _rt = Runtime::new()?;`, wrap the body, end it with `Ok(())`. + +- **Nothing engine-backed leaves a scope.** `Runtime::scope` requires `Send` of + its return type and of the closure, and `Value`, `Table`, `Fn`, `TcpClient` + and `QConnection` are all `!Send` — so returning one, or assigning one into a + variable declared outside, is a compile error reading `required by a bound in + Runtime::scope`. The cost is that an unrelated `!Send` capture (an `Rc`, a + `RefCell` borrow) is refused too, with a diagnostic about threads when no + thread is involved; construct such values inside the closure, or move them in. + +- **Breaking: `is_live()` is now `on_runtime_thread()`**, and answers a + per-thread question rather than a per-process one. A live runtime is required + for everything except reading and dropping handles you already hold: `eval`, + `set_global`, `get_global`, the value constructors and the connection + constructors all answer to this one predicate, which is true only inside a + scope *and* only on the thread that entered it. A `false` result does not mean + a runtime can be created — one may be live on another thread, and + `Runtime::scope` says so. + +### Fixed + +- **Engine calls from another thread are refused instead of segfaulting.** The + liveness flag was a process-wide `AtomicBool`, but everything it guards is + thread-local: the core's VM (`__VM`) and heap (`ray_tl_heap`) both are. So + inside a scope, any other thread saw a live runtime and every guard passed — + `std::thread::spawn(|| rayforce::eval("(+ 1 1)"))` crashed in `ray_eval_str`, + which dereferences `__VM` with no null check, from safe code with no `unsafe` + anywhere. Constructors were quieter but not better: off-thread + `Value::sym("hello")` succeeded, allocating into a per-thread heap that no + `ray_runtime_destroy` would ever unmap. The guard is now a thread-local, so + those calls panic naming the thread; creating a runtime stays process-wide, + because the core's `__RUNTIME` is an unguarded global that a second + `ray_runtime_create` would overwrite in silence. + +- **A `Value` can no longer outlive its `Runtime`.** Dropping the runtime + unmaps the engine heap, so a handle still alive afterwards released into + memory that is no longer mapped. No check at the point of use could have + helped: `ray_t.rc` counts references to an *object*, while + `ray_runtime_destroy` munmaps every pool without consulting it, and by the + time a stale handle is used the thing to check is the pointer — which is what + became invalid. `Runtime::scope` removes the shape instead: the closure's + locals are dropped before the runtime is, and its `Send` bounds stop a value + leaving. `Value` stays one pointer wide, with no bookkeeping on clone or drop. + +- **The connection types are confined to their scope too.** `TcpClient` and + `QConnection` had no liveness tracking of any kind, so a client outliving its + `Runtime` called `ray_ipc_close` / `q_close` against an unmapped heap. Both + are now `!Send`/`!Sync` with `compile_fail` markers pinning it, which is what + the scope's bounds read, and both `Drop`s run before the runtime's. + +- **Building a value requires a live `Runtime`.** `Value::i64(1)` with no runtime + was safe Rust calling straight into the engine with no check at all. It did not + crash, which is why it went unnoticed: `ray_alloc` lazily maps a heap when none + exists, so the value landed in an orphan one. The sharp case was symbols, which + are runtime-scoped — `Value::sym("hello")` returned an *empty* symbol, dropping + the string with no error anywhere. + +- **The runtime tears down its event loop.** `TcpClient::connect` installs a + poll on first use and `ray_runtime_destroy` does not touch it, so it leaked. + `Runtime`'s `Drop` now takes it down first, while the heap it releases + selector state into is still there. + +- **`QConnection` is `!Send`/`!Sync`**, like every other handle in the crate. + It was a bare file descriptor, so it inferred both, while `execute` interns + symbols and builds engine objects that belong to the runtime's thread. + +- Building with `--no-default-features` (no `chrono`) is now warning-free. + + ## 1.0.1 ### Added diff --git a/docs/docs/content/documentation/data-types/boolean.md b/docs/docs/content/documentation/data-types/boolean.md index 886c465..d57db62 100644 --- a/docs/docs/content/documentation/data-types/boolean.md +++ b/docs/docs/content/documentation/data-types/boolean.md @@ -6,7 +6,8 @@ with `Value::bool_vec`. !!! note "Assume a live runtime" ```rust use rayforce::{Runtime, Value}; - let _rt = Runtime::new()?; + // every snippet below runs inside: + Runtime::scope(|rt| { /* … */ })?; ``` ## Atoms diff --git a/docs/docs/content/documentation/data-types/dict.md b/docs/docs/content/documentation/data-types/dict.md index 58a5f21..7423a45 100644 --- a/docs/docs/content/documentation/data-types/dict.md +++ b/docs/docs/content/documentation/data-types/dict.md @@ -8,7 +8,8 @@ block of a [table](../table/overview.md). !!! note "Assume a live runtime" ```rust use rayforce::{Runtime, Value}; - let _rt = Runtime::new()?; + // every snippet below runs inside: + Runtime::scope(|rt| { /* … */ })?; ``` ## Construction diff --git a/docs/docs/content/documentation/data-types/float.md b/docs/docs/content/documentation/data-types/float.md index 5269dda..8d2b46f 100644 --- a/docs/docs/content/documentation/data-types/float.md +++ b/docs/docs/content/documentation/data-types/float.md @@ -6,7 +6,8 @@ double-precision `F64`. !!! note "Assume a live runtime" ```rust use rayforce::{Runtime, Value}; - let _rt = Runtime::new()?; + // every snippet below runs inside: + Runtime::scope(|rt| { /* … */ })?; ``` ## Atoms diff --git a/docs/docs/content/documentation/data-types/functions.md b/docs/docs/content/documentation/data-types/functions.md index 5be17e7..a60fcf5 100644 --- a/docs/docs/content/documentation/data-types/functions.md +++ b/docs/docs/content/documentation/data-types/functions.md @@ -8,7 +8,8 @@ wraps a compiled function object built from Rayfall source — for example !!! note "Assume a live runtime" ```rust use rayforce::{Fn, Runtime, Value}; - let _rt = Runtime::new()?; + // every snippet below runs inside: + Runtime::scope(|rt| { /* … */ })?; ``` ## Construction diff --git a/docs/docs/content/documentation/data-types/guid.md b/docs/docs/content/documentation/data-types/guid.md index 021ea25..c242ee5 100644 --- a/docs/docs/content/documentation/data-types/guid.md +++ b/docs/docs/content/documentation/data-types/guid.md @@ -6,7 +6,8 @@ for keys that must be unique across machines without coordination. !!! note "Assume a live runtime" ```rust use rayforce::{Runtime, Value, Guid}; - let _rt = Runtime::new()?; + // every snippet below runs inside: + Runtime::scope(|rt| { /* … */ })?; ``` ## Constructor and reader diff --git a/docs/docs/content/documentation/data-types/integers.md b/docs/docs/content/documentation/data-types/integers.md index 208d2de..4cbd107 100644 --- a/docs/docs/content/documentation/data-types/integers.md +++ b/docs/docs/content/documentation/data-types/integers.md @@ -7,7 +7,8 @@ contiguous vector form. !!! note "Assume a live runtime" ```rust use rayforce::{Runtime, Value}; - let _rt = Runtime::new()?; + // every snippet below runs inside: + Runtime::scope(|rt| { /* … */ })?; ``` ## Atoms diff --git a/docs/docs/content/documentation/data-types/list.md b/docs/docs/content/documentation/data-types/list.md index 627f426..aeef4f0 100644 --- a/docs/docs/content/documentation/data-types/list.md +++ b/docs/docs/content/documentation/data-types/list.md @@ -7,7 +7,8 @@ mix atoms, vectors, dicts, or even nested lists. !!! note "Assume a live runtime" ```rust use rayforce::{Runtime, Value}; - let _rt = Runtime::new()?; + // every snippet below runs inside: + Runtime::scope(|rt| { /* … */ })?; ``` ## Construction diff --git a/docs/docs/content/documentation/data-types/overview.md b/docs/docs/content/documentation/data-types/overview.md index e3f27d7..d6dbc58 100644 --- a/docs/docs/content/documentation/data-types/overview.md +++ b/docs/docs/content/documentation/data-types/overview.md @@ -12,7 +12,8 @@ the payload. ```rust use rayforce::{Runtime, Value}; - let _rt = Runtime::new()?; + // every snippet below runs inside: + Runtime::scope(|rt| { /* … */ })?; ``` ## The value model diff --git a/docs/docs/content/documentation/data-types/string.md b/docs/docs/content/documentation/data-types/string.md index 3ebf255..0d0b5f1 100644 --- a/docs/docs/content/documentation/data-types/string.md +++ b/docs/docs/content/documentation/data-types/string.md @@ -7,7 +7,8 @@ or high-cardinality text. !!! note "Assume a live runtime" ```rust use rayforce::{Runtime, Value, Str}; - let _rt = Runtime::new()?; + // every snippet below runs inside: + Runtime::scope(|rt| { /* … */ })?; ``` ## Atoms diff --git a/docs/docs/content/documentation/data-types/symbol.md b/docs/docs/content/documentation/data-types/symbol.md index 97e3228..03c1823 100644 --- a/docs/docs/content/documentation/data-types/symbol.md +++ b/docs/docs/content/documentation/data-types/symbol.md @@ -8,7 +8,8 @@ comparison and storage operate on the id, not the characters. !!! note "Assume a live runtime" ```rust use rayforce::{Runtime, Value}; - let _rt = Runtime::new()?; + // every snippet below runs inside: + Runtime::scope(|rt| { /* … */ })?; ``` ## Atoms diff --git a/docs/docs/content/documentation/data-types/temporal.md b/docs/docs/content/documentation/data-types/temporal.md index fdb91a7..e54d97b 100644 --- a/docs/docs/content/documentation/data-types/temporal.md +++ b/docs/docs/content/documentation/data-types/temporal.md @@ -11,7 +11,8 @@ Rayforce has three temporal types, all measured against a **2000-01-01** epoch: !!! note "Assume a live runtime" ```rust use rayforce::{Runtime, Value}; - let _rt = Runtime::new()?; + // every snippet below runs inside: + Runtime::scope(|rt| { /* … */ })?; ``` ## Raw constructors and readers diff --git a/docs/docs/content/documentation/data-types/values.md b/docs/docs/content/documentation/data-types/values.md index ce48e12..26fc966 100644 --- a/docs/docs/content/documentation/data-types/values.md +++ b/docs/docs/content/documentation/data-types/values.md @@ -8,7 +8,8 @@ owns the [`Runtime`](../../get-started/overview.md). !!! note "Assume a live runtime" ```rust use rayforce::{Runtime, Value, ToValue, FromValue, Str, Guid}; - let _rt = Runtime::new()?; + // every snippet below runs inside: + Runtime::scope(|rt| { /* … */ })?; ``` ## Inspecting a value diff --git a/docs/docs/content/documentation/data-types/vector.md b/docs/docs/content/documentation/data-types/vector.md index 0431f60..bffa4bc 100644 --- a/docs/docs/content/documentation/data-types/vector.md +++ b/docs/docs/content/documentation/data-types/vector.md @@ -8,7 +8,8 @@ the page to read if you care about performance. !!! note "Assume a live runtime" ```rust use rayforce::{Runtime, Value}; - let _rt = Runtime::new()?; + // every snippet below runs inside: + Runtime::scope(|rt| { /* … */ })?; ``` The numeric element types are captured by the `VecElem` trait: diff --git a/docs/docs/content/documentation/ipc.md b/docs/docs/content/documentation/ipc.md index 1aa054c..fc02cc5 100644 --- a/docs/docs/content/documentation/ipc.md +++ b/docs/docs/content/documentation/ipc.md @@ -7,7 +7,8 @@ local) RayforceDB instance and exchange `Value`s with it. !!! note "Assume a live runtime" ```rust use rayforce::{Runtime, TcpClient, Value}; - let _rt = Runtime::new()?; + // every snippet below runs inside: + Runtime::scope(|rt| { /* … */ })?; ``` ## :material-server: Running a server @@ -34,9 +35,10 @@ call returns `Result`, so a failed connection is an error you handle. ```rust use rayforce::{Runtime, TcpClient}; -let _rt = Runtime::new()?; - -let client = TcpClient::connect("127.0.0.1", 5000, "", "")?; +Runtime::scope(|_rt| { + let client = TcpClient::connect("127.0.0.1", 5000, "", "")?; + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` @@ -50,17 +52,18 @@ returns the result as a `Value`. ```rust use rayforce::{Runtime, TcpClient}; -let _rt = Runtime::new()?; - -let client = TcpClient::connect("127.0.0.1", 5000, "", "")?; +Runtime::scope(|_rt| { + let client = TcpClient::connect("127.0.0.1", 5000, "", "")?; -// A scalar result. -let sum = client.execute("(+ 1 2)")?; -assert_eq!(sum.as_i64()?, 3); + // A scalar result. + let sum = client.execute("(+ 1 2)")?; + assert_eq!(sum.as_i64()?, 3); -// A vector result, read back zero-copy. -let v = client.execute("(til 5)")?; -assert_eq!(v.as_slice::()?, &[0, 1, 2, 3, 4]); + // A vector result, read back zero-copy. + let v = client.execute("(til 5)")?; + assert_eq!(v.as_slice::()?, &[0, 1, 2, 3, 4]); + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` @@ -68,10 +71,12 @@ Server-side errors come back as a `Result::Err`: ```rust use rayforce::{Runtime, TcpClient}; -let _rt = Runtime::new()?; -let client = TcpClient::connect("127.0.0.1", 5000, "", "")?; +Runtime::scope(|_rt| { + let client = TcpClient::connect("127.0.0.1", 5000, "", "")?; -assert!(client.execute("(undefined_symbol_xyz)").is_err()); + assert!(client.execute("(undefined_symbol_xyz)").is_err()); + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` @@ -82,11 +87,13 @@ value to the server, waits for the response, and returns it as a `Value`: ```rust use rayforce::{Runtime, TcpClient, Value}; -let _rt = Runtime::new()?; -let client = TcpClient::connect("127.0.0.1", 5000, "", "")?; +Runtime::scope(|_rt| { + let client = TcpClient::connect("127.0.0.1", 5000, "", "")?; -let payload = Value::vec(&[1i64, 2, 3]); -let reply = client.send(&payload)?; + let payload = Value::vec(&[1i64, 2, 3]); + let reply = client.send(&payload)?; + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` @@ -95,10 +102,12 @@ value and returns immediately: ```rust use rayforce::{Runtime, TcpClient, Value}; -let _rt = Runtime::new()?; -let client = TcpClient::connect("127.0.0.1", 5000, "", "")?; +Runtime::scope(|_rt| { + let client = TcpClient::connect("127.0.0.1", 5000, "", "")?; -client.send_async(&Value::sym("ping"))?; // returns () on success + client.send_async(&Value::sym("ping"))?; // returns () on success + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` @@ -108,17 +117,17 @@ client.send_async(&Value::sym("ping"))?; // returns () on success use rayforce::{Runtime, TcpClient}; fn main() -> rayforce::Result<()> { - let _rt = Runtime::new()?; + Runtime::scope(|_rt| { + // Connect to a server started with: rayforce -p 5000 + let client = TcpClient::connect("127.0.0.1", 5000, "", "")?; - // Connect to a server started with: rayforce -p 5000 - let client = TcpClient::connect("127.0.0.1", 5000, "", "")?; - - // Run a query remotely. - let result = client.execute("(+ 1 2)")?; - println!("server says: {}", result.as_i64()?); + // Run a query remotely. + let result = client.execute("(+ 1 2)")?; + println!("server says: {}", result.as_i64()?); - // The connection closes automatically when `client` is dropped. - Ok(()) + // The connection closes automatically when `client` is dropped. + Ok(()) + }) } ``` diff --git a/docs/docs/content/documentation/overview.md b/docs/docs/content/documentation/overview.md index 0e4f5c9..170d027 100644 --- a/docs/docs/content/documentation/overview.md +++ b/docs/docs/content/documentation/overview.md @@ -13,7 +13,8 @@ columns into [tables](table/overview.md), shape them with a fluent ```rust use rayforce::{Runtime, Value}; - let _rt = Runtime::new()?; + // every snippet below runs inside: + Runtime::scope(|rt| { /* … */ })?; ``` ## :material-map-outline: Map of the documentation diff --git a/docs/docs/content/documentation/query-guide/expressions.md b/docs/docs/content/documentation/query-guide/expressions.md index e356741..478ec79 100644 --- a/docs/docs/content/documentation/query-guide/expressions.md +++ b/docs/docs/content/documentation/query-guide/expressions.md @@ -7,7 +7,8 @@ combine them with operators and methods, and either hand the tree to a !!! note "Assume a live runtime" ```rust use rayforce::{col, lit, sum, avg, count, min, max, Expr, Operation, Runtime, Value}; - let _rt = Runtime::new()?; + // every snippet below runs inside: + Runtime::scope(|rt| { /* … */ })?; ``` ## `col` and `lit` diff --git a/docs/docs/content/documentation/query-guide/group-by.md b/docs/docs/content/documentation/query-guide/group-by.md index 20a1277..1c05798 100644 --- a/docs/docs/content/documentation/query-guide/group-by.md +++ b/docs/docs/content/documentation/query-guide/group-by.md @@ -7,8 +7,10 @@ once per group. !!! note "Assume a live runtime and the trades table" ```rust use rayforce::{col, sum, avg, max, Runtime, Table, Value}; - let _rt = Runtime::new()?; - let t = trades(); // sym / price / size — see the Overview + Runtime::scope(|_rt| { + let t = trades(); // sym / price / size — see the Overview + Ok(()) + })?; ``` ## Group by a column diff --git a/docs/docs/content/documentation/query-guide/insert.md b/docs/docs/content/documentation/query-guide/insert.md index 4ca7555..367df79 100644 --- a/docs/docs/content/documentation/query-guide/insert.md +++ b/docs/docs/content/documentation/query-guide/insert.md @@ -6,8 +6,10 @@ !!! note "Assume a live runtime and the trades table" ```rust use rayforce::{Runtime, Table, Value}; - let _rt = Runtime::new()?; - let t = trades(); // sym / price / size — see the Overview + Runtime::scope(|_rt| { + let t = trades(); // sym / price / size — see the Overview + Ok(()) + })?; ``` ## `insert_row` — a single record diff --git a/docs/docs/content/documentation/query-guide/joins.md b/docs/docs/content/documentation/query-guide/joins.md index ae8df98..b6ec9bd 100644 --- a/docs/docs/content/documentation/query-guide/joins.md +++ b/docs/docs/content/documentation/query-guide/joins.md @@ -7,8 +7,10 @@ key column names: `inner_join(&other, &[on])`, `left_join(&other, &[on])`, and !!! note "Assume a live runtime and the trades table" ```rust use rayforce::{Runtime, Table, Value}; - let _rt = Runtime::new()?; - let t = trades(); // sym / price / size — see the Overview + Runtime::scope(|_rt| { + let t = trades(); // sym / price / size — see the Overview + Ok(()) + })?; ``` ## Inner join diff --git a/docs/docs/content/documentation/query-guide/order-by.md b/docs/docs/content/documentation/query-guide/order-by.md index 7a3424f..bd47d31 100644 --- a/docs/docs/content/documentation/query-guide/order-by.md +++ b/docs/docs/content/documentation/query-guide/order-by.md @@ -7,8 +7,10 @@ builder. The first argument is the list of columns to sort by; the second is a !!! note "Assume a live runtime and the trades table" ```rust use rayforce::{col, Runtime, Table, Value}; - let _rt = Runtime::new()?; - let t = trades(); // sym / price / size — see the Overview + Runtime::scope(|_rt| { + let t = trades(); // sym / price / size — see the Overview + Ok(()) + })?; ``` ## Descending sort diff --git a/docs/docs/content/documentation/query-guide/overview.md b/docs/docs/content/documentation/query-guide/overview.md index b743c70..28a085a 100644 --- a/docs/docs/content/documentation/query-guide/overview.md +++ b/docs/docs/content/documentation/query-guide/overview.md @@ -11,7 +11,8 @@ expression tree (`Expr`), the engine compiles it to a Rayfall program, and ```rust use rayforce::{col, lit, sum, avg, count, min, max, Runtime, Table, Value}; - let _rt = Runtime::new()?; + // every snippet below runs inside: + Runtime::scope(|rt| { /* … */ })?; ``` ## The query model diff --git a/docs/docs/content/documentation/query-guide/select.md b/docs/docs/content/documentation/query-guide/select.md index 39b5620..7f886b0 100644 --- a/docs/docs/content/documentation/query-guide/select.md +++ b/docs/docs/content/documentation/query-guide/select.md @@ -7,8 +7,10 @@ columns, filters, grouping, and ordering, then call `.execute()` to get a !!! note "Assume a live runtime and the trades table" ```rust use rayforce::{col, sum, avg, count, min, max, Runtime, Table, Value}; - let _rt = Runtime::new()?; - let t = trades(); // sym / price / size — see the Overview + Runtime::scope(|_rt| { + let t = trades(); // sym / price / size — see the Overview + Ok(()) + })?; ``` ## Builder methods diff --git a/docs/docs/content/documentation/query-guide/update.md b/docs/docs/content/documentation/query-guide/update.md index d2691ed..cf4e8bc 100644 --- a/docs/docs/content/documentation/query-guide/update.md +++ b/docs/docs/content/documentation/query-guide/update.md @@ -7,8 +7,10 @@ optionally restrict the rows touched with `.filter`, and run with `.execute()`. !!! note "Assume a live runtime and the trades table" ```rust use rayforce::{col, Runtime, Table, Value}; - let _rt = Runtime::new()?; - let t = trades(); // sym / price / size — see the Overview + Runtime::scope(|_rt| { + let t = trades(); // sym / price / size — see the Overview + Ok(()) + })?; ``` ## Builder methods diff --git a/docs/docs/content/documentation/query-guide/upsert.md b/docs/docs/content/documentation/query-guide/upsert.md index 558c4be..24cd071 100644 --- a/docs/docs/content/documentation/query-guide/upsert.md +++ b/docs/docs/content/documentation/query-guide/upsert.md @@ -7,7 +7,8 @@ with a new key are **appended**. !!! note "Assume a live runtime" ```rust use rayforce::{Runtime, Table, Value}; - let _rt = Runtime::new()?; + // every snippet below runs inside: + Runtime::scope(|rt| { /* … */ })?; ``` ## The `key_columns` argument diff --git a/docs/docs/content/documentation/query-guide/where.md b/docs/docs/content/documentation/query-guide/where.md index b980d92..c442b01 100644 --- a/docs/docs/content/documentation/query-guide/where.md +++ b/docs/docs/content/documentation/query-guide/where.md @@ -6,8 +6,10 @@ predicate is any [expression](expressions.md) that evaluates to a boolean mask. !!! note "Assume a live runtime and the trades table" ```rust use rayforce::{col, Runtime, Table, Value}; - let _rt = Runtime::new()?; - let t = trades(); // sym / price / size — see the Overview + Runtime::scope(|_rt| { + let t = trades(); // sym / price / size — see the Overview + Ok(()) + })?; ``` ## A single filter diff --git a/docs/docs/content/documentation/serialization.md b/docs/docs/content/documentation/serialization.md index 5a866e8..1300b24 100644 --- a/docs/docs/content/documentation/serialization.md +++ b/docs/docs/content/documentation/serialization.md @@ -7,7 +7,8 @@ it is the **same wire format** the [IPC](ipc.md) layer uses on the network. !!! note "Assume a live runtime" ```rust use rayforce::{Runtime, Value}; - let _rt = Runtime::new()?; + // every snippet below runs inside: + Runtime::scope(|rt| { /* … */ })?; ``` ## :material-export: Serialize and deserialize @@ -17,13 +18,14 @@ it is the **same wire format** the [IPC](ipc.md) layer uses on the network. ```rust use rayforce::{Runtime, Value}; -let _rt = Runtime::new()?; - -let v = Value::i64(123456789); -let bytes = v.serialize()?; -let restored = Value::deserialize(&bytes)?; - -assert_eq!(restored.as_i64()?, 123456789); +Runtime::scope(|_rt| { + let v = Value::i64(123456789); + let bytes = v.serialize()?; + let restored = Value::deserialize(&bytes)?; + + assert_eq!(restored.as_i64()?, 123456789); + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` @@ -31,11 +33,12 @@ A round-trip preserves the value exactly — including its formatting: ```rust use rayforce::{Runtime, Value}; -let _rt = Runtime::new()?; - -let v = Value::vec(&[7i64, 8, 9]); -let back = Value::deserialize(&v.serialize()?)?; -assert_eq!(v.format(), back.format()); +Runtime::scope(|_rt| { + let v = Value::vec(&[7i64, 8, 9]); + let back = Value::deserialize(&v.serialize()?)?; + assert_eq!(v.format(), back.format()); + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` @@ -45,16 +48,17 @@ Every atom type round-trips: ```rust use rayforce::{Runtime, Value}; -let _rt = Runtime::new()?; - -fn roundtrip(v: &Value) -> rayforce::Result { - Value::deserialize(&v.serialize()?) -} - -assert_eq!(roundtrip(&Value::f64(123.456))?.as_f64()?, 123.456); -assert_eq!(roundtrip(&Value::sym("hello"))?.as_sym()?, "hello"); -assert_eq!(roundtrip(&Value::string("a string"))?.as_string()?, "a string"); -assert!(roundtrip(&Value::bool(true))?.as_bool()?); +Runtime::scope(|_rt| { + fn roundtrip(v: &Value) -> rayforce::Result { + Value::deserialize(&v.serialize()?) + } + + assert_eq!(roundtrip(&Value::f64(123.456))?.as_f64()?, 123.456); + assert_eq!(roundtrip(&Value::sym("hello"))?.as_sym()?, "hello"); + assert_eq!(roundtrip(&Value::string("a string"))?.as_string()?, "a string"); + assert!(roundtrip(&Value::bool(true))?.as_bool()?); + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` @@ -64,16 +68,17 @@ Vectors round-trip element-for-element; numeric vectors read back zero-copy: ```rust use rayforce::{Runtime, Value}; -let _rt = Runtime::new()?; - -let v = Value::vec(&[1i64, 2, 3, 4, 5]); -let back = Value::deserialize(&v.serialize()?)?; -assert_eq!(back.as_slice::()?, &[1, 2, 3, 4, 5]); - -let syms = Value::sym_vec(&["a", "bb", "ccc"]); -let back = Value::deserialize(&syms.serialize()?)?; -assert_eq!(back.len(), 3); -assert_eq!(back.get(1)?.as_sym()?, "bb"); +Runtime::scope(|_rt| { + let v = Value::vec(&[1i64, 2, 3, 4, 5]); + let back = Value::deserialize(&v.serialize()?)?; + assert_eq!(back.as_slice::()?, &[1, 2, 3, 4, 5]); + + let syms = Value::sym_vec(&["a", "bb", "ccc"]); + let back = Value::deserialize(&syms.serialize()?)?; + assert_eq!(back.len(), 3); + assert_eq!(back.get(1)?.as_sym()?, "bb"); + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` @@ -84,21 +89,22 @@ table's underlying value and rebuild it on the other side: ```rust use rayforce::{Runtime, Table, Value}; -let _rt = Runtime::new()?; - -let t = Table::new( - &["sym", "px"], - &[ - Value::sym_vec(&["AAPL", "MSFT"]), - Value::vec(&[100.0f64, 200.0]), - ], -)?; - -let bytes = t.as_value().serialize()?; -let restored = Value::deserialize(&bytes)?.as_table()?; - -assert_eq!(restored.shape(), (2, 2)); -assert_eq!(restored.column("px")?.as_slice::()?, &[100.0, 200.0]); +Runtime::scope(|_rt| { + let t = Table::new( + &["sym", "px"], + &[ + Value::sym_vec(&["AAPL", "MSFT"]), + Value::vec(&[100.0f64, 200.0]), + ], + )?; + + let bytes = t.as_value().serialize()?; + let restored = Value::deserialize(&bytes)?.as_table()?; + + assert_eq!(restored.shape(), (2, 2)); + assert_eq!(restored.column("px")?.as_slice::()?, &[100.0, 200.0]); + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` @@ -108,8 +114,10 @@ assert_eq!(restored.column("px")?.as_slice::()?, &[100.0, 200.0]); ```rust use rayforce::{Runtime, Value}; - let _rt = Runtime::new()?; - assert!(Value::deserialize(&[0u8, 1, 2, 3, 4, 5, 6, 7]).is_err()); + Runtime::scope(|_rt| { + assert!(Value::deserialize(&[0u8, 1, 2, 3, 4, 5, 6, 7]).is_err()); + Ok(()) + })?; # Ok::<(), rayforce::RayError>(()) ``` diff --git a/docs/docs/content/documentation/table/access-values.md b/docs/docs/content/documentation/table/access-values.md index 4d4bb1e..91d0a2b 100644 --- a/docs/docs/content/documentation/table/access-values.md +++ b/docs/docs/content/documentation/table/access-values.md @@ -6,16 +6,17 @@ shape, pull out whole columns, or read individual cells. ```rust use rayforce::{Runtime, Table, Value}; -let _rt = Runtime::new()?; - -let t = Table::new( - &["sym", "price", "size"], - &[ - Value::sym_vec(&["AAPL", "MSFT", "GOOG"]), - Value::vec(&[101.5f64, 202.0, 303.25]), - Value::vec(&[10i64, 20, 30]), - ], -)?; +Runtime::scope(|_rt| { + let t = Table::new( + &["sym", "price", "size"], + &[ + Value::sym_vec(&["AAPL", "MSFT", "GOOG"]), + Value::vec(&[101.5f64, 202.0, 303.25]), + Value::vec(&[10i64, 20, 30]), + ], + )?; + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` @@ -30,12 +31,14 @@ let t = Table::new( ```rust # use rayforce::{Runtime, Table, Value}; -# let _rt = Runtime::new()?; -# let t = Table::new(&["sym","price","size"], &[Value::sym_vec(&["AAPL","MSFT","GOOG"]), Value::vec(&[101.5f64,202.0,303.25]), Value::vec(&[10i64,20,30])])?; -assert_eq!(t.ncols(), 3); -assert_eq!(t.nrows(), 3); -assert_eq!(t.shape(), (3, 3)); -assert_eq!(t.column_names(), vec!["sym", "price", "size"]); +Runtime::scope(|_rt| { + # let t = Table::new(&["sym","price","size"], &[Value::sym_vec(&["AAPL","MSFT","GOOG"]), Value::vec(&[101.5f64,202.0,303.25]), Value::vec(&[10i64,20,30])])?; + assert_eq!(t.ncols(), 3); + assert_eq!(t.nrows(), 3); + assert_eq!(t.shape(), (3, 3)); + assert_eq!(t.column_names(), vec!["sym", "price", "size"]); + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` @@ -51,15 +54,17 @@ Lookups that miss return an `Err`. ```rust # use rayforce::{Runtime, Table, Value}; -# let _rt = Runtime::new()?; -# let t = Table::new(&["sym","price","size"], &[Value::sym_vec(&["AAPL","MSFT","GOOG"]), Value::vec(&[101.5f64,202.0,303.25]), Value::vec(&[10i64,20,30])])?; -let price = t.column("price")?; // by name -let size = t.column_at(2)?; // by index -let all = t.columns()?; // Vec, in column order - -assert_eq!(all.len(), 3); -assert!(t.column("nope").is_err()); // unknown name -> Err -assert!(t.column_at(9).is_err()); // out of range -> Err +Runtime::scope(|_rt| { + # let t = Table::new(&["sym","price","size"], &[Value::sym_vec(&["AAPL","MSFT","GOOG"]), Value::vec(&[101.5f64,202.0,303.25]), Value::vec(&[10i64,20,30])])?; + let price = t.column("price")?; // by name + let size = t.column_at(2)?; // by index + let all = t.columns()?; // Vec, in column order + + assert_eq!(all.len(), 3); + assert!(t.column("nope").is_err()); // unknown name -> Err + assert!(t.column_at(9).is_err()); // out of range -> Err + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` @@ -71,13 +76,15 @@ type (one of `u8 / i16 / i32 / i64 / f32 / f64`). ```rust # use rayforce::{Runtime, Table, Value}; -# let _rt = Runtime::new()?; -# let t = Table::new(&["sym","price","size"], &[Value::sym_vec(&["AAPL","MSFT","GOOG"]), Value::vec(&[101.5f64,202.0,303.25]), Value::vec(&[10i64,20,30])])?; -let price: &[f64] = t.column("price")?.as_slice()?; -assert_eq!(price, &[101.5, 202.0, 303.25]); - -let size: &[i64] = t.column_at(2)?.as_slice()?; -assert_eq!(size, &[10, 20, 30]); +Runtime::scope(|_rt| { + # let t = Table::new(&["sym","price","size"], &[Value::sym_vec(&["AAPL","MSFT","GOOG"]), Value::vec(&[101.5f64,202.0,303.25]), Value::vec(&[10i64,20,30])])?; + let price: &[f64] = t.column("price")?.as_slice()?; + assert_eq!(price, &[101.5, 202.0, 303.25]); + + let size: &[i64] = t.column_at(2)?.as_slice()?; + assert_eq!(size, &[10, 20, 30]); + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` @@ -94,13 +101,15 @@ then read with the matching `as_*` accessor. ```rust # use rayforce::{Runtime, Table, Value}; -# let _rt = Runtime::new()?; -# let t = Table::new(&["sym","price","size"], &[Value::sym_vec(&["AAPL","MSFT","GOOG"]), Value::vec(&[101.5f64,202.0,303.25]), Value::vec(&[10i64,20,30])])?; -let sym0 = t.column("sym")?.get(0)?.as_sym()?; -assert_eq!(sym0, "AAPL"); - -let price1 = t.column("price")?.get(1)?.as_f64()?; -assert_eq!(price1, 202.0); +Runtime::scope(|_rt| { + # let t = Table::new(&["sym","price","size"], &[Value::sym_vec(&["AAPL","MSFT","GOOG"]), Value::vec(&[101.5f64,202.0,303.25]), Value::vec(&[10i64,20,30])])?; + let sym0 = t.column("sym")?.get(0)?.as_sym()?; + assert_eq!(sym0, "AAPL"); + + let price1 = t.column("price")?.get(1)?.as_f64()?; + assert_eq!(price1, 202.0); + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` diff --git a/docs/docs/content/documentation/table/create.md b/docs/docs/content/documentation/table/create.md index 9460c78..154b7d8 100644 --- a/docs/docs/content/documentation/table/create.md +++ b/docs/docs/content/documentation/table/create.md @@ -11,18 +11,19 @@ per column. The columns are matched to names by position. ```rust use rayforce::{Runtime, Table, Value}; -let _rt = Runtime::new()?; - -let t = Table::new( - &["sym", "price", "size"], - &[ - Value::sym_vec(&["AAPL", "MSFT", "GOOG"]), // symbol column - Value::vec(&[101.5f64, 202.0, 303.25]), // f64 column - Value::vec(&[10i64, 20, 30]), // i64 column - ], -)?; - -assert_eq!(t.shape(), (3, 3)); // (rows, cols) +Runtime::scope(|_rt| { + let t = Table::new( + &["sym", "price", "size"], + &[ + Value::sym_vec(&["AAPL", "MSFT", "GOOG"]), // symbol column + Value::vec(&[101.5f64, 202.0, 303.25]), // f64 column + Value::vec(&[10i64, 20, 30]), // i64 column + ], + )?; + + assert_eq!(t.shape(), (3, 3)); // (rows, cols) + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` @@ -57,13 +58,15 @@ let syms = Value::sym_vec(&["a", "b", "c"]); ```rust # use rayforce::{Runtime, Table, Value}; -# let _rt = Runtime::new()?; -// mismatched row counts -> Err -let bad = Table::new( - &["a", "b"], - &[Value::vec(&[1i64, 2, 3]), Value::vec(&[10i64, 20])], -); -assert!(bad.is_err()); +Runtime::scope(|_rt| { + // mismatched row counts -> Err + let bad = Table::new( + &["a", "b"], + &[Value::vec(&[1i64, 2, 3]), Value::vec(&[10i64, 20])], + ); + assert!(bad.is_err()); + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` @@ -81,16 +84,18 @@ freely in both directions: ```rust # use rayforce::{Runtime, Table, Value}; -# let _rt = Runtime::new()?; -# let t = Table::new(&["x"], &[Value::vec(&[1i64, 2, 3])])?; -let v: Value = t.clone().into_value(); -assert!(v.is_table()); - -let back: Table = v.as_table()?; // or Table::from_value(v) -assert_eq!(back.nrows(), 3); - -// non-table values cannot become a Table -assert!(Value::i64(5).as_table().is_err()); +Runtime::scope(|_rt| { + # let t = Table::new(&["x"], &[Value::vec(&[1i64, 2, 3])])?; + let v: Value = t.clone().into_value(); + assert!(v.is_table()); + + let back: Table = v.as_table()?; // or Table::from_value(v) + assert_eq!(back.nrows(), 3); + + // non-table values cannot become a Table + assert!(Value::i64(5).as_table().is_err()); + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` diff --git a/docs/docs/content/documentation/table/overview.md b/docs/docs/content/documentation/table/overview.md index 702bf33..f9af7de 100644 --- a/docs/docs/content/documentation/table/overview.md +++ b/docs/docs/content/documentation/table/overview.md @@ -8,16 +8,17 @@ and every column must have the same number of rows. ```rust use rayforce::{Runtime, Table, Value}; -let _rt = Runtime::new()?; - -let t = Table::new( - &["sym", "price", "size"], - &[ - Value::sym_vec(&["AAPL", "MSFT", "GOOG"]), - Value::vec(&[101.5f64, 202.0, 303.25]), - Value::vec(&[10i64, 20, 30]), - ], -)?; +Runtime::scope(|_rt| { + let t = Table::new( + &["sym", "price", "size"], + &[ + Value::sym_vec(&["AAPL", "MSFT", "GOOG"]), + Value::vec(&[101.5f64, 202.0, 303.25]), + Value::vec(&[10i64, 20, 30]), + ], + )?; + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` @@ -61,11 +62,13 @@ before handing a table off to a transform or query. ```rust # use rayforce::{Runtime, Table, Value}; -# let _rt = Runtime::new()?; -# let t = Table::new(&["x"], &[Value::vec(&[1i64, 2, 3])])?; -let snapshot = t.clone(); -let trimmed = t.head(2)?; // original is untouched -assert_eq!(snapshot.nrows(), 3); +Runtime::scope(|_rt| { + # let t = Table::new(&["x"], &[Value::vec(&[1i64, 2, 3])])?; + let snapshot = t.clone(); + let trimmed = t.head(2)?; // original is untouched + assert_eq!(snapshot.nrows(), 3); + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` diff --git a/docs/docs/content/documentation/table/save-and-fetch.md b/docs/docs/content/documentation/table/save-and-fetch.md index c1b3734..2b3c449 100644 --- a/docs/docs/content/documentation/table/save-and-fetch.md +++ b/docs/docs/content/documentation/table/save-and-fetch.md @@ -11,18 +11,19 @@ column up front. ```rust use rayforce::{Runtime, Table, Value}; -let _rt = Runtime::new()?; - -let t = Table::new( - &["sym", "price", "size"], - &[ - Value::sym_vec(&["AAPL", "MSFT", "GOOG"]), - Value::vec(&[101.5f64, 202.0, 303.25]), - Value::vec(&[10i64, 20, 30]), - ], -)?; - -t.write_csv("/tmp/trades.csv")?; +Runtime::scope(|_rt| { + let t = Table::new( + &["sym", "price", "size"], + &[ + Value::sym_vec(&["AAPL", "MSFT", "GOOG"]), + Value::vec(&[101.5f64, 202.0, 303.25]), + Value::vec(&[10i64, 20, 30]), + ], + )?; + + t.write_csv("/tmp/trades.csv")?; + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` @@ -34,13 +35,15 @@ column. ```rust # use rayforce::{Runtime, Table, Value}; -# let _rt = Runtime::new()?; -# let t = Table::new(&["sym","price","size"], &[Value::sym_vec(&["AAPL","MSFT","GOOG"]), Value::vec(&[101.5f64,202.0,303.25]), Value::vec(&[10i64,20,30])])?; -# t.write_csv("/tmp/trades.csv")?; -let loaded = Table::read_csv(&["SYMBOL", "F64", "I64"], "/tmp/trades.csv")?; - -assert_eq!(loaded.shape(), (3, 3)); -assert_eq!(loaded.column("price")?.as_slice::()?, &[101.5, 202.0, 303.25]); +Runtime::scope(|_rt| { + # let t = Table::new(&["sym","price","size"], &[Value::sym_vec(&["AAPL","MSFT","GOOG"]), Value::vec(&[101.5f64,202.0,303.25]), Value::vec(&[10i64,20,30])])?; + # t.write_csv("/tmp/trades.csv")?; + let loaded = Table::read_csv(&["SYMBOL", "F64", "I64"], "/tmp/trades.csv")?; + + assert_eq!(loaded.shape(), (3, 3)); + assert_eq!(loaded.column("price")?.as_slice::()?, &[101.5, 202.0, 303.25]); + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` @@ -75,12 +78,14 @@ case-insensitive (`"i64"` works as well as `"I64"`). ```rust # use rayforce::{Runtime, Table}; -# let _rt = Runtime::new()?; -// these schemas are equivalent -# let _ = || -> rayforce::Result<()> { -let a = Table::read_csv(&["SYM", "F64", "I64"], "/tmp/trades.csv")?; -let b = Table::read_csv(&["symbol", "f64", "i64"], "/tmp/trades.csv")?; -# let _ = (a, b); Ok(()) }; +Runtime::scope(|_rt| { + // these schemas are equivalent + # let _ = || -> rayforce::Result<()> { + let a = Table::read_csv(&["SYM", "F64", "I64"], "/tmp/trades.csv")?; + let b = Table::read_csv(&["symbol", "f64", "i64"], "/tmp/trades.csv")?; + # let _ = (a, b); Ok(()) }; + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` diff --git a/docs/docs/content/documentation/table/splayed-and-parted.md b/docs/docs/content/documentation/table/splayed-and-parted.md index e4fdd20..17c0430 100644 --- a/docs/docs/content/documentation/table/splayed-and-parted.md +++ b/docs/docs/content/documentation/table/splayed-and-parted.md @@ -14,19 +14,20 @@ enumerated symbol columns are written against. ```rust use rayforce::{Runtime, Table, Value}; -let _rt = Runtime::new()?; - -let t = Table::new( - &["sym", "price", "size"], - &[ - Value::sym_vec(&["AAPL", "MSFT", "GOOG"]), - Value::vec(&[101.5f64, 202.0, 303.25]), - Value::vec(&[10i64, 20, 30]), - ], -)?; - -// this table has a symbol column, so supply a symfile path -t.save_splayed("/tmp/db/trades", Some("/tmp/db/sym"))?; +Runtime::scope(|_rt| { + let t = Table::new( + &["sym", "price", "size"], + &[ + Value::sym_vec(&["AAPL", "MSFT", "GOOG"]), + Value::vec(&[101.5f64, 202.0, 303.25]), + Value::vec(&[10i64, 20, 30]), + ], + )?; + + // this table has a symbol column, so supply a symfile path + t.save_splayed("/tmp/db/trades", Some("/tmp/db/sym"))?; + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` @@ -38,13 +39,15 @@ t.save_splayed("/tmp/db/trades", Some("/tmp/db/sym"))?; ```rust # use rayforce::{Runtime, Table, Value}; -# let _rt = Runtime::new()?; -// numeric-only table: no symfile required -let nums = Table::new( - &["a", "b"], - &[Value::vec(&[1i64, 2, 3]), Value::vec(&[1.0f64, 2.0, 3.0])], -)?; -nums.save_splayed("/tmp/db/nums", None)?; +Runtime::scope(|_rt| { + // numeric-only table: no symfile required + let nums = Table::new( + &["a", "b"], + &[Value::vec(&[1i64, 2, 3]), Value::vec(&[1.0f64, 2.0, 3.0])], + )?; + nums.save_splayed("/tmp/db/nums", None)?; + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` @@ -55,13 +58,15 @@ nums.save_splayed("/tmp/db/nums", None)?; ```rust # use rayforce::{Runtime, Table, Value}; -# let _rt = Runtime::new()?; -# let t = Table::new(&["sym","price","size"], &[Value::sym_vec(&["AAPL","MSFT","GOOG"]), Value::vec(&[101.5f64,202.0,303.25]), Value::vec(&[10i64,20,30])])?; -# t.save_splayed("/tmp/db/trades", Some("/tmp/db/sym"))?; -let loaded = Table::load_splayed("/tmp/db/trades", Some("/tmp/db/sym"))?; - -assert_eq!(loaded.shape(), (3, 3)); -assert_eq!(loaded.column("size")?.as_slice::()?, &[10, 20, 30]); +Runtime::scope(|_rt| { + # let t = Table::new(&["sym","price","size"], &[Value::sym_vec(&["AAPL","MSFT","GOOG"]), Value::vec(&[101.5f64,202.0,303.25]), Value::vec(&[10i64,20,30])])?; + # t.save_splayed("/tmp/db/trades", Some("/tmp/db/sym"))?; + let loaded = Table::load_splayed("/tmp/db/trades", Some("/tmp/db/sym"))?; + + assert_eq!(loaded.shape(), (3, 3)); + assert_eq!(loaded.column("size")?.as_slice::()?, &[10, 20, 30]); + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` @@ -74,11 +79,13 @@ partitions under `root` into a single table. ```rust # use rayforce::{Runtime, Table}; -# let _rt = Runtime::new()?; -# let _ = || -> rayforce::Result<()> { -let trades = Table::load_parted("/tmp/pdb", "trades")?; -println!("{} rows across all partitions", trades.nrows()); -# Ok(()) }; +Runtime::scope(|_rt| { + # let _ = || -> rayforce::Result<()> { + let trades = Table::load_parted("/tmp/pdb", "trades")?; + println!("{} rows across all partitions", trades.nrows()); + # Ok(()) }; + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` diff --git a/docs/docs/content/documentation/table/transform.md b/docs/docs/content/documentation/table/transform.md index 474b83d..1ee8e25 100644 --- a/docs/docs/content/documentation/table/transform.md +++ b/docs/docs/content/documentation/table/transform.md @@ -7,16 +7,17 @@ untouched. ```rust use rayforce::{Runtime, Table, Value}; -let _rt = Runtime::new()?; - -let t = Table::new( - &["sym", "price", "size"], - &[ - Value::sym_vec(&["AAPL", "MSFT", "GOOG"]), - Value::vec(&[101.5f64, 202.0, 303.25]), - Value::vec(&[10i64, 20, 30]), - ], -)?; +Runtime::scope(|_rt| { + let t = Table::new( + &["sym", "price", "size"], + &[ + Value::sym_vec(&["AAPL", "MSFT", "GOOG"]), + Value::vec(&[101.5f64, 202.0, 303.25]), + Value::vec(&[10i64, 20, 30]), + ], + )?; + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` @@ -24,11 +25,13 @@ let t = Table::new( ```rust # use rayforce::{Runtime, Table, Value}; -# let _rt = Runtime::new()?; -# let t = Table::new(&["sym","price","size"], &[Value::sym_vec(&["AAPL","MSFT","GOOG"]), Value::vec(&[101.5f64,202.0,303.25]), Value::vec(&[10i64,20,30])])?; -let top = t.head(2)?; -assert_eq!(top.nrows(), 2); -println!("{top}"); +Runtime::scope(|_rt| { + # let t = Table::new(&["sym","price","size"], &[Value::sym_vec(&["AAPL","MSFT","GOOG"]), Value::vec(&[101.5f64,202.0,303.25]), Value::vec(&[10i64,20,30])])?; + let top = t.head(2)?; + assert_eq!(top.nrows(), 2); + println!("{top}"); + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` @@ -48,10 +51,12 @@ println!("{top}"); ```rust # use rayforce::{Runtime, Table, Value}; -# let _rt = Runtime::new()?; -# let t = Table::new(&["sym","price","size"], &[Value::sym_vec(&["AAPL","MSFT","GOOG"]), Value::vec(&[101.5f64,202.0,303.25]), Value::vec(&[10i64,20,30])])?; -let bottom = t.tail(2)?; -assert_eq!(bottom.column("sym")?.get(0)?.as_sym()?, "MSFT"); +Runtime::scope(|_rt| { + # let t = Table::new(&["sym","price","size"], &[Value::sym_vec(&["AAPL","MSFT","GOOG"]), Value::vec(&[101.5f64,202.0,303.25]), Value::vec(&[10i64,20,30])])?; + let bottom = t.tail(2)?; + assert_eq!(bottom.column("sym")?.get(0)?.as_sym()?, "MSFT"); + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` @@ -63,13 +68,15 @@ the front (like `head`), and a **negative** `n` takes from the end (like ```rust # use rayforce::{Runtime, Table, Value}; -# let _rt = Runtime::new()?; -# let t = Table::new(&["sym","price","size"], &[Value::sym_vec(&["AAPL","MSFT","GOOG"]), Value::vec(&[101.5f64,202.0,303.25]), Value::vec(&[10i64,20,30])])?; -let first_two = t.take(2)?; // same rows as head(2) -let last_two = t.take(-2)?; // same rows as tail(2) - -assert_eq!(first_two.column("sym")?.get(0)?.as_sym()?, "AAPL"); -assert_eq!(last_two.column("sym")?.get(0)?.as_sym()?, "MSFT"); +Runtime::scope(|_rt| { + # let t = Table::new(&["sym","price","size"], &[Value::sym_vec(&["AAPL","MSFT","GOOG"]), Value::vec(&[101.5f64,202.0,303.25]), Value::vec(&[10i64,20,30])])?; + let first_two = t.take(2)?; // same rows as head(2) + let last_two = t.take(-2)?; // same rows as tail(2) + + assert_eq!(first_two.column("sym")?.get(0)?.as_sym()?, "AAPL"); + assert_eq!(last_two.column("sym")?.get(0)?.as_sym()?, "MSFT"); + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` diff --git a/docs/docs/content/get-started/installation.md b/docs/docs/content/get-started/installation.md index b846d0f..d78ed3b 100644 --- a/docs/docs/content/get-started/installation.md +++ b/docs/docs/content/get-started/installation.md @@ -50,6 +50,16 @@ precedence over the vendored copy, and is built in place so your incremental state and the version its git history reports are preserved. `RAYFORCE_Q_SRC` does the same for the `rayforce-q` IPC client. +!!! warning "Switching core flavour rebuilds your checkout from scratch" + + The one exception to "incremental state is preserved". Release and debug + objects share every filename and `make` tracks headers but not flags, so + flipping `RAYFORCE_CORE_DEBUG` would otherwise archive a mixed library. The + build script therefore drops every object under `src/` and the + `librayforce.a` beside them on the first build after a change, and records + the flags in an untracked `.stamp` file next to them. Nothing tracked by git + is touched. + ```sh export RAYFORCE_SRC=/path/to/rayforce export RAYFORCE_Q_SRC=/path/to/rayforce-q diff --git a/docs/docs/content/get-started/overview.md b/docs/docs/content/get-started/overview.md index 7b505a2..8211127 100644 --- a/docs/docs/content/get-started/overview.md +++ b/docs/docs/content/get-started/overview.md @@ -9,26 +9,27 @@ practical overhead. ```rust use rayforce::{col, sum, Runtime, Table, Value}; -let _rt = Runtime::new()?; // one live runtime per process - -let t = Table::new( - &["sym", "price", "size"], - &[ - Value::sym_vec(&["AAPL", "MSFT", "AAPL", "GOOG"]), - Value::vec(&[100.0f64, 200.0, 110.0, 300.0]), - Value::vec(&[10i64, 20, 30, 40]), - ], -)?; - -// select total:sum size by sym from t where price > 150.0 -let totals = t - .select() - .agg("total", sum(col("size"))) - .filter(col("price").gt(150.0)) - .by("sym") - .execute()?; - -println!("{totals}"); +Runtime::scope(|_rt| { + let t = Table::new( + &["sym", "price", "size"], + &[ + Value::sym_vec(&["AAPL", "MSFT", "AAPL", "GOOG"]), + Value::vec(&[100.0f64, 200.0, 110.0, 300.0]), + Value::vec(&[10i64, 20, 30, 40]), + ], + )?; + + // select total:sum size by sym from t where price > 150.0 + let totals = t + .select() + .agg("total", sum(col("size"))) + .filter(col("price").gt(150.0)) + .by("sym") + .execute()?; + + println!("{totals}"); + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` @@ -56,25 +57,26 @@ is an RAII guard: keep it alive for as long as you touch any `Value`. ```rust use rayforce::{col, Runtime, Table, Value}; -let _rt = Runtime::new()?; - -// Build a table from typed columns. -let trades = Table::new( - &["sym", "price", "size"], - &[ - Value::sym_vec(&["AAPL", "MSFT", "AAPL"]), - Value::vec(&[100.0f64, 200.0, 110.0]), - Value::vec(&[10i64, 20, 30]), - ], -)?; - -// Filter and project with the fluent query DSL. -let big = trades - .select() - .filter(col("size").gt(15i64)) - .execute()?; - -println!("{big}"); +Runtime::scope(|_rt| { + // Build a table from typed columns. + let trades = Table::new( + &["sym", "price", "size"], + &[ + Value::sym_vec(&["AAPL", "MSFT", "AAPL"]), + Value::vec(&[100.0f64, 200.0, 110.0]), + Value::vec(&[10i64, 20, 30]), + ], + )?; + + // Filter and project with the fluent query DSL. + let big = trades + .select() + .filter(col("size").gt(15i64)) + .execute()?; + + println!("{big}"); + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` diff --git a/docs/docs/content/get-started/technical-details.md b/docs/docs/content/get-started/technical-details.md index 1aacf53..be747c2 100644 --- a/docs/docs/content/get-started/technical-details.md +++ b/docs/docs/content/get-started/technical-details.md @@ -29,38 +29,82 @@ managed by Rust's RAII: ```rust use rayforce::{Runtime, Value}; -let _rt = Runtime::new()?; - -let a = Value::vec(&[1i64, 2, 3]); -let b = a.clone(); // same payload, refcount += 1 -drop(b); // refcount -= 1; `a` still valid -assert_eq!(a.as_slice::()?, &[1, 2, 3]); +Runtime::scope(|_rt| { + let a = Value::vec(&[1i64, 2, 3]); + let b = a.clone(); // same payload, refcount += 1 + drop(b); // refcount -= 1; `a` still valid + assert_eq!(a.as_slice::()?, &[1, 2, 3]); + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` Because lifetime is handled for you, there is no manual `free` and no -use-after-free: the borrow checker and `Drop` keep handles honest. +use-after-free: `Drop` keeps handles honest. + +That refcount governs the *object*. The *heap* those objects live in is a +separate matter: `ray_runtime_destroy` unmaps every pool without consulting any +object's refcount, so an object with five handles is unmapped exactly like one +with one. Nothing checked at the point of use could make a surviving handle safe +— the pointer is what became invalid. Instead the runtime's life is bounded so +such a handle is never produced; see below. ## :material-cpu-64-bit: Single runtime, single thread, `!Send` The RayforceDB core runs on a single thread with a thread-local VM and permits **one live `Runtime` per process**. To make this safe in Rust: -- `Runtime::new()` returns an RAII guard. Hold it alive for as long as you touch - any `Value`; dropping it tears the runtime down. -- `Value`, `Table`, and `TcpClient` are **`!Send`** and **`!Sync`**. They cannot - be moved or shared across threads, which statically prevents you from touching - the engine from a thread other than the one that owns the runtime. +- `Runtime::scope(|rt| …)` is the only way in. It creates the runtime, hands + your closure a `&Runtime` — which you cannot drop or move out of — and tears + it down when the closure returns, on the error path and on unwind alike. + Values built inside are dropped first, because the closure's locals go first. +- `Value`, `Table`, `TcpClient` and `QConnection` are **`!Send`** and + **`!Sync`**. They cannot be moved or shared across threads, which statically + prevents you from touching the engine from a thread other than the one that + owns the runtime. ```rust use rayforce::{Runtime, Value}; -let _rt = Runtime::new()?; // start here, in every runtime-dependent program -let v = Value::i64(42); -// `v` stays on this thread — it is !Send by design. +Runtime::scope(|_rt| { + let v = Value::i64(42); + // `v` stays on this thread — it is !Send by design. + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` +### What keeps a `Value` inside its scope + +The same `!Send` marker, read as a bound. `Runtime::scope` requires `Send` of +its return type and of the closure, and nothing engine-backed satisfies it: + +```rust +use rayforce::{Runtime, Value}; + +// Returning one: rejected, `Value` is !Send. +// let v = Runtime::scope(|rt| rt.eval("(+ 1 1)"))?; + +// Assigning one outward: rejected too — a closure is Send only if every +// capture is, and this one captures `&mut Option`. +// let mut out = None; +// Runtime::scope(|rt| { out = Some(rt.eval("1")?); Ok(()) })?; + +// Extracting plain data is the way through. +let sum = Runtime::scope(|rt| Ok(rt.eval("(+ 1 1)")?.as_i64()?))?; +assert_eq!(sum, 2); +# Ok::<(), rayforce::RayError>(()) +``` + +Both rejections read `required by a bound in Runtime::scope`. The cost is that +an unrelated `!Send` capture — an `Rc`, a `RefCell` borrow — is refused too, +with a diagnostic about threads when no thread is involved; construct such +values inside the closure, or move them in. + +Calling `eval` or a constructor with no scope open panics rather than working +against a runtime nobody owns, and a nested `Runtime::scope` returns an error +rather than starting a second runtime. + !!! note "Why single-thread?" The engine's VM state is thread-local. Rather than hide this behind locks, the bindings surface it directly: `!Send`/`!Sync` turns a runtime invariant @@ -82,11 +126,12 @@ conversion, no intermediate `Vec`: ```rust use rayforce::{Runtime, Value}; -let _rt = Runtime::new()?; - -let prices = Value::vec(&[100.0f64, 200.0, 110.0]); -let slice: &[f64] = prices.as_slice()?; // borrows engine memory, no copy -assert_eq!(slice, &[100.0, 200.0, 110.0]); +Runtime::scope(|_rt| { + let prices = Value::vec(&[100.0f64, 200.0, 110.0]); + let slice: &[f64] = prices.as_slice()?; // borrows engine memory, no copy + assert_eq!(slice, &[100.0, 200.0, 110.0]); + Ok(()) +})?; # Ok::<(), rayforce::RayError>(()) ``` diff --git a/docs/docs/index.md b/docs/docs/index.md index 1f45e2a..87b08ff 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -139,25 +139,26 @@ View on GitHub ```rust use rayforce::{col, sum, Runtime, Table, Value}; -let _rt = Runtime::new()?; - -let t = Table::new( - &["sym", "price", "size"], - &[ - Value::sym_vec(&["AAPL", "MSFT", "AAPL", "GOOG"]), - Value::vec(&[100.0f64, 200.0, 110.0, 300.0]), - Value::vec(&[10i64, 20, 30, 40]), - ], -)?; - -let totals = t - .select() - .agg("total", sum(col("size"))) - .filter(col("price").gt(150.0)) - .by("sym") - .execute()?; - -println!("{totals}"); +Runtime::scope(|_rt| { + let t = Table::new( + &["sym", "price", "size"], + &[ + Value::sym_vec(&["AAPL", "MSFT", "AAPL", "GOOG"]), + Value::vec(&[100.0f64, 200.0, 110.0, 300.0]), + Value::vec(&[10i64, 20, 30, 40]), + ], + )?; + + let totals = t + .select() + .agg("total", sum(col("size"))) + .filter(col("price").gt(150.0)) + .by("sym") + .execute()?; + + println!("{totals}"); + Ok(()) +})?; ``` diff --git a/rayforce-sys/build.rs b/rayforce-sys/build.rs index a94a0d7..bcbebfd 100644 --- a/rayforce-sys/build.rs +++ b/rayforce-sys/build.rs @@ -345,6 +345,21 @@ fn walk(root: &Path, visit: &mut dyn FnMut(&Path)) { /// Makefile tracks header dependencies (`Makefile:143`) but not flag changes, /// so editing [`CORE_VERSION`] on its own would otherwise leave the previous /// string baked into objects that `make` still considers up to date. +/// +/// The same blindness is what makes a [`Flavour`] switch unsafe: release and +/// debug objects share every filename, so make would archive a mixed library. +/// Both flavour and version ride in the one stamp, so one comparison covers +/// both. +/// +/// # This runs for a `RAYFORCE_SRC` checkout too +/// +/// It has to: only the vendored copy stamps a version, but either tree can flip +/// flavour, and a mixed archive is as wrong in a checkout the user owns as it is +/// under OUT_DIR. The consequence is worth stating plainly — in such a checkout +/// the first build after a flavour change deletes every object under `src/` and +/// the `librayforce.a` beside them, forcing a full core rebuild, and leaves a +/// `.stamp` file behind. The objects are `.gitignore`d upstream so nothing +/// tracked is touched; `.stamp` is not, so it shows up as untracked. fn invalidate_on_stamp_change(core: &Path, stamp: &str) { let marker = core.join(".stamp"); if fs::read_to_string(&marker).is_ok_and(|current| current == stamp) { @@ -384,9 +399,45 @@ fn sanitize_libclang_path() { } } +/// Which flavour of `librayforce.a` to link. +/// +/// `Release` is the default and what every published build uses. `Debug` adds +/// `-DDEBUG`, which compiles in the core's invariant checks and its stale +/// retain/release detector (`ray_dfd_check_live` in `src/mem/cow.c`) — the only +/// tool that can see a use-after-free inside the engine's `mmap`-backed pool +/// allocator, which ASan and Valgrind are structurally blind to. Opt in with +/// `RAYFORCE_CORE_DEBUG=1`, then run with `RAY_DFD=1` to arm the detector. +/// +/// Both flavours compile to the same object names, so switching forces a full +/// rebuild of the core — the flavour rides in the stamp +/// [`invalidate_on_stamp_change`] compares, and a change drops every object. +/// CI never pays that: each matrix leg is a fresh checkout building one +/// flavour. Locally it bites whenever you alternate, because `cargo clippy` and +/// a debug `cargo test` share one `OUT_DIR` and therefore one staged core. +#[derive(PartialEq, Eq, Clone, Copy)] +enum Flavour { + Release, + Debug, +} + +/// Read the flavour from `RAYFORCE_CORE_DEBUG`, using the same truthiness rule +/// as the core's own `dfd_enabled()` (`src/mem/heap.c`): set, non-empty, not +/// `"0"`. The empty-string case is not hypothetical — a GitHub Actions +/// conditional expression yields `''` for its false branch. +fn core_flavour() -> Flavour { + println!("cargo:rerun-if-env-changed=RAYFORCE_CORE_DEBUG"); + match env::var("RAYFORCE_CORE_DEBUG") { + Ok(v) if !v.is_empty() && v != "0" => Flavour::Debug, + _ => Flavour::Release, + } +} + /// Run the core's `make lib`. `stamp_version` is set when the core is our /// pinned submodule staged under OUT_DIR, rather than a `RAYFORCE_SRC` -/// checkout building in place with its own git history. +/// checkout building in place with its own git history. It selects whether to +/// pass `RAY_VERSION`/`GIT_HASH`, and nothing else — object invalidation is +/// deliberately not gated on it, because either tree can flip [`Flavour`]. See +/// [`invalidate_on_stamp_change`]. fn build_core_lib(core: &Path, stamp_version: bool) { // Cargo budgets build-script parallelism via NUM_JOBS. Without it make runs // serially — minutes of wall clock for ~90 translation units at -O3, which @@ -396,14 +447,27 @@ fn build_core_lib(core: &Path, stamp_version: bool) { // Make command-line assignments override the Makefile's own definitions, // including `?=` ones. let mut defs = vec![format!("WARNS={CORE_WARNS}")]; + if core_flavour() == Flavour::Debug { + // `DEBUG_CFLAGS` from the core's Makefile minus `-fsanitize=address, + // undefined`: the sanitizers cannot see into the pool allocator (the + // engine says so itself, `src/mem/heap.c`) and linking their runtime + // into every Rust test binary buys nothing for the cost. `$(WARNS)`, + // `$(STD)` and `$(RAY_MARCH)` are expanded by make from its own + // definitions, so only the flavour delta is restated here. + defs.push( + "RELEASE_CFLAGS=-fPIC $(WARNS) -std=$(STD) -g -O0 \ + -march=$(RAY_MARCH) -DDEBUG -fno-omit-frame-pointer" + .to_string(), + ); + } if stamp_version { // Staged under OUT_DIR, with no git history of its own — and `git` // searches upward, so leaving these unset would report the enclosing // repository rather than falling back to "unknown". defs.push(format!("RAY_VERSION={CORE_VERSION}")); defs.push(format!("GIT_HASH={CORE_COMMIT}")); - invalidate_on_stamp_change(core, &defs.join(" ")); } + invalidate_on_stamp_change(core, &defs.join(" ")); let status = Command::new("make") .arg("lib") diff --git a/rayforce/benches/benchmarks.rs b/rayforce/benches/benchmarks.rs index 27cbd14..2b96db0 100644 --- a/rayforce/benches/benchmarks.rs +++ b/rayforce/benches/benchmarks.rs @@ -5,21 +5,11 @@ //! element), engine-side aggregation, group-by, and serialization. //! //! The core is single-threaded with one live runtime per process; Criterion -//! runs benchmark routines synchronously on one thread, so we create the -//! runtime once (and leak it for the process lifetime) on that thread. +//! runs benchmark routines synchronously on one thread, so a single +//! `Runtime::scope` brackets the whole run on that thread. -use criterion::{black_box, criterion_group, criterion_main, Criterion, Throughput}; +use criterion::{black_box, criterion_group, Criterion, Throughput}; use rayforce::{col, lit, sum, Runtime, Table, Value}; -use std::sync::Once; - -static INIT: Once = Once::new(); - -fn ensure_runtime() { - INIT.call_once(|| { - // Leak: the runtime must outlive every benchmark on this thread. - std::mem::forget(Runtime::new().expect("runtime")); - }); -} const N: usize = 100_000; @@ -28,7 +18,6 @@ fn i64_data() -> Vec { } fn bench_vector(c: &mut Criterion) { - ensure_runtime(); let data = i64_data(); let mut g = c.benchmark_group("vector"); @@ -57,7 +46,6 @@ fn bench_vector(c: &mut Criterion) { /// Boxed element read: one FFI hop + allocation per element — the cost the /// zero-copy `as_slice` path avoids. Smaller N keeps wall-clock sane. fn bench_boxed_read(c: &mut Criterion) { - ensure_runtime(); let small = Value::vec(&(0..10_000i64).collect::>()); let mut g = c.benchmark_group("vector_boxed_read_10k"); g.throughput(Throughput::Elements(10_000)); @@ -74,7 +62,6 @@ fn bench_boxed_read(c: &mut Criterion) { } fn bench_aggregation(c: &mut Criterion) { - ensure_runtime(); let v = Value::vec(&i64_data()); let mut g = c.benchmark_group("aggregation"); @@ -89,8 +76,6 @@ fn bench_aggregation(c: &mut Criterion) { } fn bench_query(c: &mut Criterion) { - ensure_runtime(); - // 100k rows over 10 symbol groups. let groups = ["g0", "g1", "g2", "g3", "g4", "g5", "g6", "g7", "g8", "g9"]; let syms: Vec<&str> = (0..N).map(|i| groups[i % groups.len()]).collect(); @@ -131,7 +116,6 @@ fn bench_query(c: &mut Criterion) { } fn bench_serde(c: &mut Criterion) { - ensure_runtime(); let v = Value::vec(&i64_data()); let bytes = v.serialize().unwrap(); @@ -157,4 +141,13 @@ criterion_group!( bench_query, bench_serde ); -criterion_main!(benches); +// Hand-rolled `criterion_main!`: every benchmark has to run inside one scope, +// since the runtime is torn down when it ends. +fn main() { + Runtime::scope(|_rt| { + benches(); + Criterion::default().configure_from_args().final_summary(); + Ok(()) + }) + .unwrap(); +} diff --git a/rayforce/examples/lambda_demo.rs b/rayforce/examples/lambda_demo.rs index 73ecdf8..50879d7 100644 --- a/rayforce/examples/lambda_demo.rs +++ b/rayforce/examples/lambda_demo.rs @@ -5,55 +5,57 @@ use rayforce::{col, sum, Fn, Runtime, Table, Value}; fn main() { // Нужен живой рантайм (один на процесс). - let _rt = Runtime::new().unwrap(); - - // 1. Создаём лямбду из исходника Rayfall. - let square = Fn::new("(fn [x] (* x x))").unwrap(); - println!("лямбда: {square}"); - - // 2. Прямой вызов на скаляре — режим `call` (немедленное вычисление). - let r = square.call(&[Value::i64(5)]).unwrap(); - println!("square(5) = {}", r.as_i64().unwrap()); - - // 3. Прямой вызов на векторе (лямбда применяется поэлементно). - let r = square.call(&[Value::vec(&[2i64, 3, 4])]).unwrap(); - println!("square([2 3 4]) = {:?}", r.as_slice::().unwrap()); - - // 4. Несколько аргументов. - let add = Fn::new("(fn [x y] (+ x y))").unwrap(); - let r = add.call(&[Value::i64(10), Value::i64(32)]).unwrap(); - println!("add(10, 32) = {}", r.as_i64().unwrap()); - - // 5. Применение внутри запроса — режим `apply`. - // Лямбда биндится в глобальный env под уникальным именем, чтобы - // DAG-компилятор запроса мог её раскрыть по имени. - let table = Table::new( - &["id", "value"], - &[Value::sym_vec(&["a", "b", "c"]), Value::vec(&[2i64, 3, 4])], - ) - .unwrap(); - - let out = table - .select() - .col("id") - .agg("squared", square.apply([col("value")]).unwrap()) - .execute() + Runtime::scope(|_rt| { + // 1. Создаём лямбду из исходника Rayfall. + let square = Fn::new("(fn [x] (* x x))").unwrap(); + println!("лямбда: {square}"); + + // 2. Прямой вызов на скаляре — режим `call` (немедленное вычисление). + let r = square.call(&[Value::i64(5)]).unwrap(); + println!("square(5) = {}", r.as_i64().unwrap()); + + // 3. Прямой вызов на векторе (лямбда применяется поэлементно). + let r = square.call(&[Value::vec(&[2i64, 3, 4])]).unwrap(); + println!("square([2 3 4]) = {:?}", r.as_slice::().unwrap()); + + // 4. Несколько аргументов. + let add = Fn::new("(fn [x y] (+ x y))").unwrap(); + let r = add.call(&[Value::i64(10), Value::i64(32)]).unwrap(); + println!("add(10, 32) = {}", r.as_i64().unwrap()); + + // 5. Применение внутри запроса — режим `apply`. + // Лямбда биндится в глобальный env под уникальным именем, чтобы + // DAG-компилятор запроса мог её раскрыть по имени. + let table = Table::new( + &["id", "value"], + &[Value::sym_vec(&["a", "b", "c"]), Value::vec(&[2i64, 3, 4])], + ) .unwrap(); - println!("\nselect id, squared = square(value):\n{}", out.as_value()); - // 6. apply можно оборачивать в агрегаты — как обычное выражение. - let out = table - .select() - .agg("sum_sq", sum(square.apply([col("value")]).unwrap())) - .execute() - .unwrap(); - println!( - "\nsum of squares = {}", - out.column("sum_sq") - .unwrap() - .get(0) - .unwrap() - .as_i64() - .unwrap() - ); + let out = table + .select() + .col("id") + .agg("squared", square.apply([col("value")]).unwrap()) + .execute() + .unwrap(); + println!("\nselect id, squared = square(value):\n{}", out.as_value()); + + // 6. apply можно оборачивать в агрегаты — как обычное выражение. + let out = table + .select() + .agg("sum_sq", sum(square.apply([col("value")]).unwrap())) + .execute() + .unwrap(); + println!( + "\nsum of squares = {}", + out.column("sum_sq") + .unwrap() + .get(0) + .unwrap() + .as_i64() + .unwrap() + ); + Ok(()) + }) + .unwrap(); } diff --git a/rayforce/src/convert.rs b/rayforce/src/convert.rs index 28938c2..3f6c8b9 100644 --- a/rayforce/src/convert.rs +++ b/rayforce/src/convert.rs @@ -6,7 +6,10 @@ //! in [`Str`] for a string atom. [`Guid`] carries 16 raw bytes. With the //! `chrono` feature, date/time/timestamp types convert directly. -use crate::error::{RayError, Result}; +// `RayError` is only constructed by the chrono conversions below. +#[cfg(feature = "chrono")] +use crate::error::RayError; +use crate::error::Result; use crate::value::Value; /// Build a [`Value`] from a Rust value. diff --git a/rayforce/src/dict.rs b/rayforce/src/dict.rs index e9f9e3d..6638737 100644 --- a/rayforce/src/dict.rs +++ b/rayforce/src/dict.rs @@ -2,6 +2,7 @@ use crate::error::{check, RayError, Result}; use crate::raw; +use crate::runtime::assert_on_runtime_thread; use crate::value::Value; use rayforce_sys as sys; @@ -10,6 +11,7 @@ impl Value { /// /// Consumes both arguments (the core takes ownership of each). pub fn dict(keys: Value, values: Value) -> Value { + assert_on_runtime_thread("Value::dict"); unsafe { let d = sys::ray_dict_new(keys.into_raw(), values.into_raw()); match check(d) { diff --git a/rayforce/src/ipc.rs b/rayforce/src/ipc.rs index 4f9dd7e..650fdf6 100644 --- a/rayforce/src/ipc.rs +++ b/rayforce/src/ipc.rs @@ -5,6 +5,7 @@ //! are process-local; the client is `!Send`/`!Sync` like the rest of the crate. use crate::error::{check, materialize, RayError, Result}; +use crate::runtime::assert_on_runtime_thread; use crate::value::Value; use rayforce_sys as sys; use std::ffi::CString; @@ -22,6 +23,31 @@ unsafe fn ensure_poll() { } /// A synchronous IPC connection to a RayforceDB server. +/// +/// Confined to its [`crate::Runtime::scope`], like a [`Value`]: `ray_ipc_close` +/// runs on drop and reaches into the runtime, so the heap has to still be mapped +/// then. Being `!Send` is what keeps it inside — the bounds on `Runtime::scope` +/// are spelled in terms of `Send`. +/// +/// # Safety +/// +/// `!Send`/`!Sync`, and must stay so, twice over: it builds engine objects, +/// which belong to the runtime thread; and that marker is also what confines it +/// to its scope. +/// +/// ```compile_fail +/// fn assert_send() {} +/// assert_send::(); +/// ``` +/// ```compile_fail +/// fn assert_sync() {} +/// assert_sync::(); +/// ``` +/// Control — `compile_fail` passes on *any* build failure, a rename included: +/// ``` +/// fn assert_exists() {} +/// assert_exists::(); +/// ``` pub struct TcpClient { handle: i64, _not_send: PhantomData<*mut ()>, @@ -31,6 +57,7 @@ impl TcpClient { /// Connect to `host:port`, optionally authenticating. Requires a live /// [`crate::Runtime`]. pub fn connect(host: &str, port: u16, user: &str, password: &str) -> Result { + assert_on_runtime_thread("TcpClient::connect"); let host_c = CString::new(host).map_err(|_| RayError::binding("host contains NUL"))?; let user_c = CString::new(user).map_err(|_| RayError::binding("user contains NUL"))?; let pass_c = @@ -90,6 +117,9 @@ impl TcpClient { impl Drop for TcpClient { fn drop(&mut self) { + // Closing a connection releases engine objects held for it, so this + // has to run while the heap is still mapped. It does: a client cannot + // leave the scope that owns the heap. unsafe { sys::ray_ipc_close(self.handle) } } } diff --git a/rayforce/src/lambda.rs b/rayforce/src/lambda.rs index 288c997..5e81261 100644 --- a/rayforce/src/lambda.rs +++ b/rayforce/src/lambda.rs @@ -18,44 +18,53 @@ //! //! ```no_run //! use rayforce::{Fn, Runtime, Value}; -//! let _rt = Runtime::new().unwrap(); -//! let square = Fn::new("(fn [x] (* x x))").unwrap(); -//! // On a scalar… -//! assert_eq!(square.call(&[Value::i64(5)]).unwrap().as_i64().unwrap(), 25); -//! // …or element-wise over a vector. -//! let v = square.call(&[Value::vec(&[2i64, 3, 4])]).unwrap(); -//! assert_eq!(v.as_slice::().unwrap(), &[4, 9, 16]); +//! Runtime::scope(|_rt| { +//! let square = Fn::new("(fn [x] (* x x))").unwrap(); +//! // On a scalar… +//! assert_eq!(square.call(&[Value::i64(5)]).unwrap().as_i64().unwrap(), 25); +//! // …or element-wise over a vector. +//! let v = square.call(&[Value::vec(&[2i64, 3, 4])]).unwrap(); +//! assert_eq!(v.as_slice::().unwrap(), &[4, 9, 16]); +//! Ok(()) +//! }) +//! # .unwrap(); //! ``` //! //! # Applying inside a query //! //! ```no_run //! use rayforce::{col, Fn, Runtime, Table, Value}; -//! let _rt = Runtime::new().unwrap(); -//! let t = Table::new( -//! &["id", "value"], -//! &[Value::sym_vec(&["a", "b", "c"]), Value::vec(&[2i64, 3, 4])], -//! ) -//! .unwrap(); -//! -//! let square = Fn::new("(fn [x] (* x x))").unwrap(); -//! let out = t -//! .select() -//! .col("id") -//! .agg("squared", square.apply([col("value")]).unwrap()) -//! .execute() +//! Runtime::scope(|_rt| { +//! let t = Table::new( +//! &["id", "value"], +//! &[Value::sym_vec(&["a", "b", "c"]), Value::vec(&[2i64, 3, 4])], +//! ) //! .unwrap(); -//! assert_eq!(out.column("squared").unwrap().as_slice::().unwrap(), &[4, 9, 16]); +//! +//! let square = Fn::new("(fn [x] (* x x))").unwrap(); +//! let out = t +//! .select() +//! .col("id") +//! .agg("squared", square.apply([col("value")]).unwrap()) +//! .execute() +//! .unwrap(); +//! assert_eq!(out.column("squared").unwrap().as_slice::().unwrap(), &[4, 9, 16]); +//! Ok(()) +//! }) +//! # .unwrap(); //! ``` //! //! # Lambdas loaded from a file //! //! ```no_run //! use rayforce::{eval, Fn, Runtime, Value}; -//! let _rt = Runtime::new().unwrap(); -//! eval("(load \"prelude.rfl\")").unwrap(); // defines e.g. a `sq` lambda -//! let sq = Fn::from_global("sq").unwrap(); -//! assert_eq!(sq.call(&[Value::i64(9)]).unwrap().as_i64().unwrap(), 81); +//! Runtime::scope(|_rt| { +//! eval("(load \"prelude.rfl\")").unwrap(); // defines e.g. a `sq` lambda +//! let sq = Fn::from_global("sq").unwrap(); +//! assert_eq!(sq.call(&[Value::i64(9)]).unwrap().as_i64().unwrap(), 81); +//! Ok(()) +//! }) +//! # .unwrap(); //! ``` //! //! Mirrors `rayforce-py`'s `Fn` type. diff --git a/rayforce/src/lib.rs b/rayforce/src/lib.rs index 96219c4..1889e20 100644 --- a/rayforce/src/lib.rs +++ b/rayforce/src/lib.rs @@ -3,12 +3,16 @@ //! Binds the core `ray_*` C API directly (via [`rayforce_sys`]); see `PLAN.md` //! for the roadmap. The crate is single-threaded by construction: the core runs //! on one thread with a thread-local VM and allows a single live [`Runtime`] per -//! process. Hold a `Runtime` for as long as you use the API. +//! process. [`Runtime::scope`] brackets it: the closure gets a `&Runtime` for as +//! long as it runs, and everything built inside is torn down with it. //! //! ```no_run -//! let _rt = rayforce::Runtime::new().unwrap(); -//! let two = rayforce::eval("(+ 1 1)").unwrap(); -//! assert_eq!(two.format(), "2"); +//! rayforce::Runtime::scope(|rt| { +//! let two = rt.eval("(+ 1 1)")?; +//! assert_eq!(two.format(), "2"); +//! Ok(()) +//! }) +//! # .unwrap(); //! ``` mod convert; @@ -38,7 +42,7 @@ pub use lambda::Fn; pub use ops::Operation; pub use q::QConnection; pub use query::{Select, Update}; -pub use runtime::{eval, eval_value, get_global, is_live, set_global, Runtime}; +pub use runtime::{eval, eval_value, get_global, on_runtime_thread, set_global, Runtime}; pub use table::Table; pub use value::Value; pub use vector::{VecElem, VecIter}; diff --git a/rayforce/src/list.rs b/rayforce/src/list.rs index 4b6566c..1d49e37 100644 --- a/rayforce/src/list.rs +++ b/rayforce/src/list.rs @@ -6,12 +6,14 @@ use crate::error::{check, RayError, Result}; use crate::raw::{self, Raw}; +use crate::runtime::assert_on_runtime_thread; use crate::value::Value; use rayforce_sys as sys; impl Value { /// Build a list from boxed values (each is retained by the list). pub fn list(items: &[Value]) -> Value { + assert_on_runtime_thread("Value::list"); unsafe { let mut l = match check(sys::ray_list_new(items.len() as i64)) { Ok(p) => p, @@ -30,6 +32,7 @@ impl Value { /// An empty list with the given capacity. pub fn empty_list(capacity: i64) -> Value { + assert_on_runtime_thread("Value::empty_list"); unsafe { match check(sys::ray_list_new(capacity)) { Ok(p) => Value::from_owned(p), diff --git a/rayforce/src/q.rs b/rayforce/src/q.rs index 0809749..8317d98 100644 --- a/rayforce/src/q.rs +++ b/rayforce/src/q.rs @@ -7,21 +7,52 @@ //! //! ```no_run //! use rayforce::{Runtime, q::QConnection}; -//! let _rt = Runtime::new().unwrap(); -//! let conn = QConnection::connect("localhost", 5010).unwrap(); -//! let fills = conn.execute("select from fixmsgs where i > 0").unwrap(); +//! Runtime::scope(|_rt| { +//! let conn = QConnection::connect("localhost", 5010).unwrap(); +//! let fills = conn.execute("select from fixmsgs where i > 0").unwrap(); +//! Ok(()) +//! }) +//! # .unwrap(); //! ``` use std::ffi::CString; +use std::marker::PhantomData; use rayforce_sys as sys; use crate::error::{check, RayError, Result}; +use crate::runtime::assert_on_runtime_thread; use crate::value::Value; /// An open connection to a Q server. Closed on drop. +/// +/// Confined to its [`crate::Runtime::scope`], like a [`Value`]: `q_close` runs +/// on drop and reaches into the runtime, so the heap has to still be mapped +/// then. Being `!Send` is what keeps it inside — the bounds on `Runtime::scope` +/// are spelled in terms of `Send`. +/// +/// # Safety +/// +/// `!Send`/`!Sync`, and must stay so, twice over: `execute` interns symbols and +/// builds engine objects, which belong to the thread that owns the +/// [`crate::Runtime`]; and that marker is also what confines it to its scope. +/// +/// ```compile_fail +/// fn assert_send() {} +/// assert_send::(); +/// ``` +/// ```compile_fail +/// fn assert_sync() {} +/// assert_sync::(); +/// ``` +/// Control — `compile_fail` passes on *any* build failure, a rename included: +/// ``` +/// fn assert_exists() {} +/// assert_exists::(); +/// ``` pub struct QConnection { fd: i32, + _not_send: PhantomData<*mut ()>, } impl QConnection { @@ -40,6 +71,7 @@ impl QConnection { password: &str, timeout_ms: i32, ) -> Result { + assert_on_runtime_thread("QConnection::connect"); let host_c = CString::new(host).map_err(|_| RayError::binding("Q host contains NUL"))?; let user_c = CString::new(user).map_err(|_| RayError::binding("Q user contains NUL"))?; let pass_c = @@ -63,7 +95,10 @@ impl QConnection { "Q: connect to {host}:{port} {reason}" ))); } - Ok(QConnection { fd }) + Ok(QConnection { + fd, + _not_send: PhantomData, + }) } /// Send a query string for remote evaluation; return the response decoded @@ -91,6 +126,9 @@ impl QConnection { impl Drop for QConnection { fn drop(&mut self) { + // Closing releases engine objects held for the connection, so this has + // to run while the heap is still mapped. It does: a connection cannot + // leave the scope that owns the heap. unsafe { sys::q_close(self.fd) }; } } @@ -104,6 +142,7 @@ impl Drop for QConnection { /// and must run on the thread that owns the [`crate::Runtime`] (like every /// other constructor in this crate). A Q server-side error surfaces as `Err`. pub fn decode_response(msg: &[u8]) -> Result { + assert_on_runtime_thread("q::decode_response"); // q_header_t (q.c): endianness, msgtype, compressed, reserved, u32 size. // `size` counts the whole message, header included. Little-endian wire only. const HEADER_LEN: usize = 8; diff --git a/rayforce/src/runtime.rs b/rayforce/src/runtime.rs index 047d121..10f8fb1 100644 --- a/rayforce/src/runtime.rs +++ b/rayforce/src/runtime.rs @@ -1,44 +1,189 @@ //! Runtime lifecycle and evaluation entry points. //! //! The core permits exactly one live runtime per process and pins it to the -//! creating thread (thread-local VM). [`Runtime`] is an RAII guard enforcing the -//! single-live-instance rule; hold one for as long as you use the API. Drop it -//! to tear the runtime down. +//! creating thread (thread-local VM). [`Runtime::scope`] is the only way to get +//! one: it creates the runtime, hands your closure a `&Runtime`, and tears it +//! down when the closure returns. +//! +//! That shape is what keeps the crate sound. `ray_runtime_destroy` unmaps the +//! engine heap without consulting any object's reference count, so a [`Value`] +//! that outlived its runtime would point into unmapped address space. Inside a +//! scope it cannot: the caller never owns the guard, so cannot drop it early, +//! and the bounds on [`Runtime::scope`] stop values leaving the closure. +//! +//! Those bounds bound a value's *lifetime*, not the *thread* a call runs on — a +//! closure spawned inside the scope captures nothing and so is `Send`. The +//! second half of the invariant is therefore a runtime check: +//! [`on_runtime_thread`], asserted at every entry point that reaches the +//! engine. use crate::error::{check, materialize, RayError, Result}; use crate::value::Value; use rayforce_sys as sys; +use std::cell::Cell; use std::ffi::CString; use std::marker::PhantomData; +use std::ptr; use std::sync::atomic::{AtomicBool, Ordering}; +/// Does a runtime exist anywhere in this process? The core permits exactly one, +/// and does nothing to enforce it: `__RUNTIME` (core `src/core/runtime.c`) is a +/// plain global that a second `ray_runtime_create` would overwrite in silence. +/// The compare-exchange in [`Runtime::new`] is the only refusal, so this flag +/// must stay process-wide. static LIVE: AtomicBool = AtomicBool::new(false); -/// An owned, live RayforceDB runtime. Only one may exist per process at a time. +thread_local! { + /// Does *this* thread own the runtime? + /// + /// A separate question from [`LIVE`], and the one every engine call has to + /// ask. The core's VM and heap are both thread-local — `__VM` + /// (`src/core/runtime.c`) and `ray_tl_heap` (`src/mem/heap.c`) — so a call + /// from any other thread finds them null. `ray_eval_str` dereferences `__VM` + /// with no check, and `ray_alloc` quietly maps a fresh per-thread heap that + /// no `ray_runtime_destroy` will ever unmap. Neither is caught by a + /// process-wide flag, because by then the process *does* have a runtime. + static OWNS_RUNTIME: Cell = const { Cell::new(false) }; +} + +/// A live RayforceDB runtime. Obtained only from [`Runtime::scope`], and only as +/// a shared reference — you cannot own one, so you cannot drop one. /// /// All evaluation and object construction must happen on the thread that created /// the runtime; `Runtime` is `!Send`/`!Sync` to enforce this. +/// +/// # Safety +/// +/// `!Send`/`!Sync`, and must stay so: the core's VM is thread-local, so a guard +/// that reached another thread would evaluate against a VM that is not there. +/// +/// ```compile_fail +/// fn assert_send() {} +/// assert_send::(); +/// ``` +/// ```compile_fail +/// fn assert_sync() {} +/// assert_sync::(); +/// ``` +/// Control — `compile_fail` passes on *any* build failure, a rename included: +/// ``` +/// fn assert_exists() {} +/// assert_exists::(); +/// ``` pub struct Runtime { rt: *mut sys::ray_runtime_s, _not_send: PhantomData<*mut ()>, } impl Runtime { - /// Create the process runtime. Returns an error if one is already live. - pub fn new() -> Result { + /// Create the process runtime, run `f` against it, and tear it down. + /// + /// ``` + /// # use rayforce::Runtime; + /// let sum = Runtime::scope(|rt| Ok(rt.eval("(+ 1 1)")?.as_i64()?))?; + /// assert_eq!(sum, 2); + /// # Ok::<(), rayforce::RayError>(()) + /// ``` + /// + /// The runtime is torn down when `f` returns, on the error path and on + /// unwind alike. Scopes may run back to back, but never nested — the core + /// permits one live runtime at a time. + /// + /// # Why the `Send` bounds + /// + /// Nothing engine-backed may leave the closure: the heap it points into is + /// unmapped on the way out. Every such type — [`Value`], [`crate::Table`], + /// [`crate::Fn`], [`crate::TcpClient`], [`crate::QConnection`] — is already + /// `!Send` and `!Sync` because the core's VM is thread-local, so requiring + /// `Send` of the return type and of the closure rejects exactly them. + /// + /// `R: Send` closes the return path, transitively and through references + /// (`&T: Send` needs `T: Sync`): + /// + /// ```compile_fail + /// # use rayforce::Runtime; + /// let v = Runtime::scope(|rt| rt.eval("(+ 1 1)")).unwrap(); + /// ``` + /// ```compile_fail + /// # use rayforce::{Runtime, Value}; + /// let v = Runtime::scope(|rt| Ok(vec![rt.eval("1")?])).unwrap(); + /// ``` + /// + /// `F: Send` closes the capture path, since a closure is `Send` only if + /// every capture is: + /// + /// ```compile_fail + /// # use rayforce::{Runtime, Value}; + /// let mut out = None; + /// Runtime::scope(|rt| { out = Some(rt.eval("1")?); Ok(()) }).unwrap(); + /// ``` + /// + /// Control — the same shape, extracting plain data instead, must compile: + /// + /// ``` + /// # use rayforce::Runtime; + /// let mut out = None; + /// Runtime::scope(|rt| { out = Some(rt.eval("(+ 1 1)")?.format()); Ok(()) })?; + /// assert_eq!(out.unwrap(), "2"); + /// # Ok::<(), rayforce::RayError>(()) + /// ``` + /// + /// The cost is that an unrelated `!Send` capture — an `Rc`, a `RefCell` + /// borrow — is refused too, with a diagnostic about threads when no thread + /// is involved. Move such values into the closure, or construct them inside. + /// + /// # What the bounds do not cover + /// + /// The bounds are about values *leaving* the closure, so they say nothing + /// about a thread spawned *inside* it. Such a closure captures nothing, so + /// it is `Send`, and it can call [`eval`] or a constructor directly. The + /// engine's VM and heap are thread-local, so that call has no runtime to + /// reach. It is refused at the boundary — see [`on_runtime_thread`] — with a + /// panic rather than a compile error: a runtime check, because the type + /// system is not tracking which thread a call happens on. + /// + /// One route stays open. A closure that stashes a value into a + /// `thread_local!` captures nothing, so it satisfies `F: Send`, and the + /// assignment compiles. Nothing catches it afterwards either: the value's + /// `Drop` runs at thread exit, against a heap that was unmapped when the + /// scope ended. A plain `static` cannot do this — `Value` is `!Sync` — and + /// the bounds cover every other route, so this is the single remaining way + /// to build a dangling handle from safe code. It takes deliberate effort; + /// don't. + pub fn scope(f: F) -> Result + where + F: Send + FnOnce(&Runtime) -> Result, + R: Send, + { + let rt = Runtime::new()?; + f(&rt) + } + + /// Create the process runtime. Private: [`Runtime::scope`] is the entry + /// point, and it being the only one is what bounds a `Runtime`'s life. + fn new() -> Result { if LIVE .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) .is_err() { - return Err(RayError::binding( - "a rayforce runtime is already live in this process", - )); + // Two ways to arrive here, and they are different mistakes: a + // nested scope is a local bug, whereas a runtime on another thread + // means this thread cannot have one at all until that scope ends. + return Err(RayError::binding(if OWNS_RUNTIME.get() { + "a rayforce runtime is already live on this thread — \ + Runtime::scope cannot be nested" + } else { + "a rayforce runtime is already live on another thread — \ + the core permits one per process" + })); } - let rt = unsafe { sys::ray_runtime_create(0, std::ptr::null_mut()) }; + let rt = unsafe { sys::ray_runtime_create(0, ptr::null_mut()) }; if rt.is_null() { LIVE.store(false, Ordering::SeqCst); return Err(RayError::binding("ray_runtime_create failed")); } + // `ray_runtime_create` bound `__VM` to this thread and nowhere else. + OWNS_RUNTIME.set(true); Ok(Runtime { rt, _not_send: PhantomData, @@ -62,9 +207,63 @@ impl Runtime { } } -/// Bind `value` to a global name. Requires a live [`Runtime`]. +impl Drop for Runtime { + fn drop(&mut self) { + unsafe { + // The poll belongs to the runtime, and closing a selector releases + // engine objects held for it — so it has to go down first, while + // the heap is still there. `ray_runtime_destroy` does not do this + // itself, so a process that only ever used a `TcpClient` leaked it. + let poll = sys::ray_runtime_get_poll(); + if !poll.is_null() { + sys::ray_runtime_set_poll(ptr::null_mut()); + sys::ray_poll_destroy(poll.cast()); + } + sys::ray_runtime_destroy(self.rt); + } + // `Runtime` is `!Send` and never leaves `scope`, so this runs on the + // same thread that set it — the pair cannot drift. + OWNS_RUNTIME.set(false); + LIVE.store(false, Ordering::SeqCst); + } +} + +/// Is this the thread that owns the live runtime? +/// +/// The engine's VM (`__VM`) and heap (`ray_tl_heap`) are thread-local, so only +/// this thread may call in. True only inside a [`Runtime::scope`] body, and only +/// on the thread that entered it. +/// +/// A `false` result does **not** mean a runtime can be created here — one may be +/// live on another thread, in which case [`Runtime::scope`] refuses. +pub fn on_runtime_thread() -> bool { + OWNS_RUNTIME.get() +} + +/// Panic unless this thread owns the runtime. +/// +/// The engine has no guard of its own: `ray_eval_str` dereferences the +/// thread-local `__VM` with no null check, and the atom constructors reach +/// `ray_alloc`, which maps a fresh per-thread heap rather than failing. This is +/// an unconditional assertion, not a `debug_assert` — the release build has +/// exactly the same hole. +#[inline] +pub(crate) fn assert_on_runtime_thread(what: &str) { + if on_runtime_thread() { + return; + } + // Distinct messages, because the two are hard to tell apart from a stack + // trace and the fixes differ: one is "open a scope", the other is "do this + // work on the scope's thread". + if LIVE.load(Ordering::SeqCst) { + panic!("rayforce: {what} called off the runtime's thread"); + } + panic!("rayforce: {what} requires a live Runtime"); +} + +/// Bind `value` to a global name. Requires a live [`Runtime`] on this thread. pub fn set_global(name: &str, value: &Value) -> Result<()> { - debug_assert!(is_live(), "set_global called without a live Runtime"); + assert_on_runtime_thread("set_global"); unsafe { let id = sys::ray_sym_intern(name.as_ptr() as *const _, name.len()); let e = sys::ray_env_set(id, value.as_ptr()); @@ -77,9 +276,9 @@ pub fn set_global(name: &str, value: &Value) -> Result<()> { Ok(()) } -/// Look up a global binding. Requires a live [`Runtime`]. +/// Look up a global binding. Requires a live [`Runtime`] on this thread. pub fn get_global(name: &str) -> Result { - debug_assert!(is_live(), "get_global called without a live Runtime"); + assert_on_runtime_thread("get_global"); unsafe { let id = sys::ray_sym_intern(name.as_ptr() as *const _, name.len()); let v = sys::ray_env_get(id); @@ -93,23 +292,11 @@ pub fn get_global(name: &str) -> Result { } } -impl Drop for Runtime { - fn drop(&mut self) { - unsafe { sys::ray_runtime_destroy(self.rt) }; - LIVE.store(false, Ordering::SeqCst); - } -} - -/// Is a runtime currently live in this process? -pub fn is_live() -> bool { - LIVE.load(Ordering::SeqCst) -} - -/// Evaluate a Rayfall source string. Requires a live [`Runtime`]. +/// Evaluate a Rayfall source string. Requires a live [`Runtime`] on this thread. /// /// A void / null result becomes [`Value::null`]; a core error becomes `Err`. pub fn eval(source: &str) -> Result { - debug_assert!(is_live(), "eval called without a live Runtime"); + assert_on_runtime_thread("eval"); let c = CString::new(source).map_err(|_| RayError::binding("source contains a NUL byte"))?; unsafe { let r = sys::ray_eval_str(c.as_ptr()); @@ -121,9 +308,9 @@ pub fn eval(source: &str) -> Result { } /// Evaluate an already-compiled AST [`Value`] (e.g. a query). Requires a live -/// [`Runtime`]. +/// [`Runtime`] on this thread. pub fn eval_value(obj: &Value) -> Result { - debug_assert!(is_live(), "eval_value called without a live Runtime"); + assert_on_runtime_thread("eval_value"); unsafe { let r = sys::ray_eval(obj.as_ptr()); if r.is_null() { diff --git a/rayforce/src/scalars.rs b/rayforce/src/scalars.rs index d3e92a0..bca7bfd 100644 --- a/rayforce/src/scalars.rs +++ b/rayforce/src/scalars.rs @@ -7,6 +7,7 @@ use crate::error::{check, RayError, Result}; use crate::raw::{self, Raw}; +use crate::runtime::assert_on_runtime_thread; use crate::value::Value; use rayforce_sys as sys; @@ -26,35 +27,43 @@ impl Value { /// A boolean atom (`-RAY_BOOL`). pub fn bool(v: bool) -> Value { + assert_on_runtime_thread("Value::bool"); unsafe { own(sys::ray_bool(v)) } } /// An unsigned byte atom (`-RAY_U8`). pub fn u8(v: u8) -> Value { + assert_on_runtime_thread("Value::u8"); unsafe { own(sys::ray_u8(v)) } } /// A 16-bit signed integer atom (`-RAY_I16`). pub fn i16(v: i16) -> Value { + assert_on_runtime_thread("Value::i16"); unsafe { own(sys::ray_i16(v)) } } /// A 32-bit signed integer atom (`-RAY_I32`). pub fn i32(v: i32) -> Value { + assert_on_runtime_thread("Value::i32"); unsafe { own(sys::ray_i32(v)) } } /// A 64-bit signed integer atom (`-RAY_I64`). pub fn i64(v: i64) -> Value { + assert_on_runtime_thread("Value::i64"); unsafe { own(sys::ray_i64(v)) } } /// A 32-bit float atom (`-RAY_F32`). pub fn f32(v: f32) -> Value { + assert_on_runtime_thread("Value::f32"); unsafe { own(sys::ray_f32(v)) } } /// A 64-bit float atom (`-RAY_F64`). pub fn f64(v: f64) -> Value { + assert_on_runtime_thread("Value::f64"); unsafe { own(sys::ray_f64(v)) } } /// A symbol atom (`-RAY_SYM`): interns `s` in the global table. pub fn sym(s: &str) -> Value { + assert_on_runtime_thread("Value::sym"); unsafe { let id = sys::ray_sym_intern(s.as_ptr() as *const _, s.len()); own(sys::ray_sym(id)) @@ -63,6 +72,7 @@ impl Value { /// A string atom (`-RAY_STR`). pub fn string(s: &str) -> Value { + assert_on_runtime_thread("Value::string"); unsafe { own(sys::ray_str(s.as_ptr() as *const _, s.len())) } } @@ -70,6 +80,7 @@ impl Value { /// the `ATTR_QUOTED` flag is cleared so the query compiler resolves it by /// name rather than treating it as a literal symbol. pub fn name_ref(name: &str) -> Value { + assert_on_runtime_thread("Value::name_ref"); unsafe { let id = sys::ray_sym_intern(name.as_ptr() as *const _, name.len()); let v = own(sys::ray_sym(id)); @@ -80,24 +91,29 @@ impl Value { /// A date atom: raw days since 2000-01-01. pub fn date_days(days: i32) -> Value { + assert_on_runtime_thread("Value::date_days"); unsafe { own(sys::ray_date(days as i64)) } } /// A time atom: raw milliseconds since midnight. pub fn time_millis(ms: i32) -> Value { + assert_on_runtime_thread("Value::time_millis"); unsafe { own(sys::ray_time(ms as i64)) } } /// A timestamp atom: raw nanoseconds since 2000-01-01 UTC. pub fn timestamp_nanos(ns: i64) -> Value { + assert_on_runtime_thread("Value::timestamp_nanos"); unsafe { own(sys::ray_timestamp(ns)) } } /// A GUID atom from 16 raw bytes. pub fn guid(bytes: &[u8; 16]) -> Value { + assert_on_runtime_thread("Value::guid"); unsafe { own(sys::ray_guid(bytes.as_ptr())) } } /// A typed null atom for the given canonical type id (e.g. [`sys::RAY_I64`]). pub fn typed_null(abs_type: i8) -> Value { + assert_on_runtime_thread("Value::typed_null"); unsafe { own(sys::ray_typed_null(abs_type)) } } diff --git a/rayforce/src/table.rs b/rayforce/src/table.rs index 5f754ee..af70e64 100644 --- a/rayforce/src/table.rs +++ b/rayforce/src/table.rs @@ -7,6 +7,7 @@ use crate::error::{check, RayError, Result}; use crate::raw; +use crate::runtime::assert_on_runtime_thread; use crate::value::Value; use rayforce_sys as sys; @@ -24,6 +25,7 @@ impl Table { /// Each column is retained by the table; the caller's `Value`s remain valid. /// Errors if the counts differ or a name/column is invalid. pub fn new>(names: &[S], columns: &[Value]) -> Result { + assert_on_runtime_thread("Table::new"); if names.len() != columns.len() { return Err(RayError::binding(format!( "table: {} names but {} columns", @@ -151,6 +153,7 @@ impl Table { /// `"I64"`, `"F64"`, `"SYMBOL"`, `"STR"`, `"DATE"`, `"TIME"`, /// `"TIMESTAMP"`, `"B8"`, `"GUID"`, `"I32"`, `"I16"`, `"U8"`). pub fn read_csv>(column_types: &[S], path: &str) -> Result
{ + assert_on_runtime_thread("Table::read_csv"); let upper: Vec = column_types .iter() .map(|t| normalize_type_token(t.as_ref())) @@ -196,6 +199,7 @@ impl Table { /// Load a splayed table from `dir`. pub fn load_splayed(dir: &str, sym_path: Option<&str>) -> Result
{ + assert_on_runtime_thread("Table::load_splayed"); let dir_v = Value::string(dir); let sym_v = sym_path.map(Value::string); unsafe { @@ -215,6 +219,7 @@ impl Table { /// Load a partitioned table named `name` rooted at `root`. pub fn load_parted(root: &str, name: &str) -> Result
{ + assert_on_runtime_thread("Table::load_parted"); let root_v = Value::string(root); let name_v = Value::sym(name); unsafe { diff --git a/rayforce/src/value.rs b/rayforce/src/value.rs index b536cc6..db66a5b 100644 --- a/rayforce/src/value.rs +++ b/rayforce/src/value.rs @@ -3,7 +3,8 @@ //! `Value` owns exactly one reference. `Clone` retains, `Drop` releases (both //! no-ops for the null singleton and error objects, per the core). It is //! deliberately `!Send`/`!Sync`: the core is single-threaded with a -//! thread-local VM, so values must never cross threads. +//! thread-local VM, so values must never cross threads — and that same marker +//! is what [`crate::Runtime::scope`] leans on to keep them inside their scope. use crate::error::{self, Result}; use crate::raw::{self, Raw}; @@ -12,6 +13,40 @@ use std::fmt; use std::marker::PhantomData; /// A handle to a RayforceDB object (atom, vector, list, dict, table, …). +/// +/// # Confined to its scope +/// +/// A `Value` points into the engine heap, and tearing the runtime down unmaps +/// that heap — `ray_runtime_destroy` munmaps every pool without consulting any +/// object's reference count, so a surviving handle would point at unmapped +/// address space, not at freed bytes. There is no check that could make such a +/// handle safe to read; only never producing one can. +/// +/// [`crate::Runtime::scope`] is what never produces one. The closure is handed a +/// `&Runtime` it cannot drop, and its `Send` bounds reject a `Value` leaving by +/// return or by capture — so every `Value` is dropped before the heap it lives +/// in goes away. +/// +/// # Safety +/// +/// `!Send`/`!Sync`, and must stay so — twice over. The core's VM is +/// thread-local, so a value on another thread would release against the wrong +/// heap; and `Runtime::scope`'s bounds are spelled in terms of `Send`, so this +/// marker is also what confines a value to its scope. +/// +/// ```compile_fail +/// fn assert_send() {} +/// assert_send::(); +/// ``` +/// ```compile_fail +/// fn assert_sync() {} +/// assert_sync::(); +/// ``` +/// Control — `compile_fail` passes on *any* build failure, a rename included: +/// ``` +/// fn assert_exists() {} +/// assert_exists::(); +/// ``` pub struct Value { ptr: Raw, /// Makes `Value` `!Send` + `!Sync`. @@ -49,9 +84,22 @@ impl Value { } } + /// The value's attribute byte. + /// + /// Rarely needed — the typed accessors cover the normal cases. The + /// exception is telling a *keyed table* apart from a plain list: it decodes + /// as a 2-element list carrying `RAY_ATTR_DICT`, which no type code + /// distinguishes. + pub fn attrs(&self) -> u8 { + unsafe { raw::attrs(self.as_ptr()) } + } + /// The untyped null singleton (`RAY_NULL_OBJ`). + /// + /// `__ray_null` is a static in the C library, not a pool block, so it is + /// unaffected by the heap's teardown. Retain and release are no-ops for it + /// in the core too. pub fn null() -> Value { - // Arena-allocated singleton: retain/release are no-ops. Value { ptr: raw::null_obj(), _not_send: PhantomData, @@ -59,6 +107,9 @@ impl Value { } /// Borrow the underlying raw pointer (does not transfer ownership). + /// + /// Needs no liveness check: a `Value` cannot outlive the scope its heap + /// belongs to, so the pointer is mapped for as long as `self` exists. #[inline] pub(crate) fn as_ptr(&self) -> Raw { self.ptr @@ -83,50 +134,50 @@ impl Value { /// The signed type tag (negative = atom, positive = vector, 0 = list, …). #[inline] pub fn type_code(&self) -> i8 { - unsafe { raw::type_code(self.ptr) } + unsafe { raw::type_code(self.as_ptr()) } } /// `|type|` — the canonical (unsigned) type id. #[inline] pub fn abs_type(&self) -> i8 { - unsafe { raw::abs_type(self.ptr) } + unsafe { raw::abs_type(self.as_ptr()) } } /// True for atoms (scalars and function objects). #[inline] pub fn is_atom(&self) -> bool { - unsafe { raw::is_atom(self.ptr) } + unsafe { raw::is_atom(self.as_ptr()) } } /// True for homogeneous vectors (bool…str). #[inline] pub fn is_vec(&self) -> bool { - unsafe { raw::is_vec(self.ptr) } + unsafe { raw::is_vec(self.as_ptr()) } } /// True if this is the null singleton. #[inline] pub fn is_null(&self) -> bool { - raw::is_null_singleton(self.ptr) + raw::is_null_singleton(self.as_ptr()) } /// Element / pair count for vectors, lists, and dicts; for other objects the /// raw `len` field (not meaningful for atoms). #[inline] pub fn len_raw(&self) -> i64 { - unsafe { raw::len(self.ptr) } + unsafe { raw::len(self.as_ptr()) } } /// Current core reference count (diagnostic). #[inline] pub fn ref_count(&self) -> u32 { - unsafe { raw::rc(self.ptr) } + unsafe { raw::rc(self.as_ptr()) } } /// Pretty-print via the core formatter (`ray_fmt`). pub fn format(&self) -> String { unsafe { - let s = sys::ray_fmt(self.ptr, 1); + let s = sys::ray_fmt(self.as_ptr(), 1); if s.is_null() { return String::new(); } @@ -151,7 +202,7 @@ impl Value { /// Serialize to a byte vector (core wire format with IPC header). pub fn serialize(&self) -> Result> { unsafe { - let ser = error::check(sys::ray_ser(self.ptr))?; + let ser = error::check(sys::ray_ser(self.as_ptr()))?; if ser.is_null() { return Err(crate::error::RayError::binding("serialize returned null")); } @@ -179,12 +230,10 @@ impl Value { impl Clone for Value { fn clone(&self) -> Value { - unsafe { - sys::ray_retain(self.ptr); - Value { - ptr: self.ptr, - _not_send: PhantomData, - } + unsafe { sys::ray_retain(self.ptr) }; + Value { + ptr: self.ptr, + _not_send: PhantomData, } } } diff --git a/rayforce/src/vector.rs b/rayforce/src/vector.rs index e5f9bdd..c8a3261 100644 --- a/rayforce/src/vector.rs +++ b/rayforce/src/vector.rs @@ -10,6 +10,7 @@ use crate::error::{check, RayError, Result}; use crate::raw::{self, Raw}; +use crate::runtime::assert_on_runtime_thread; use crate::value::Value; use core::ffi::c_void; use rayforce_sys as sys; @@ -45,6 +46,7 @@ impl Value { /// Build a vector from a slice of fixed-width elements (single `memcpy`). pub fn vec(data: &[T]) -> Value { + assert_on_runtime_thread("Value::vec"); unsafe { let p = check(sys::ray_vec_from_raw( T::RAY_TYPE, @@ -60,6 +62,7 @@ impl Value { /// Build a boolean vector (`RAY_BOOL`) from a slice of `bool`. pub fn bool_vec(data: &[bool]) -> Value { + assert_on_runtime_thread("Value::bool_vec"); unsafe { // bool is a 1-byte 0/1 value — reinterpret as the byte buffer. let p = check(sys::ray_vec_from_raw( @@ -76,6 +79,7 @@ impl Value { /// Build a symbol vector, interning each string. pub fn sym_vec>(items: &[S]) -> Value { + assert_on_runtime_thread("Value::sym_vec"); unsafe { let mut v = match check(sys::ray_sym_vec_new( sys::RAY_SYM_W64 as u8, @@ -95,6 +99,7 @@ impl Value { /// Build a string vector (`RAY_STR`). pub fn str_vec>(items: &[S]) -> Value { + assert_on_runtime_thread("Value::str_vec"); unsafe { let mut v = match check(sys::ray_vec_new(sys::RAY_STR as i8, items.len() as i64)) { Ok(v) => v, @@ -114,6 +119,7 @@ impl Value { /// Allocate an empty vector of the given canonical type with `capacity`. pub fn empty_vec(abs_type: i8, capacity: i64) -> Value { + assert_on_runtime_thread("Value::empty_vec"); unsafe { match check(sys::ray_vec_new(abs_type, capacity)) { Ok(p) => Value::from_owned(p), diff --git a/rayforce/tests/containers.rs b/rayforce/tests/containers.rs index 8c9a88d..5b03aa6 100644 --- a/rayforce/tests/containers.rs +++ b/rayforce/tests/containers.rs @@ -4,193 +4,235 @@ use rayforce::{eval, Runtime, Value}; #[test] fn vector_zero_copy_slice() { - let _rt = Runtime::new().unwrap(); - let data = [1i64, 2, 3, 4, 5]; - let v = Value::vec(&data); - assert_eq!(v.len(), 5); - assert_eq!(v.as_slice::().unwrap(), &data); - // wrong element type is rejected - assert!(v.as_slice::().is_err()); + Runtime::scope(|_rt| { + let data = [1i64, 2, 3, 4, 5]; + let v = Value::vec(&data); + assert_eq!(v.len(), 5); + assert_eq!(v.as_slice::().unwrap(), &data); + // wrong element type is rejected + assert!(v.as_slice::().is_err()); + Ok(()) + }) + .unwrap(); } #[test] fn vector_all_numeric_types() { - let _rt = Runtime::new().unwrap(); - assert_eq!( - Value::vec(&[1u8, 2, 3]).as_slice::().unwrap(), - &[1, 2, 3] - ); - assert_eq!( - Value::vec(&[-1i16, 0, 1]).as_slice::().unwrap(), - &[-1, 0, 1] - ); - assert_eq!( - Value::vec(&[10i32, 20]).as_slice::().unwrap(), - &[10, 20] - ); - assert_eq!( - Value::vec(&[1.5f32, 2.5]).as_slice::().unwrap(), - &[1.5, 2.5] - ); - assert_eq!( - Value::vec(&[1.0f64, 2.0]).as_slice::().unwrap(), - &[1.0, 2.0] - ); - assert_eq!(Value::bool_vec(&[true, false, true]).len(), 3); + Runtime::scope(|_rt| { + assert_eq!( + Value::vec(&[1u8, 2, 3]).as_slice::().unwrap(), + &[1, 2, 3] + ); + assert_eq!( + Value::vec(&[-1i16, 0, 1]).as_slice::().unwrap(), + &[-1, 0, 1] + ); + assert_eq!( + Value::vec(&[10i32, 20]).as_slice::().unwrap(), + &[10, 20] + ); + assert_eq!( + Value::vec(&[1.5f32, 2.5]).as_slice::().unwrap(), + &[1.5, 2.5] + ); + assert_eq!( + Value::vec(&[1.0f64, 2.0]).as_slice::().unwrap(), + &[1.0, 2.0] + ); + assert_eq!(Value::bool_vec(&[true, false, true]).len(), 3); + Ok(()) + }) + .unwrap(); } #[test] fn vector_get_and_iter() { - let _rt = Runtime::new().unwrap(); - let v = Value::vec(&[10i64, 20, 30]); - assert_eq!(v.get(0).unwrap().as_i64().unwrap(), 10); - assert_eq!(v.get(2).unwrap().as_i64().unwrap(), 30); - assert!(v.get(3).is_err()); - let collected: Vec = v.to_vec().unwrap(); - assert_eq!(collected, vec![10, 20, 30]); - let via_iter: Vec = v.iter().map(|r| r.unwrap().as_i64().unwrap()).collect(); - assert_eq!(via_iter, vec![10, 20, 30]); + Runtime::scope(|_rt| { + let v = Value::vec(&[10i64, 20, 30]); + assert_eq!(v.get(0).unwrap().as_i64().unwrap(), 10); + assert_eq!(v.get(2).unwrap().as_i64().unwrap(), 30); + assert!(v.get(3).is_err()); + let collected: Vec = v.to_vec().unwrap(); + assert_eq!(collected, vec![10, 20, 30]); + let via_iter: Vec = v.iter().map(|r| r.unwrap().as_i64().unwrap()).collect(); + assert_eq!(via_iter, vec![10, 20, 30]); + Ok(()) + }) + .unwrap(); } #[test] fn vector_mutation() { - let _rt = Runtime::new().unwrap(); - let mut v = Value::vec(&[1i64, 2, 3]); - v.set(1, 99i64).unwrap(); - assert_eq!(v.as_slice::().unwrap(), &[1, 99, 3]); - v.push(4i64).unwrap(); - assert_eq!(v.as_slice::().unwrap(), &[1, 99, 3, 4]); - // type mismatch rejected - assert!(v.set(0, 1.0f64).is_err()); + Runtime::scope(|_rt| { + let mut v = Value::vec(&[1i64, 2, 3]); + v.set(1, 99i64).unwrap(); + assert_eq!(v.as_slice::().unwrap(), &[1, 99, 3]); + v.push(4i64).unwrap(); + assert_eq!(v.as_slice::().unwrap(), &[1, 99, 3, 4]); + // type mismatch rejected + assert!(v.set(0, 1.0f64).is_err()); + Ok(()) + }) + .unwrap(); } #[test] fn vector_slice_and_concat() { - let _rt = Runtime::new().unwrap(); - let v = Value::vec(&[1i64, 2, 3, 4, 5]); - let s = v.slice(1, 3).unwrap(); - assert_eq!(s.as_slice::().unwrap(), &[2, 3, 4]); - let a = Value::vec(&[1i64, 2]); - let b = Value::vec(&[3i64, 4]); - let c = a.concat(&b).unwrap(); - assert_eq!(c.as_slice::().unwrap(), &[1, 2, 3, 4]); + Runtime::scope(|_rt| { + let v = Value::vec(&[1i64, 2, 3, 4, 5]); + let s = v.slice(1, 3).unwrap(); + assert_eq!(s.as_slice::().unwrap(), &[2, 3, 4]); + let a = Value::vec(&[1i64, 2]); + let b = Value::vec(&[3i64, 4]); + let c = a.concat(&b).unwrap(); + assert_eq!(c.as_slice::().unwrap(), &[1, 2, 3, 4]); + Ok(()) + }) + .unwrap(); } #[test] fn vector_nulls() { - let _rt = Runtime::new().unwrap(); - // A raw buffer carrying a coincidental sentinel is NOT null until the - // HAS_NULLS attribute is set — matching the engine's design. - let raw = Value::vec(&[1i64, i64::MIN, 3]); - assert!(!raw.is_null_at(1)); - - // Explicitly marking an element null is the supported path. - let mut v = Value::vec(&[1i64, 2, 3]); - v.set_null(1, true).unwrap(); - assert!(v.is_null_at(1)); - assert!(v.get(1).unwrap().is_null()); - assert_eq!(v.get(0).unwrap().as_i64().unwrap(), 1); + Runtime::scope(|_rt| { + // A raw buffer carrying a coincidental sentinel is NOT null until the + // HAS_NULLS attribute is set — matching the engine's design. + let raw = Value::vec(&[1i64, i64::MIN, 3]); + assert!(!raw.is_null_at(1)); + + // Explicitly marking an element null is the supported path. + let mut v = Value::vec(&[1i64, 2, 3]); + v.set_null(1, true).unwrap(); + assert!(v.is_null_at(1)); + assert!(v.get(1).unwrap().is_null()); + assert_eq!(v.get(0).unwrap().as_i64().unwrap(), 1); + Ok(()) + }) + .unwrap(); } #[test] fn symbol_and_string_vectors() { - let _rt = Runtime::new().unwrap(); - let syms = Value::sym_vec(&["aaa", "bbb", "ccc"]); - assert_eq!(syms.len(), 3); - assert_eq!(syms.get(1).unwrap().as_sym().unwrap(), "bbb"); - - let strs = Value::str_vec(&["hello", "a longer value here", ""]); - assert_eq!(strs.len(), 3); - assert_eq!(strs.get(0).unwrap().as_string().unwrap(), "hello"); - assert_eq!( - strs.get(1).unwrap().as_string().unwrap(), - "a longer value here" - ); + Runtime::scope(|_rt| { + let syms = Value::sym_vec(&["aaa", "bbb", "ccc"]); + assert_eq!(syms.len(), 3); + assert_eq!(syms.get(1).unwrap().as_sym().unwrap(), "bbb"); + + let strs = Value::str_vec(&["hello", "a longer value here", ""]); + assert_eq!(strs.len(), 3); + assert_eq!(strs.get(0).unwrap().as_string().unwrap(), "hello"); + assert_eq!( + strs.get(1).unwrap().as_string().unwrap(), + "a longer value here" + ); + Ok(()) + }) + .unwrap(); } #[test] fn list_heterogeneous() { - let _rt = Runtime::new().unwrap(); - let items = [Value::i64(42), Value::sym("x"), Value::f64(3.5)]; - let l = Value::list(&items); - assert!(l.is_list()); - assert_eq!(l.len(), 3); - assert_eq!(l.get(0).unwrap().as_i64().unwrap(), 42); - assert_eq!(l.get(1).unwrap().as_sym().unwrap(), "x"); - assert_eq!(l.get(2).unwrap().as_f64().unwrap(), 3.5); - - // source values keep their own reference (list retained them) - assert_eq!(items[0].as_i64().unwrap(), 42); + Runtime::scope(|_rt| { + let items = [Value::i64(42), Value::sym("x"), Value::f64(3.5)]; + let l = Value::list(&items); + assert!(l.is_list()); + assert_eq!(l.len(), 3); + assert_eq!(l.get(0).unwrap().as_i64().unwrap(), 42); + assert_eq!(l.get(1).unwrap().as_sym().unwrap(), "x"); + assert_eq!(l.get(2).unwrap().as_f64().unwrap(), 3.5); + + // source values keep their own reference (list retained them) + assert_eq!(items[0].as_i64().unwrap(), 42); + Ok(()) + }) + .unwrap(); } #[test] fn list_push() { - let _rt = Runtime::new().unwrap(); - let mut l = Value::empty_list(2); - l.list_push(&Value::i64(1)).unwrap(); - l.list_push(&Value::sym("two")).unwrap(); - assert_eq!(l.len(), 2); - assert_eq!(l.get(1).unwrap().as_sym().unwrap(), "two"); + Runtime::scope(|_rt| { + let mut l = Value::empty_list(2); + l.list_push(&Value::i64(1)).unwrap(); + l.list_push(&Value::sym("two")).unwrap(); + assert_eq!(l.len(), 2); + assert_eq!(l.get(1).unwrap().as_sym().unwrap(), "two"); + Ok(()) + }) + .unwrap(); } #[test] fn dict_construction_and_lookup() { - let _rt = Runtime::new().unwrap(); - let keys = Value::sym_vec(&["a", "b", "c"]); - let vals = Value::vec(&[1i64, 2, 3]); - let d = Value::dict(keys, vals); - assert!(d.is_dict()); - assert_eq!(d.dict_len().unwrap(), 3); - assert_eq!( - d.dict_keys().unwrap().get(0).unwrap().as_sym().unwrap(), - "a" - ); - assert_eq!( - d.dict_values().unwrap().as_slice::().unwrap(), - &[1, 2, 3] - ); - - let got = d.dict_get(&Value::sym("b")).unwrap(); - assert_eq!(got.unwrap().as_i64().unwrap(), 2); - assert!(d.dict_get(&Value::sym("missing")).unwrap().is_none()); + Runtime::scope(|_rt| { + let keys = Value::sym_vec(&["a", "b", "c"]); + let vals = Value::vec(&[1i64, 2, 3]); + let d = Value::dict(keys, vals); + assert!(d.is_dict()); + assert_eq!(d.dict_len().unwrap(), 3); + assert_eq!( + d.dict_keys().unwrap().get(0).unwrap().as_sym().unwrap(), + "a" + ); + assert_eq!( + d.dict_values().unwrap().as_slice::().unwrap(), + &[1, 2, 3] + ); + + let got = d.dict_get(&Value::sym("b")).unwrap(); + assert_eq!(got.unwrap().as_i64().unwrap(), 2); + assert!(d.dict_get(&Value::sym("missing")).unwrap().is_none()); + Ok(()) + }) + .unwrap(); } #[test] fn bool_and_temporal_slices() { - let _rt = Runtime::new().unwrap(); - let b = Value::bool_vec(&[true, false, true]); - assert_eq!(b.bool_slice().unwrap(), &[1u8, 0, 1]); - - let dates = Value::empty_vec(rayforce::sys::RAY_DATE as i8, 0); - let _ = dates; // construct-by-slice for temporals comes via Value::vec on i32 raw later - // date/time/timestamp readers reject a plain i64 vector - let v = Value::vec(&[1i64, 2, 3]); - assert!(v.date_days_slice().is_err()); - assert!(v.timestamp_nanos_slice().is_err()); + Runtime::scope(|_rt| { + let b = Value::bool_vec(&[true, false, true]); + assert_eq!(b.bool_slice().unwrap(), &[1u8, 0, 1]); + + let dates = Value::empty_vec(rayforce::sys::RAY_DATE as i8, 0); + let _ = dates; // construct-by-slice for temporals comes via Value::vec on i32 raw later + // date/time/timestamp readers reject a plain i64 vector + let v = Value::vec(&[1i64, 2, 3]); + assert!(v.date_days_slice().is_err()); + assert!(v.timestamp_nanos_slice().is_err()); + Ok(()) + }) + .unwrap(); } #[test] fn sym_get_is_lossless() { - let _rt = Runtime::new().unwrap(); - // get() boxes a symbol directly from its id (no string round-trip). - let syms = Value::sym_vec(&["alpha", "beta"]); - assert_eq!(syms.get(0).unwrap().as_sym().unwrap(), "alpha"); - assert_eq!(syms.get(1).unwrap().as_sym().unwrap(), "beta"); + Runtime::scope(|_rt| { + // get() boxes a symbol directly from its id (no string round-trip). + let syms = Value::sym_vec(&["alpha", "beta"]); + assert_eq!(syms.get(0).unwrap().as_sym().unwrap(), "alpha"); + assert_eq!(syms.get(1).unwrap().as_sym().unwrap(), "beta"); + Ok(()) + }) + .unwrap(); } #[test] fn set_out_of_range_errors_no_leak() { - let _rt = Runtime::new().unwrap(); - let mut v = Value::vec(&[1i64, 2, 3]); - assert!(v.set(5, 9i64).is_err()); - // vector is still usable and unchanged - assert_eq!(v.as_slice::().unwrap(), &[1, 2, 3]); + Runtime::scope(|_rt| { + let mut v = Value::vec(&[1i64, 2, 3]); + assert!(v.set(5, 9i64).is_err()); + // vector is still usable and unchanged + assert_eq!(v.as_slice::().unwrap(), &[1, 2, 3]); + Ok(()) + }) + .unwrap(); } #[test] fn vector_matches_engine() { - let _rt = Runtime::new().unwrap(); - // A constructed i64 vector formats like the engine's `(til 5)` (0 1 2 3 4). - let v = Value::vec(&[0i64, 1, 2, 3, 4]); - assert_eq!(v.format(), eval("(til 5)").unwrap().format()); + Runtime::scope(|_rt| { + // A constructed i64 vector formats like the engine's `(til 5)` (0 1 2 3 4). + let v = Value::vec(&[0i64, 1, 2, 3, 4]); + assert_eq!(v.format(), eval("(til 5)").unwrap().format()); + Ok(()) + }) + .unwrap(); } diff --git a/rayforce/tests/expr.rs b/rayforce/tests/expr.rs index 105e52e..9daa0ed 100644 --- a/rayforce/tests/expr.rs +++ b/rayforce/tests/expr.rs @@ -4,95 +4,119 @@ use rayforce::{col, lit, sum, Expr, Operation, Runtime, Value}; #[test] fn literal_arithmetic() { - let _rt = Runtime::new().unwrap(); - // (+ [1 2 3] 10) -> [11 12 13] - let e = lit(Value::vec(&[1i64, 2, 3])) + 10i64; - let r = e.execute().unwrap(); - assert_eq!(r.as_slice::().unwrap(), &[11, 12, 13]); + Runtime::scope(|_rt| { + // (+ [1 2 3] 10) -> [11 12 13] + let e = lit(Value::vec(&[1i64, 2, 3])) + 10i64; + let r = e.execute().unwrap(); + assert_eq!(r.as_slice::().unwrap(), &[11, 12, 13]); + Ok(()) + }) + .unwrap(); } #[test] fn chained_arithmetic() { - let _rt = Runtime::new().unwrap(); - // (* (- v 1) 2) over [1 2 3] -> [0 2 4] - let v = lit(Value::vec(&[1i64, 2, 3])); - let e = (v - 1i64) * 2i64; - assert_eq!(e.execute().unwrap().as_slice::().unwrap(), &[0, 2, 4]); + Runtime::scope(|_rt| { + // (* (- v 1) 2) over [1 2 3] -> [0 2 4] + let v = lit(Value::vec(&[1i64, 2, 3])); + let e = (v - 1i64) * 2i64; + assert_eq!(e.execute().unwrap().as_slice::().unwrap(), &[0, 2, 4]); + Ok(()) + }) + .unwrap(); } #[test] fn aggregation_free_and_method() { - let _rt = Runtime::new().unwrap(); - let data = Value::vec(&[1i64, 2, 3, 4]); - // free function form - assert_eq!( - sum(lit(data.clone())).execute().unwrap().as_i64().unwrap(), - 10 - ); - // method form - assert_eq!(lit(data).sum().execute().unwrap().as_i64().unwrap(), 10); + Runtime::scope(|_rt| { + let data = Value::vec(&[1i64, 2, 3, 4]); + // free function form + assert_eq!( + sum(lit(data.clone())).execute().unwrap().as_i64().unwrap(), + 10 + ); + // method form + assert_eq!(lit(data).sum().execute().unwrap().as_i64().unwrap(), 10); + Ok(()) + }) + .unwrap(); } #[test] fn comparison_produces_mask() { - let _rt = Runtime::new().unwrap(); - // (> [1 2 3 4] 2) -> [0 0 1 1] (bool vector) - let e = lit(Value::vec(&[1i64, 2, 3, 4])).gt(2i64); - let r = e.execute().unwrap(); - assert_eq!(r.len(), 4); - // formatting check (engine bool vec) - assert!(!r.get(0).unwrap().as_bool().unwrap()); - assert!(r.get(3).unwrap().as_bool().unwrap()); + Runtime::scope(|_rt| { + // (> [1 2 3 4] 2) -> [0 0 1 1] (bool vector) + let e = lit(Value::vec(&[1i64, 2, 3, 4])).gt(2i64); + let r = e.execute().unwrap(); + assert_eq!(r.len(), 4); + // formatting check (engine bool vec) + assert!(!r.get(0).unwrap().as_bool().unwrap()); + assert!(r.get(3).unwrap().as_bool().unwrap()); + Ok(()) + }) + .unwrap(); } #[test] fn column_reference_resolves_from_global() { - let _rt = Runtime::new().unwrap(); - // Bind a global vector `xs`, then reference it by name in an expression. - let xs = Value::vec(&[10i64, 20, 30]); - rayforce::set_global("xs", &xs).unwrap(); + Runtime::scope(|_rt| { + // Bind a global vector `xs`, then reference it by name in an expression. + let xs = Value::vec(&[10i64, 20, 30]); + rayforce::set_global("xs", &xs).unwrap(); - let e = col("xs") + 5i64; - assert_eq!( - e.execute().unwrap().as_slice::().unwrap(), - &[15, 25, 35] - ); + let e = col("xs") + 5i64; + assert_eq!( + e.execute().unwrap().as_slice::().unwrap(), + &[15, 25, 35] + ); - assert_eq!(sum(col("xs")).execute().unwrap().as_i64().unwrap(), 60); + assert_eq!(sum(col("xs")).execute().unwrap().as_i64().unwrap(), 60); + Ok(()) + }) + .unwrap(); } #[test] fn logical_combination() { - let _rt = Runtime::new().unwrap(); - rayforce::set_global("v", &Value::vec(&[1i64, 5, 10, 15])).unwrap(); - // (and (> v 2) (< v 12)) -> [0 1 1 0] - let e = col("v").gt(2i64).and(col("v").lt(12i64)); - let r = e.execute().unwrap(); - let bits: Vec = (0..4) - .map(|i| r.get(i).unwrap().as_bool().unwrap()) - .collect(); - assert_eq!(bits, vec![false, true, true, false]); + Runtime::scope(|_rt| { + rayforce::set_global("v", &Value::vec(&[1i64, 5, 10, 15])).unwrap(); + // (and (> v 2) (< v 12)) -> [0 1 1 0] + let e = col("v").gt(2i64).and(col("v").lt(12i64)); + let r = e.execute().unwrap(); + let bits: Vec = (0..4) + .map(|i| r.get(i).unwrap().as_bool().unwrap()) + .collect(); + assert_eq!(bits, vec![false, true, true, false]); + Ok(()) + }) + .unwrap(); } #[test] fn operator_overload_bitand() { - let _rt = Runtime::new().unwrap(); - rayforce::set_global("w", &Value::vec(&[1i64, 5, 10, 15])).unwrap(); - // same as logical_combination but using `&` - let e = col("w").gt(2i64) & col("w").lt(12i64); - let r = e.execute().unwrap(); - assert!(!r.get(0).unwrap().as_bool().unwrap()); - assert!(r.get(1).unwrap().as_bool().unwrap()); + Runtime::scope(|_rt| { + rayforce::set_global("w", &Value::vec(&[1i64, 5, 10, 15])).unwrap(); + // same as logical_combination but using `&` + let e = col("w").gt(2i64) & col("w").lt(12i64); + let r = e.execute().unwrap(); + assert!(!r.get(0).unwrap().as_bool().unwrap()); + assert!(r.get(1).unwrap().as_bool().unwrap()); + Ok(()) + }) + .unwrap(); } #[test] fn compile_shape_is_a_list() { - let _rt = Runtime::new().unwrap(); - let e = col("price").gt(100i64); - let compiled = e.compile(); - // (> price 100) — a 3-element list: op, name-ref, literal - assert!(compiled.is_list()); - assert_eq!(compiled.len(), 3); + Runtime::scope(|_rt| { + let e = col("price").gt(100i64); + let compiled = e.compile(); + // (> price 100) — a 3-element list: op, name-ref, literal + assert!(compiled.is_list()); + assert_eq!(compiled.len(), 3); + Ok(()) + }) + .unwrap(); } #[test] @@ -105,10 +129,13 @@ fn operation_names() { #[test] fn sum_of_filter() { - let _rt = Runtime::new().unwrap(); - rayforce::set_global("c", &Value::vec(&[10i64, 20, 30, 40])).unwrap(); - rayforce::set_global("m", &Value::bool_vec(&[true, false, true, false])).unwrap(); - // sum(filter(c, m)) keeps [10, 30] -> 40 - let e = Expr::op(Operation::Sum, vec![col("c").filter(col("m"))]); - assert_eq!(e.execute().unwrap().as_i64().unwrap(), 40); + Runtime::scope(|_rt| { + rayforce::set_global("c", &Value::vec(&[10i64, 20, 30, 40])).unwrap(); + rayforce::set_global("m", &Value::bool_vec(&[true, false, true, false])).unwrap(); + // sum(filter(c, m)) keeps [10, 30] -> 40 + let e = Expr::op(Operation::Sum, vec![col("c").filter(col("m"))]); + assert_eq!(e.execute().unwrap().as_i64().unwrap(), 40); + Ok(()) + }) + .unwrap(); } diff --git a/rayforce/tests/ipc.rs b/rayforce/tests/ipc.rs index b80502a..7124a7c 100644 --- a/rayforce/tests/ipc.rs +++ b/rayforce/tests/ipc.rs @@ -61,10 +61,25 @@ fn spawn_server(bin: &PathBuf, port: u16) -> Server { panic!("server did not become reachable on port {port}"); } +/// Whether a missing server binary is a hard error rather than a skip. +/// +/// Skipping reports as a pass, which is indistinguishable from having run — +/// so on its own it lets this file's coverage lapse unnoticed, and this is the +/// only file that drives [`TcpClient`] against a real server. CI sets +/// `RAYFORCE_REQUIRE_SERVER=1` after building the binary, so a path that stops +/// resolving fails the job instead of quietly going green. +fn server_required() -> bool { + matches!(std::env::var("RAYFORCE_REQUIRE_SERVER"), Ok(v) if !v.is_empty() && v != "0") +} + macro_rules! require_binary { () => { match binary_path() { Some(b) => b, + None if server_required() => panic!( + "RAYFORCE_REQUIRE_SERVER is set but no rayforce binary was found; \ + point RAYFORCE_BINARY at one or run `make release` in the core checkout" + ), None => { let _ = writeln!(std::io::stderr(), "skipping IPC test: no rayforce binary"); return; @@ -76,42 +91,76 @@ macro_rules! require_binary { #[test] fn client_executes_arithmetic() { let bin = require_binary!(); - let _rt = Runtime::new().unwrap(); - let port = free_port(); - let _server = spawn_server(&bin, port); + Runtime::scope(|_rt| { + let port = free_port(); + let _server = spawn_server(&bin, port); - let client = TcpClient::connect("127.0.0.1", port, "", "").unwrap(); - let r = client.execute("(+ 1 2)").unwrap(); - assert_eq!(r.as_i64().unwrap(), 3); + let client = TcpClient::connect("127.0.0.1", port, "", "").unwrap(); + let r = client.execute("(+ 1 2)").unwrap(); + assert_eq!(r.as_i64().unwrap(), 3); + Ok(()) + }) + .unwrap(); } #[test] fn client_roundtrips_vector() { let bin = require_binary!(); - let _rt = Runtime::new().unwrap(); - let port = free_port(); - let _server = spawn_server(&bin, port); + Runtime::scope(|_rt| { + let port = free_port(); + let _server = spawn_server(&bin, port); - let client = TcpClient::connect("127.0.0.1", port, "", "").unwrap(); - let r = client.execute("(til 5)").unwrap(); - assert_eq!(r.as_slice::().unwrap(), &[0, 1, 2, 3, 4]); + let client = TcpClient::connect("127.0.0.1", port, "", "").unwrap(); + let r = client.execute("(til 5)").unwrap(); + assert_eq!(r.as_slice::().unwrap(), &[0, 1, 2, 3, 4]); + Ok(()) + }) + .unwrap(); } #[test] fn client_reports_server_error() { let bin = require_binary!(); - let _rt = Runtime::new().unwrap(); - let port = free_port(); - let _server = spawn_server(&bin, port); + Runtime::scope(|_rt| { + let port = free_port(); + let _server = spawn_server(&bin, port); - let client = TcpClient::connect("127.0.0.1", port, "", "").unwrap(); - assert!(client.execute("(undefined_symbol_xyz)").is_err()); + let client = TcpClient::connect("127.0.0.1", port, "", "").unwrap(); + assert!(client.execute("(undefined_symbol_xyz)").is_err()); + Ok(()) + }) + .unwrap(); } #[test] fn connect_failure_is_an_error() { - let _rt = Runtime::new().unwrap(); - // Nothing listening on this port. + Runtime::scope(|_rt| { + // Nothing listening on this port. + let port = free_port(); + assert!(TcpClient::connect("127.0.0.1", port, "", "").is_err()); + Ok(()) + }) + .unwrap(); +} + +#[test] +fn a_client_is_closed_before_its_scope_ends() { + // The hole no generation check ever covered: `TcpClient::drop` calls + // `ray_ipc_close`, which reaches into the engine, and nothing ordered it + // against the unmap. The scope does: the closure's locals are dropped + // before it returns, and only then is the runtime torn down. Left implicit + // on purpose — an explicit `drop(client)` would not test the ordering. + let bin = require_binary!(); let port = free_port(); - assert!(TcpClient::connect("127.0.0.1", port, "", "").is_err()); + let _server = spawn_server(&bin, port); + + Runtime::scope(|_rt| { + let client = TcpClient::connect("127.0.0.1", port, "", "").unwrap(); + assert_eq!(client.execute("(+ 1 2)").unwrap().as_i64().unwrap(), 3); + Ok(()) + }) + .unwrap(); + + // The close ran against a mapped heap, so the next scope starts cleanly. + Runtime::scope(|rt| rt.eval("1")?.as_i64()).unwrap(); } diff --git a/rayforce/tests/lambda.rs b/rayforce/tests/lambda.rs index aa90ff1..9d4053a 100644 --- a/rayforce/tests/lambda.rs +++ b/rayforce/tests/lambda.rs @@ -4,100 +4,121 @@ use rayforce::{col, sum, Fn, Runtime, Table, Value}; #[test] fn direct_call_scalar() { - let _rt = Runtime::new().unwrap(); - let square = Fn::new("(fn [x] (* x x))").unwrap(); - assert_eq!(square.call(&[Value::i64(5)]).unwrap().as_i64().unwrap(), 25); - assert_eq!( - square.call(&[Value::i64(10)]).unwrap().as_i64().unwrap(), - 100 - ); + Runtime::scope(|_rt| { + let square = Fn::new("(fn [x] (* x x))").unwrap(); + assert_eq!(square.call(&[Value::i64(5)]).unwrap().as_i64().unwrap(), 25); + assert_eq!( + square.call(&[Value::i64(10)]).unwrap().as_i64().unwrap(), + 100 + ); + Ok(()) + }) + .unwrap(); } #[test] fn direct_call_multiple_args() { - let _rt = Runtime::new().unwrap(); - let add = Fn::new("(fn [x y] (+ x y))").unwrap(); - assert_eq!( - add.call(&[Value::i64(5), Value::i64(3)]) - .unwrap() - .as_i64() - .unwrap(), - 8 - ); + Runtime::scope(|_rt| { + let add = Fn::new("(fn [x y] (+ x y))").unwrap(); + assert_eq!( + add.call(&[Value::i64(5), Value::i64(3)]) + .unwrap() + .as_i64() + .unwrap(), + 8 + ); + Ok(()) + }) + .unwrap(); } #[test] fn call_over_vector() { - let _rt = Runtime::new().unwrap(); - let square = Fn::new("(fn [x] (* x x))").unwrap(); - let r = square.call(&[Value::vec(&[2i64, 3, 4])]).unwrap(); - assert_eq!(r.as_slice::().unwrap(), &[4, 9, 16]); + Runtime::scope(|_rt| { + let square = Fn::new("(fn [x] (* x x))").unwrap(); + let r = square.call(&[Value::vec(&[2i64, 3, 4])]).unwrap(); + assert_eq!(r.as_slice::().unwrap(), &[4, 9, 16]); + Ok(()) + }) + .unwrap(); } #[test] fn rejects_non_fn_source() { - let _rt = Runtime::new().unwrap(); - assert!(Fn::new("(+ 1 2)").is_err()); + Runtime::scope(|_rt| { + assert!(Fn::new("(+ 1 2)").is_err()); + Ok(()) + }) + .unwrap(); } #[test] fn apply_in_select() { - let _rt = Runtime::new().unwrap(); - let table = Table::new( - &["id", "value"], - &[Value::sym_vec(&["a", "b", "c"]), Value::vec(&[2i64, 3, 4])], - ) - .unwrap(); - - let square = Fn::new("(fn [x] (* x x))").unwrap(); - let out = table - .select() - .col("id") - .agg("squared", square.apply([col("value")]).unwrap()) - .execute() + Runtime::scope(|_rt| { + let table = Table::new( + &["id", "value"], + &[Value::sym_vec(&["a", "b", "c"]), Value::vec(&[2i64, 3, 4])], + ) .unwrap(); - let squared = out.column("squared").unwrap(); - assert_eq!(squared.as_slice::().unwrap(), &[4, 9, 16]); + let square = Fn::new("(fn [x] (* x x))").unwrap(); + let out = table + .select() + .col("id") + .agg("squared", square.apply([col("value")]).unwrap()) + .execute() + .unwrap(); + + let squared = out.column("squared").unwrap(); + assert_eq!(squared.as_slice::().unwrap(), &[4, 9, 16]); + Ok(()) + }) + .unwrap(); } #[test] fn apply_with_aggregation() { - let _rt = Runtime::new().unwrap(); - let table = Table::new( - &["id", "value"], - &[ - Value::sym_vec(&["a", "b", "c", "d"]), - Value::vec(&[2i64, 3, 4, 5]), - ], - ) - .unwrap(); - - let square = Fn::new("(fn [x] (* x x))").unwrap(); - let out = table - .select() - .agg("sum_sq", sum(square.apply([col("value")]).unwrap())) - .execute() + Runtime::scope(|_rt| { + let table = Table::new( + &["id", "value"], + &[ + Value::sym_vec(&["a", "b", "c", "d"]), + Value::vec(&[2i64, 3, 4, 5]), + ], + ) .unwrap(); - // 4 + 9 + 16 + 25 = 54 - assert_eq!( - out.column("sum_sq") - .unwrap() - .get(0) - .unwrap() - .as_i64() - .unwrap(), - 54 - ); + let square = Fn::new("(fn [x] (* x x))").unwrap(); + let out = table + .select() + .agg("sum_sq", sum(square.apply([col("value")]).unwrap())) + .execute() + .unwrap(); + + // 4 + 9 + 16 + 25 = 54 + assert_eq!( + out.column("sum_sq") + .unwrap() + .get(0) + .unwrap() + .as_i64() + .unwrap(), + 54 + ); + Ok(()) + }) + .unwrap(); } #[test] fn introspection() { - let _rt = Runtime::new().unwrap(); - let square = Fn::new("(fn [x] (* x x))").unwrap(); - assert_eq!(square.source(), Some("(fn [x] (* x x))")); - assert_eq!(format!("{square}"), "(fn [x] (* x x))"); - // `meta` reflects whatever the engine reports for the lambda. - assert!(square.meta().is_ok()); + Runtime::scope(|_rt| { + let square = Fn::new("(fn [x] (* x x))").unwrap(); + assert_eq!(square.source(), Some("(fn [x] (* x x))")); + assert_eq!(format!("{square}"), "(fn [x] (* x x))"); + // `meta` reflects whatever the engine reports for the lambda. + assert!(square.meta().is_ok()); + Ok(()) + }) + .unwrap(); } diff --git a/rayforce/tests/no_runtime.rs b/rayforce/tests/no_runtime.rs new file mode 100644 index 0000000..cbe8103 --- /dev/null +++ b/rayforce/tests/no_runtime.rs @@ -0,0 +1,27 @@ +//! Building a value requires a live `Runtime`. +//! +//! Its own test binary on purpose: this is about a process where no runtime was +//! ever created, which cannot be arranged in a file that also runs tests that +//! build one. +//! +//! Nothing here used to fail. `ray_alloc` lazily mmaps a thread-local heap when +//! none exists (core `heap.c:1386-1390`), so a constructor with no runtime +//! quietly allocated into an orphan heap instead of crashing — which is why the +//! hole survived so long. + +#[test] +#[should_panic(expected = "requires a live Runtime")] +fn an_atom_cannot_be_built_without_a_runtime() { + assert!(!rayforce::on_runtime_thread()); + let _ = rayforce::Value::i64(41); +} + +#[test] +#[should_panic(expected = "requires a live Runtime")] +fn a_symbol_cannot_be_built_without_a_runtime() { + // The sharp case: symbols are runtime-scoped, so with no symbol table to + // intern into this returned an *empty* symbol — "hello" silently dropped on + // the floor, no error anywhere. + assert!(!rayforce::on_runtime_thread()); + let _ = rayforce::Value::sym("hello"); +} diff --git a/rayforce/tests/q.rs b/rayforce/tests/q.rs index dc3794f..41b1fa6 100644 --- a/rayforce/tests/q.rs +++ b/rayforce/tests/q.rs @@ -77,36 +77,41 @@ fn spawn_mock(response: Vec) -> u16 { #[test] fn q_pulls_a_table() { - let _rt = Runtime::new().unwrap(); + Runtime::scope(|_rt| { + let response = msg(&table( + &["seq", "sym"], + &[long_vec(&[1, 2, 3]), sym_vec(&["AAPL", "MSFT", "GOOG"])], + )); + let port = spawn_mock(response); - let response = msg(&table( - &["seq", "sym"], - &[long_vec(&[1, 2, 3]), sym_vec(&["AAPL", "MSFT", "GOOG"])], - )); - let port = spawn_mock(response); + let conn = QConnection::connect("127.0.0.1", port).unwrap(); + let v = conn.execute("select from fixmsgs where i > 0").unwrap(); - let conn = QConnection::connect("127.0.0.1", port).unwrap(); - let v = conn.execute("select from fixmsgs where i > 0").unwrap(); - - let t = Table::from_value(v).unwrap(); - assert_eq!(t.shape(), (3, 2)); - assert_eq!( - t.column("seq").unwrap().as_slice::().unwrap(), - &[1, 2, 3] - ); - let sym = t.column("sym").unwrap(); - assert_eq!(sym.get(0).unwrap().as_sym().unwrap(), "AAPL"); - assert_eq!(sym.get(2).unwrap().as_sym().unwrap(), "GOOG"); + let t = Table::from_value(v).unwrap(); + assert_eq!(t.shape(), (3, 2)); + assert_eq!( + t.column("seq").unwrap().as_slice::().unwrap(), + &[1, 2, 3] + ); + let sym = t.column("sym").unwrap(); + assert_eq!(sym.get(0).unwrap().as_sym().unwrap(), "AAPL"); + assert_eq!(sym.get(2).unwrap().as_sym().unwrap(), "GOOG"); + Ok(()) + }) + .unwrap(); } #[test] fn q_surfaces_server_error() { - let _rt = Runtime::new().unwrap(); - // Q error frame: type -128 then a NUL-terminated message. - let mut body = vec![(-128i8) as u8]; - body.extend_from_slice(b"type\0"); - let port = spawn_mock(msg(&body)); + Runtime::scope(|_rt| { + // Q error frame: type -128 then a NUL-terminated message. + let mut body = vec![(-128i8) as u8]; + body.extend_from_slice(b"type\0"); + let port = spawn_mock(msg(&body)); - let conn = QConnection::connect("127.0.0.1", port).unwrap(); - assert!(conn.execute("1+`a").is_err()); + let conn = QConnection::connect("127.0.0.1", port).unwrap(); + assert!(conn.execute("1+`a").is_err()); + Ok(()) + }) + .unwrap(); } diff --git a/rayforce/tests/q_real.rs b/rayforce/tests/q_real.rs index 0ca6182..d975d54 100644 --- a/rayforce/tests/q_real.rs +++ b/rayforce/tests/q_real.rs @@ -21,33 +21,37 @@ fn real_q_roundtrips_atoms_vectors_and_tables() { eprintln!("RAYFORCE_Q_ADDR unset — skipping real-q e2e"); return; }; - let _rt = Runtime::new().unwrap(); - let conn = QConnection::connect(&host, port).unwrap(); - - // scalar - let v = conn.execute("ping 41").unwrap(); - assert_eq!(v.format(), "42"); - - // vector - let v = conn.execute("exec seq from fixmsgs").unwrap(); - assert_eq!(v.as_slice::().unwrap(), &[1, 2, 3, 4, 5]); - - // full table - let t = Table::from_value(conn.execute("select from fixmsgs").unwrap()).unwrap(); - assert_eq!(t.shape(), (5, 4)); - assert_eq!( - t.column("seq").unwrap().as_slice::().unwrap(), - &[1, 2, 3, 4, 5] - ); - let sym = t.column("sym").unwrap(); - assert_eq!(sym.get(0).unwrap().as_sym().unwrap(), "AAPL"); - assert_eq!(sym.get(4).unwrap().as_sym().unwrap(), "TSLA"); - - // RevoLT-style pull-by-sequence: only rows past a cursor - let t = Table::from_value(conn.execute("select from fixmsgs where seq > 3").unwrap()).unwrap(); - assert_eq!(t.shape(), (2, 4)); - assert_eq!(t.column("seq").unwrap().as_slice::().unwrap(), &[4, 5]); - - // server-side error surfaces as Err - assert!(conn.execute("1+`a").is_err()); + Runtime::scope(|_rt| { + let conn = QConnection::connect(&host, port).unwrap(); + + // scalar + let v = conn.execute("ping 41").unwrap(); + assert_eq!(v.format(), "42"); + + // vector + let v = conn.execute("exec seq from fixmsgs").unwrap(); + assert_eq!(v.as_slice::().unwrap(), &[1, 2, 3, 4, 5]); + + // full table + let t = Table::from_value(conn.execute("select from fixmsgs").unwrap()).unwrap(); + assert_eq!(t.shape(), (5, 4)); + assert_eq!( + t.column("seq").unwrap().as_slice::().unwrap(), + &[1, 2, 3, 4, 5] + ); + let sym = t.column("sym").unwrap(); + assert_eq!(sym.get(0).unwrap().as_sym().unwrap(), "AAPL"); + assert_eq!(sym.get(4).unwrap().as_sym().unwrap(), "TSLA"); + + // RevoLT-style pull-by-sequence: only rows past a cursor + let t = + Table::from_value(conn.execute("select from fixmsgs where seq > 3").unwrap()).unwrap(); + assert_eq!(t.shape(), (2, 4)); + assert_eq!(t.column("seq").unwrap().as_slice::().unwrap(), &[4, 5]); + + // server-side error surfaces as Err + assert!(conn.execute("1+`a").is_err()); + Ok(()) + }) + .unwrap(); } diff --git a/rayforce/tests/query.rs b/rayforce/tests/query.rs index 98b1640..4ab430b 100644 --- a/rayforce/tests/query.rs +++ b/rayforce/tests/query.rs @@ -13,160 +13,190 @@ fn trades() -> Table { #[test] fn select_columns() { - let _rt = Runtime::new().unwrap(); - let t = trades(); - let r = t.select().cols(["sym", "price"]).execute().unwrap(); - assert_eq!(r.ncols(), 2); - assert_eq!(r.nrows(), 5); - assert_eq!(r.column_names(), vec!["sym", "price"]); + Runtime::scope(|_rt| { + let t = trades(); + let r = t.select().cols(["sym", "price"]).execute().unwrap(); + assert_eq!(r.ncols(), 2); + assert_eq!(r.nrows(), 5); + assert_eq!(r.column_names(), vec!["sym", "price"]); + Ok(()) + }) + .unwrap(); } #[test] fn select_where() { - let _rt = Runtime::new().unwrap(); - let t = trades(); - let r = t - .select() - .col("price") - .filter(col("price").gt(150.0)) - .execute() - .unwrap(); - // prices > 150 -> 200, 300, 210 - assert_eq!(r.nrows(), 3); - let prices = r - .column("price") - .unwrap() - .as_slice::() - .unwrap() - .to_vec(); - assert_eq!(prices, vec![200.0, 300.0, 210.0]); + Runtime::scope(|_rt| { + let t = trades(); + let r = t + .select() + .col("price") + .filter(col("price").gt(150.0)) + .execute() + .unwrap(); + // prices > 150 -> 200, 300, 210 + assert_eq!(r.nrows(), 3); + let prices = r + .column("price") + .unwrap() + .as_slice::() + .unwrap() + .to_vec(); + assert_eq!(prices, vec![200.0, 300.0, 210.0]); + Ok(()) + }) + .unwrap(); } #[test] fn select_where_combined() { - let _rt = Runtime::new().unwrap(); - let t = trades(); - // price > 150 AND sym == MSFT -> rows with 200, 210 - let r = t - .select() - .filter(col("price").gt(150.0)) - .filter(col("sym").eq("MSFT")) - .execute() - .unwrap(); - assert_eq!(r.nrows(), 2); + Runtime::scope(|_rt| { + let t = trades(); + // price > 150 AND sym == MSFT -> rows with 200, 210 + let r = t + .select() + .filter(col("price").gt(150.0)) + .filter(col("sym").eq("MSFT")) + .execute() + .unwrap(); + assert_eq!(r.nrows(), 2); + Ok(()) + }) + .unwrap(); } #[test] fn select_aggregate_by() { - let _rt = Runtime::new().unwrap(); - let t = trades(); - // total size by sym: AAPL=40, MSFT=70, GOOG=40 - let r = t - .select() - .agg("total", sum(col("size"))) - .by("sym") - .execute() - .unwrap(); - assert_eq!(r.nrows(), 3); - assert!(r.column_names().contains(&"total".to_string())); + Runtime::scope(|_rt| { + let t = trades(); + // total size by sym: AAPL=40, MSFT=70, GOOG=40 + let r = t + .select() + .agg("total", sum(col("size"))) + .by("sym") + .execute() + .unwrap(); + assert_eq!(r.nrows(), 3); + assert!(r.column_names().contains(&"total".to_string())); + Ok(()) + }) + .unwrap(); } #[test] fn select_aggregate_only_collapses_to_one_row() { - let _rt = Runtime::new().unwrap(); - let t = trades(); - let r = t.select().agg("total", sum(col("size"))).execute().unwrap(); - assert_eq!(r.nrows(), 1); - assert_eq!( - r.column("total").unwrap().get(0).unwrap().as_i64().unwrap(), - 150 - ); + Runtime::scope(|_rt| { + let t = trades(); + let r = t.select().agg("total", sum(col("size"))).execute().unwrap(); + assert_eq!(r.nrows(), 1); + assert_eq!( + r.column("total").unwrap().get(0).unwrap().as_i64().unwrap(), + 150 + ); + Ok(()) + }) + .unwrap(); } #[test] fn select_order_by() { - let _rt = Runtime::new().unwrap(); - let t = trades(); - let r = t - .select() - .col("price") - .order_by(["price"], true) // descending - .execute() - .unwrap(); - let prices = r - .column("price") - .unwrap() - .as_slice::() - .unwrap() - .to_vec(); - assert_eq!(prices, vec![300.0, 210.0, 200.0, 110.0, 100.0]); + Runtime::scope(|_rt| { + let t = trades(); + let r = t + .select() + .col("price") + .order_by(["price"], true) // descending + .execute() + .unwrap(); + let prices = r + .column("price") + .unwrap() + .as_slice::() + .unwrap() + .to_vec(); + assert_eq!(prices, vec![300.0, 210.0, 200.0, 110.0, 100.0]); + Ok(()) + }) + .unwrap(); } #[test] fn update_adds_column() { - let _rt = Runtime::new().unwrap(); - let t = trades(); - // notional = price * size - let r = t - .update() - .set("notional", col("price") * col("size")) - .execute() - .unwrap(); - assert!(r.column_names().contains(&"notional".to_string())); - let n0 = r - .column("notional") - .unwrap() - .get(0) - .unwrap() - .as_f64() - .unwrap(); - assert_eq!(n0, 1000.0); + Runtime::scope(|_rt| { + let t = trades(); + // notional = price * size + let r = t + .update() + .set("notional", col("price") * col("size")) + .execute() + .unwrap(); + assert!(r.column_names().contains(&"notional".to_string())); + let n0 = r + .column("notional") + .unwrap() + .get(0) + .unwrap() + .as_f64() + .unwrap(); + assert_eq!(n0, 1000.0); + Ok(()) + }) + .unwrap(); } #[test] fn insert_row() { - let _rt = Runtime::new().unwrap(); - let t = trades(); - let r = t - .insert_row(&[Value::sym("TSLA"), Value::f64(250.0), Value::i64(60)]) - .unwrap(); - assert_eq!(r.nrows(), 6); - assert_eq!( - r.column("sym").unwrap().get(5).unwrap().as_sym().unwrap(), - "TSLA" - ); + Runtime::scope(|_rt| { + let t = trades(); + let r = t + .insert_row(&[Value::sym("TSLA"), Value::f64(250.0), Value::i64(60)]) + .unwrap(); + assert_eq!(r.nrows(), 6); + assert_eq!( + r.column("sym").unwrap().get(5).unwrap().as_sym().unwrap(), + "TSLA" + ); + Ok(()) + }) + .unwrap(); } #[test] fn join_tables() { - let _rt = Runtime::new().unwrap(); - let t = trades(); - // reference table: sym -> sector - let sym = Value::sym_vec(&["AAPL", "MSFT", "GOOG"]); - let sector = Value::sym_vec(&["Tech", "Tech", "Search"]); - let ref_tbl = Table::new(&["sym", "sector"], &[sym, sector]).unwrap(); + Runtime::scope(|_rt| { + let t = trades(); + // reference table: sym -> sector + let sym = Value::sym_vec(&["AAPL", "MSFT", "GOOG"]); + let sector = Value::sym_vec(&["Tech", "Tech", "Search"]); + let ref_tbl = Table::new(&["sym", "sector"], &[sym, sector]).unwrap(); - let joined = t.inner_join(&ref_tbl, &["sym"]).unwrap(); - assert!(joined.column_names().contains(&"sector".to_string())); - assert_eq!(joined.nrows(), 5); + let joined = t.inner_join(&ref_tbl, &["sym"]).unwrap(); + assert!(joined.column_names().contains(&"sector".to_string())); + assert_eq!(joined.nrows(), 5); + Ok(()) + }) + .unwrap(); } #[test] fn head_tail_take() { - let _rt = Runtime::new().unwrap(); - let t = trades(); - assert_eq!(t.head(2).unwrap().nrows(), 2); - assert_eq!(t.tail(3).unwrap().nrows(), 3); - // head keeps first rows - let h = t.head(1).unwrap(); - assert_eq!( - h.column("size").unwrap().get(0).unwrap().as_i64().unwrap(), - 10 - ); - // tail keeps last rows - let tl = t.tail(1).unwrap(); - assert_eq!( - tl.column("size").unwrap().get(0).unwrap().as_i64().unwrap(), - 50 - ); + Runtime::scope(|_rt| { + let t = trades(); + assert_eq!(t.head(2).unwrap().nrows(), 2); + assert_eq!(t.tail(3).unwrap().nrows(), 3); + // head keeps first rows + let h = t.head(1).unwrap(); + assert_eq!( + h.column("size").unwrap().get(0).unwrap().as_i64().unwrap(), + 10 + ); + // tail keeps last rows + let tl = t.tail(1).unwrap(); + assert_eq!( + tl.column("size").unwrap().get(0).unwrap().as_i64().unwrap(), + 50 + ); + Ok(()) + }) + .unwrap(); } diff --git a/rayforce/tests/runtime.rs b/rayforce/tests/runtime.rs index 98f5a9a..cb8a1cf 100644 --- a/rayforce/tests/runtime.rs +++ b/rayforce/tests/runtime.rs @@ -1,52 +1,74 @@ //! Phase 1: runtime lifecycle, eval, and Value basics. //! -//! These run serialized (`RUST_TEST_THREADS=1`); each test owns a short-lived -//! `Runtime`, which also exercises create → destroy → recreate within one +//! These run serialized (`RUST_TEST_THREADS=1`); each test opens its own +//! `Runtime::scope`, which also exercises create → destroy → recreate within one //! process — the property the whole test suite relies on. use rayforce::{eval, Runtime}; #[test] fn eval_arithmetic() { - let _rt = Runtime::new().unwrap(); - let v = eval("(+ 1 1)").unwrap(); - assert_eq!(v.format(), "2"); + Runtime::scope(|_rt| { + let v = eval("(+ 1 1)").unwrap(); + assert_eq!(v.format(), "2"); + Ok(()) + }) + .unwrap(); } #[test] -fn recreate_runtime_after_drop() { - { - let _rt = Runtime::new().unwrap(); - assert!(rayforce::is_live()); +fn recreate_runtime_after_scope() { + assert!(!rayforce::on_runtime_thread()); + Runtime::scope(|_rt| { + assert!(rayforce::on_runtime_thread()); assert_eq!(eval("(* 6 7)").unwrap().format(), "42"); - } - assert!(!rayforce::is_live()); + Ok(()) + }) + .unwrap(); + assert!(!rayforce::on_runtime_thread()); // A second runtime in the same process must work (tests depend on this). - { - let _rt = Runtime::new().unwrap(); + Runtime::scope(|_rt| { assert_eq!(eval("(- 10 3)").unwrap().format(), "7"); - } + Ok(()) + }) + .unwrap(); } #[test] fn only_one_live_runtime() { - let _rt = Runtime::new().unwrap(); - assert!(Runtime::new().is_err(), "second runtime should be rejected"); + // The core permits one live runtime at a time, so a nested scope must + // refuse rather than tear the outer one's heap out from under it. + let err = Runtime::scope(|_rt| match Runtime::scope(|_inner| Ok(())) { + Ok(()) => panic!("a nested scope must not start a second runtime"), + Err(e) => Ok(e), + }) + .unwrap(); + assert!( + err.message.contains("cannot be nested"), + "expected a nesting error, got: {}", + err.message + ); } #[test] fn eval_error_is_surfaced() { - let _rt = Runtime::new().unwrap(); - let err = eval("(undefined_name_xyz)").unwrap_err(); - // Should be a categorized error, not a panic. - assert!(!err.code_str.is_empty() || !err.message.is_empty()); + Runtime::scope(|_rt| { + let err = eval("(undefined_name_xyz)").unwrap_err(); + // Should be a categorized error, not a panic. + assert!(!err.code_str.is_empty() || !err.message.is_empty()); + Ok(()) + }) + .unwrap(); } #[test] fn value_type_inspection() { - let _rt = Runtime::new().unwrap(); - let v = eval("(+ 2 3)").unwrap(); - assert!(v.is_atom()); - assert!(!v.is_vec()); - assert!(!v.is_null()); + Runtime::scope(|_rt| { + let v = eval("(+ 2 3)").unwrap(); + assert!(v.is_atom()); + assert!(!v.is_vec()); + assert!(!v.is_null()); + Ok(()) + }) + .unwrap(); } diff --git a/rayforce/tests/scalars.rs b/rayforce/tests/scalars.rs index b3dd236..25cda87 100644 --- a/rayforce/tests/scalars.rs +++ b/rayforce/tests/scalars.rs @@ -4,124 +4,153 @@ use rayforce::{eval, Guid, Runtime, Str, ToValue, Value}; #[test] fn integer_roundtrips() { - let _rt = Runtime::new().unwrap(); - assert_eq!(Value::i16(-12345).as_i16().unwrap(), -12345); - assert_eq!(Value::i32(2_000_000_000).as_i32().unwrap(), 2_000_000_000); - assert_eq!(Value::i64(i64::MAX).as_i64().unwrap(), i64::MAX); - assert_eq!(Value::u8(255).as_u8().unwrap(), 255); - assert!(Value::bool(true).as_bool().unwrap()); - assert!(!Value::bool(false).as_bool().unwrap()); + Runtime::scope(|_rt| { + assert_eq!(Value::i16(-12345).as_i16().unwrap(), -12345); + assert_eq!(Value::i32(2_000_000_000).as_i32().unwrap(), 2_000_000_000); + assert_eq!(Value::i64(i64::MAX).as_i64().unwrap(), i64::MAX); + assert_eq!(Value::u8(255).as_u8().unwrap(), 255); + assert!(Value::bool(true).as_bool().unwrap()); + assert!(!Value::bool(false).as_bool().unwrap()); + Ok(()) + }) + .unwrap(); } #[test] fn float_roundtrips() { - let _rt = Runtime::new().unwrap(); - assert_eq!(Value::f64(3.5).as_f64().unwrap(), 3.5); - assert_eq!(Value::f32(1.25).as_f32().unwrap(), 1.25); - assert!(Value::f64(f64::NAN).is_atom_null()); + Runtime::scope(|_rt| { + assert_eq!(Value::f64(3.5).as_f64().unwrap(), 3.5); + assert_eq!(Value::f32(1.25).as_f32().unwrap(), 1.25); + assert!(Value::f64(f64::NAN).is_atom_null()); + Ok(()) + }) + .unwrap(); } #[test] fn symbol_and_string() { - let _rt = Runtime::new().unwrap(); - assert_eq!(Value::sym("hello").as_sym().unwrap(), "hello"); - assert_eq!( - Value::string("a longer string value").as_string().unwrap(), - "a longer string value" - ); - // short (SSO) and empty - assert_eq!(Value::string("hi").as_string().unwrap(), "hi"); - assert_eq!(Value::string("").as_string().unwrap(), ""); - assert!(Value::string("").is_atom_null()); + Runtime::scope(|_rt| { + assert_eq!(Value::sym("hello").as_sym().unwrap(), "hello"); + assert_eq!( + Value::string("a longer string value").as_string().unwrap(), + "a longer string value" + ); + // short (SSO) and empty + assert_eq!(Value::string("hi").as_string().unwrap(), "hi"); + assert_eq!(Value::string("").as_string().unwrap(), ""); + assert!(Value::string("").is_atom_null()); + Ok(()) + }) + .unwrap(); } #[test] fn guid_roundtrip() { - let _rt = Runtime::new().unwrap(); - let bytes = [ - 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, 0x32, - 0x10, - ]; - assert_eq!(Value::guid(&bytes).as_guid().unwrap(), bytes); - assert!(Value::guid(&[0u8; 16]).is_atom_null()); + Runtime::scope(|_rt| { + let bytes = [ + 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, + 0x32, 0x10, + ]; + assert_eq!(Value::guid(&bytes).as_guid().unwrap(), bytes); + assert!(Value::guid(&[0u8; 16]).is_atom_null()); + Ok(()) + }) + .unwrap(); } #[test] fn typed_nulls() { - let _rt = Runtime::new().unwrap(); - assert!(Value::i64(i64::MIN).is_atom_null()); - assert!(Value::i32(i32::MIN).is_atom_null()); - assert!(Value::i16(i16::MIN).is_atom_null()); - assert!(!Value::i64(0).is_atom_null()); + Runtime::scope(|_rt| { + assert!(Value::i64(i64::MIN).is_atom_null()); + assert!(Value::i32(i32::MIN).is_atom_null()); + assert!(Value::i16(i16::MIN).is_atom_null()); + assert!(!Value::i64(0).is_atom_null()); + Ok(()) + }) + .unwrap(); } #[test] fn temporal_raw_roundtrips() { - let _rt = Runtime::new().unwrap(); - assert_eq!(Value::date_days(9000).as_date_days().unwrap(), 9000); - assert_eq!( - Value::time_millis(3_600_000).as_time_millis().unwrap(), - 3_600_000 - ); - assert_eq!( - Value::timestamp_nanos(1_000_000_000) - .as_timestamp_nanos() - .unwrap(), - 1_000_000_000 - ); + Runtime::scope(|_rt| { + assert_eq!(Value::date_days(9000).as_date_days().unwrap(), 9000); + assert_eq!( + Value::time_millis(3_600_000).as_time_millis().unwrap(), + 3_600_000 + ); + assert_eq!( + Value::timestamp_nanos(1_000_000_000) + .as_timestamp_nanos() + .unwrap(), + 1_000_000_000 + ); + Ok(()) + }) + .unwrap(); } #[test] fn type_mismatch_errors() { - let _rt = Runtime::new().unwrap(); - assert!(Value::i64(5).as_f64().is_err()); - assert!(Value::sym("x").as_i64().is_err()); + Runtime::scope(|_rt| { + assert!(Value::i64(5).as_f64().is_err()); + assert!(Value::sym("x").as_i64().is_err()); + Ok(()) + }) + .unwrap(); } #[test] fn to_from_value_traits() { - let _rt = Runtime::new().unwrap(); - assert_eq!(42i64.to_value().extract::().unwrap(), 42); - assert!(true.to_value().extract::().unwrap()); - // bare &str -> symbol; Str(..) -> string atom - assert_eq!("sym".to_value().as_sym().unwrap(), "sym"); - assert_eq!(Str("str").to_value().as_string().unwrap(), "str"); - // Option null handling - let none: Option = None; - assert!(none.to_value().is_null()); - assert_eq!(Value::i64(7).extract::>().unwrap(), Some(7)); - assert_eq!(Value::i64(i64::MIN).extract::>().unwrap(), None); - // Guid wrapper - let g = Guid([7u8; 16]); - assert_eq!(g.to_value().extract::().unwrap(), g); + Runtime::scope(|_rt| { + assert_eq!(42i64.to_value().extract::().unwrap(), 42); + assert!(true.to_value().extract::().unwrap()); + // bare &str -> symbol; Str(..) -> string atom + assert_eq!("sym".to_value().as_sym().unwrap(), "sym"); + assert_eq!(Str("str").to_value().as_string().unwrap(), "str"); + // Option null handling + let none: Option = None; + assert!(none.to_value().is_null()); + assert_eq!(Value::i64(7).extract::>().unwrap(), Some(7)); + assert_eq!(Value::i64(i64::MIN).extract::>().unwrap(), None); + // Guid wrapper + let g = Guid([7u8; 16]); + assert_eq!(g.to_value().extract::().unwrap(), g); + Ok(()) + }) + .unwrap(); } #[test] fn matches_engine_evaluation() { - let _rt = Runtime::new().unwrap(); - // Our constructed atoms format identically to engine-produced ones. - assert_eq!(Value::i64(2).format(), eval("(+ 1 1)").unwrap().format()); - assert_eq!( - Value::f64(3.0).format(), - eval("(* 1.5 2.0)").unwrap().format() - ); + Runtime::scope(|_rt| { + // Our constructed atoms format identically to engine-produced ones. + assert_eq!(Value::i64(2).format(), eval("(+ 1 1)").unwrap().format()); + assert_eq!( + Value::f64(3.0).format(), + eval("(* 1.5 2.0)").unwrap().format() + ); + Ok(()) + }) + .unwrap(); } #[cfg(feature = "chrono")] #[test] fn chrono_roundtrips() { use chrono::{NaiveDate, NaiveTime, TimeZone, Utc}; - let _rt = Runtime::new().unwrap(); + Runtime::scope(|_rt| { + let d = NaiveDate::from_ymd_opt(2021, 6, 15).unwrap(); + assert_eq!(d.to_value().extract::().unwrap(), d); - let d = NaiveDate::from_ymd_opt(2021, 6, 15).unwrap(); - assert_eq!(d.to_value().extract::().unwrap(), d); + let t = NaiveTime::from_hms_milli_opt(13, 30, 45, 250).unwrap(); + assert_eq!(t.to_value().extract::().unwrap(), t); - let t = NaiveTime::from_hms_milli_opt(13, 30, 45, 250).unwrap(); - assert_eq!(t.to_value().extract::().unwrap(), t); - - let ts = Utc.with_ymd_and_hms(2021, 6, 15, 13, 30, 45).unwrap(); - assert_eq!( - ts.to_value().extract::>().unwrap(), - ts - ); + let ts = Utc.with_ymd_and_hms(2021, 6, 15, 13, 30, 45).unwrap(); + assert_eq!( + ts.to_value().extract::>().unwrap(), + ts + ); + Ok(()) + }) + .unwrap(); } diff --git a/rayforce/tests/serde.rs b/rayforce/tests/serde.rs index 8322a94..6074007 100644 --- a/rayforce/tests/serde.rs +++ b/rayforce/tests/serde.rs @@ -10,96 +10,117 @@ fn roundtrip(v: &Value) -> Value { #[test] fn scalar_roundtrips() { - let _rt = Runtime::new().unwrap(); - assert_eq!( - roundtrip(&Value::i64(123456789)).as_i64().unwrap(), - 123456789 - ); - assert_eq!(roundtrip(&Value::f64(123.456)).as_f64().unwrap(), 123.456); - assert_eq!(roundtrip(&Value::sym("hello")).as_sym().unwrap(), "hello"); - assert_eq!( - roundtrip(&Value::string("a string value")) - .as_string() - .unwrap(), - "a string value" - ); - assert!(roundtrip(&Value::bool(true)).as_bool().unwrap()); + Runtime::scope(|_rt| { + assert_eq!( + roundtrip(&Value::i64(123456789)).as_i64().unwrap(), + 123456789 + ); + assert_eq!(roundtrip(&Value::f64(123.456)).as_f64().unwrap(), 123.456); + assert_eq!(roundtrip(&Value::sym("hello")).as_sym().unwrap(), "hello"); + assert_eq!( + roundtrip(&Value::string("a string value")) + .as_string() + .unwrap(), + "a string value" + ); + assert!(roundtrip(&Value::bool(true)).as_bool().unwrap()); + Ok(()) + }) + .unwrap(); } #[test] fn vector_roundtrips() { - let _rt = Runtime::new().unwrap(); - let v = Value::vec(&[1i64, 2, 3, 4, 5]); - assert_eq!(roundtrip(&v).as_slice::().unwrap(), &[1, 2, 3, 4, 5]); + Runtime::scope(|_rt| { + let v = Value::vec(&[1i64, 2, 3, 4, 5]); + assert_eq!(roundtrip(&v).as_slice::().unwrap(), &[1, 2, 3, 4, 5]); - let f = Value::vec(&[1.5f64, 2.5, 3.5]); - assert_eq!(roundtrip(&f).as_slice::().unwrap(), &[1.5, 2.5, 3.5]); + let f = Value::vec(&[1.5f64, 2.5, 3.5]); + assert_eq!(roundtrip(&f).as_slice::().unwrap(), &[1.5, 2.5, 3.5]); - let syms = Value::sym_vec(&["a", "bb", "ccc"]); - let back = roundtrip(&syms); - assert_eq!(back.len(), 3); - assert_eq!(back.get(1).unwrap().as_sym().unwrap(), "bb"); + let syms = Value::sym_vec(&["a", "bb", "ccc"]); + let back = roundtrip(&syms); + assert_eq!(back.len(), 3); + assert_eq!(back.get(1).unwrap().as_sym().unwrap(), "bb"); + Ok(()) + }) + .unwrap(); } #[test] fn list_roundtrip() { - let _rt = Runtime::new().unwrap(); - let l = Value::list(&[Value::i64(1), Value::sym("two"), Value::f64(3.0)]); - let back = roundtrip(&l); - assert_eq!(back.len(), 3); - assert_eq!(back.get(0).unwrap().as_i64().unwrap(), 1); - assert_eq!(back.get(1).unwrap().as_sym().unwrap(), "two"); + Runtime::scope(|_rt| { + let l = Value::list(&[Value::i64(1), Value::sym("two"), Value::f64(3.0)]); + let back = roundtrip(&l); + assert_eq!(back.len(), 3); + assert_eq!(back.get(0).unwrap().as_i64().unwrap(), 1); + assert_eq!(back.get(1).unwrap().as_sym().unwrap(), "two"); + Ok(()) + }) + .unwrap(); } #[test] fn dict_roundtrip() { - let _rt = Runtime::new().unwrap(); - let d = Value::dict(Value::sym_vec(&["x", "y"]), Value::vec(&[10i64, 20])); - let back = roundtrip(&d); - assert!(back.is_dict()); - assert_eq!(back.dict_len().unwrap(), 2); - assert_eq!( - back.dict_get(&Value::sym("y")) - .unwrap() - .unwrap() - .as_i64() - .unwrap(), - 20 - ); + Runtime::scope(|_rt| { + let d = Value::dict(Value::sym_vec(&["x", "y"]), Value::vec(&[10i64, 20])); + let back = roundtrip(&d); + assert!(back.is_dict()); + assert_eq!(back.dict_len().unwrap(), 2); + assert_eq!( + back.dict_get(&Value::sym("y")) + .unwrap() + .unwrap() + .as_i64() + .unwrap(), + 20 + ); + Ok(()) + }) + .unwrap(); } #[test] fn table_roundtrip() { - let _rt = Runtime::new().unwrap(); - let t = Table::new( - &["sym", "px"], - &[ - Value::sym_vec(&["AAPL", "MSFT"]), - Value::vec(&[100.0f64, 200.0]), - ], - ) + Runtime::scope(|_rt| { + let t = Table::new( + &["sym", "px"], + &[ + Value::sym_vec(&["AAPL", "MSFT"]), + Value::vec(&[100.0f64, 200.0]), + ], + ) + .unwrap(); + let back = roundtrip(t.as_value()); + assert!(back.is_table()); + let bt = back.as_table().unwrap(); + assert_eq!(bt.shape(), (2, 2)); + assert_eq!( + bt.column("px").unwrap().as_slice::().unwrap(), + &[100.0, 200.0] + ); + Ok(()) + }) .unwrap(); - let back = roundtrip(t.as_value()); - assert!(back.is_table()); - let bt = back.as_table().unwrap(); - assert_eq!(bt.shape(), (2, 2)); - assert_eq!( - bt.column("px").unwrap().as_slice::().unwrap(), - &[100.0, 200.0] - ); } #[test] fn roundtrip_preserves_formatting() { - let _rt = Runtime::new().unwrap(); - let v = Value::vec(&[7i64, 8, 9]); - assert_eq!(v.format(), roundtrip(&v).format()); + Runtime::scope(|_rt| { + let v = Value::vec(&[7i64, 8, 9]); + assert_eq!(v.format(), roundtrip(&v).format()); + Ok(()) + }) + .unwrap(); } #[test] fn deserialize_garbage_errors() { - let _rt = Runtime::new().unwrap(); - // Random bytes are not a valid wire payload. - let bad = [0u8, 1, 2, 3, 4, 5, 6, 7]; - assert!(Value::deserialize(&bad).is_err()); + Runtime::scope(|_rt| { + // Random bytes are not a valid wire payload. + let bad = [0u8, 1, 2, 3, 4, 5, 6, 7]; + assert!(Value::deserialize(&bad).is_err()); + Ok(()) + }) + .unwrap(); } diff --git a/rayforce/tests/soundness.rs b/rayforce/tests/soundness.rs new file mode 100644 index 0000000..fe4e5b9 --- /dev/null +++ b/rayforce/tests/soundness.rs @@ -0,0 +1,132 @@ +//! A `Value` cannot outlive the runtime it was built in. +//! +//! Tearing the runtime down unmaps the engine heap: `ray_runtime_destroy` +//! munmaps every pool without consulting any object's reference count. A handle +//! that survived that pointed at unmapped address space, and its `Drop` wrote a +//! refcount into it — a segfault reachable from safe code, usually surfacing at +//! process exit far from its cause. +//! +//! `Runtime::scope` removes the shape rather than tracking it: the closure gets +//! a `&Runtime` it cannot drop, and the `Send` bounds reject a `Value` leaving +//! by return or by capture. Those rejections are `compile_fail` doctests on +//! `Runtime::scope` itself. What is left to check here is that the scope really +//! does tear down, on every path out. + +use rayforce::{Runtime, TcpClient, Value}; + +#[test] +fn values_built_in_a_scope_are_dropped_with_it() { + Runtime::scope(|_rt| { + let vals: Vec = (0..64).map(Value::i64).collect(); + let list = Value::list(&vals); + let vec = Value::vec(&[1i64, 2, 3]); + let cloned = vec.clone(); + assert_eq!(list.len(), 64); + assert_eq!(cloned.as_slice::().unwrap(), &[1, 2, 3]); + Ok(()) + }) + .unwrap(); +} + +#[test] +fn the_error_path_still_tears_down() { + let r = Runtime::scope(|rt| Err::<(), _>(rt.eval("(undefined_name_xyz)").unwrap_err())); + assert!(r.is_err()); + // If the guard had leaked, this would fail with "already live". + assert_eq!(Runtime::scope(|rt| rt.eval("1")?.as_i64()).unwrap(), 1); +} + +#[test] +fn a_panic_in_the_closure_still_tears_down() { + let caught = std::panic::catch_unwind(|| { + Runtime::scope(|_rt| -> rayforce::Result<()> { + panic!("deliberate"); + }) + }); + assert!(caught.is_err(), "the panic must propagate"); + // The guard's `Drop` ran during the unwind, so the next scope can start. + assert_eq!(Runtime::scope(|rt| rt.eval("2")?.as_i64()).unwrap(), 2); +} + +#[test] +fn value_is_one_pointer_wide() { + // No generation tag, and no heap-handle bookkeeping either: the scope bounds + // a value's life, so there is nothing for the value itself to carry. + assert_eq!( + std::mem::size_of::(), + std::mem::size_of::<*mut ()>(), + "Value grew a field — did a liveness tag creep back?" + ); +} + +#[test] +fn a_refused_connection_leaves_the_scope_usable() { + // Nothing is listening on port 1. A refused connection is routine, so it + // must leave nothing behind: the same scope keeps working, and the next one + // starts. The failing path returns before a `TcpClient` exists, so its + // `Drop` — which calls `ray_ipc_close` — must not run. + Runtime::scope(|rt| { + assert!(TcpClient::connect("127.0.0.1", 1, "", "").is_err()); + assert_eq!(rt.eval("(+ 1 1)")?.as_i64()?, 2); + Ok(()) + }) + .unwrap(); + Runtime::scope(|rt| rt.eval("1")?.as_i64()).unwrap(); +} + +// The guard the three tests below exercise is a *thread* property, not a +// process one: the engine's VM and heap are both thread-local (`__VM` in the +// core's src/core/runtime.c, `ray_tl_heap` in src/mem/heap.c), while creating a +// runtime is what must stay unique process-wide. + +#[test] +fn eval_off_the_runtime_thread_is_refused() { + Runtime::scope(|rt| { + // Control: the same call on the runtime's own thread works. + assert_eq!(rt.eval("(+ 1 1)")?.as_i64()?, 2); + // The value never crosses the join — `Value` is `!Send`, so returning + // one would not compile. What is under test is the call, not the result. + let off = std::thread::spawn(|| rayforce::eval("(+ 1 1)").map(|v| v.as_i64())).join(); + assert!( + off.is_err(), + "eval off the runtime's thread must be refused, not dispatched" + ); + Ok(()) + }) + .unwrap(); +} + +#[test] +fn construction_off_the_runtime_thread_is_refused() { + Runtime::scope(|_rt| { + // Control: on the runtime's thread the symbol interns and reads back. + assert_eq!(Value::sym("hello").as_sym()?, "hello"); + // Off-thread this used to succeed, quietly allocating in a per-thread + // heap that no `ray_runtime_destroy` will ever unmap. + let off = std::thread::spawn(|| Value::sym("hello").as_sym()).join(); + assert!( + off.is_err(), + "constructing off the runtime's thread must be refused" + ); + Ok(()) + }) + .unwrap(); +} + +#[test] +fn a_second_thread_cannot_start_a_runtime() { + // The invariant the thread-local guard must leave alone. `__RUNTIME` in the + // core is an unguarded global, so a second `ray_runtime_create` would + // overwrite the first; the compare-exchange in `Runtime::new` is the only + // thing refusing it, and it stays process-wide for exactly this reason. + Runtime::scope(|rt| { + let nested = std::thread::spawn(|| Runtime::scope(|rt| rt.eval("1")?.as_i64())) + .join() + .expect("the second thread must be refused, not crash"); + assert!(nested.is_err(), "a second runtime must not be creatable"); + // The refusal left the first runtime intact. + assert_eq!(rt.eval("(+ 2 3)")?.as_i64()?, 5); + Ok(()) + }) + .unwrap(); +} diff --git a/rayforce/tests/table.rs b/rayforce/tests/table.rs index 83c8435..d9b92fe 100644 --- a/rayforce/tests/table.rs +++ b/rayforce/tests/table.rs @@ -12,115 +12,139 @@ fn sample_table() -> Table { #[test] fn construct_and_shape() { - let _rt = Runtime::new().unwrap(); - let t = sample_table(); - assert_eq!(t.ncols(), 3); - assert_eq!(t.nrows(), 3); - assert_eq!(t.shape(), (3, 3)); - assert_eq!(t.column_names(), vec!["sym", "price", "size"]); + Runtime::scope(|_rt| { + let t = sample_table(); + assert_eq!(t.ncols(), 3); + assert_eq!(t.nrows(), 3); + assert_eq!(t.shape(), (3, 3)); + assert_eq!(t.column_names(), vec!["sym", "price", "size"]); + Ok(()) + }) + .unwrap(); } #[test] fn column_access() { - let _rt = Runtime::new().unwrap(); - let t = sample_table(); + Runtime::scope(|_rt| { + let t = sample_table(); - let price = t.column("price").unwrap(); - assert_eq!(price.as_slice::().unwrap(), &[101.5, 202.0, 303.25]); + let price = t.column("price").unwrap(); + assert_eq!(price.as_slice::().unwrap(), &[101.5, 202.0, 303.25]); - let size = t.column_at(2).unwrap(); - assert_eq!(size.as_slice::().unwrap(), &[10, 20, 30]); + let size = t.column_at(2).unwrap(); + assert_eq!(size.as_slice::().unwrap(), &[10, 20, 30]); - let sym0 = t.column("sym").unwrap().get(0).unwrap().as_sym().unwrap(); - assert_eq!(sym0, "AAPL"); + let sym0 = t.column("sym").unwrap().get(0).unwrap().as_sym().unwrap(); + assert_eq!(sym0, "AAPL"); - assert!(t.column("nonexistent").is_err()); - assert!(t.column_at(9).is_err()); + assert!(t.column("nonexistent").is_err()); + assert!(t.column_at(9).is_err()); + Ok(()) + }) + .unwrap(); } #[test] fn all_columns() { - let _rt = Runtime::new().unwrap(); - let t = sample_table(); - let cols = t.columns().unwrap(); - assert_eq!(cols.len(), 3); - assert_eq!(cols[1].as_slice::().unwrap(), &[101.5, 202.0, 303.25]); + Runtime::scope(|_rt| { + let t = sample_table(); + let cols = t.columns().unwrap(); + assert_eq!(cols.len(), 3); + assert_eq!(cols[1].as_slice::().unwrap(), &[101.5, 202.0, 303.25]); + Ok(()) + }) + .unwrap(); } #[test] fn value_table_interop() { - let _rt = Runtime::new().unwrap(); - let t = sample_table(); - let v = t.clone().into_value(); - assert!(v.is_table()); - let back = v.as_table().unwrap(); - assert_eq!(back.shape(), (3, 3)); - - // a non-table value cannot be made a Table - assert!(Value::i64(5).as_table().is_err()); - assert!(Table::from_value(Value::i64(5)).is_err()); + Runtime::scope(|_rt| { + let t = sample_table(); + let v = t.clone().into_value(); + assert!(v.is_table()); + let back = v.as_table().unwrap(); + assert_eq!(back.shape(), (3, 3)); + + // a non-table value cannot be made a Table + assert!(Value::i64(5).as_table().is_err()); + assert!(Table::from_value(Value::i64(5)).is_err()); + Ok(()) + }) + .unwrap(); } #[test] fn matches_engine_table() { - let _rt = Runtime::new().unwrap(); - // Build the same table the engine builds via a literal, compare formatting. - let t = sample_table(); - let engine = - eval("(table 'sym (list 'AAPL 'MSFT 'GOOG) 'price 101.5 202.0 303.25 'size 10 20 30)").ok(); - // The exact literal syntax may differ across engine versions; only assert - // our table renders non-empty and has the expected shape-derived header. - let rendered = format!("{t}"); - assert!(rendered.contains("sym") && rendered.contains("price") && rendered.contains("size")); - let _ = engine; // engine literal is advisory; not asserted on + Runtime::scope(|_rt| { + // Build the same table the engine builds via a literal, compare formatting. + let t = sample_table(); + let engine = + eval("(table 'sym (list 'AAPL 'MSFT 'GOOG) 'price 101.5 202.0 303.25 'size 10 20 30)") + .ok(); + // The exact literal syntax may differ across engine versions; only assert + // our table renders non-empty and has the expected shape-derived header. + let rendered = format!("{t}"); + assert!( + rendered.contains("sym") && rendered.contains("price") && rendered.contains("size") + ); + let _ = engine; // engine literal is advisory; not asserted on + Ok(()) + }) + .unwrap(); } #[test] fn csv_roundtrip() { - let _rt = Runtime::new().unwrap(); - let t = sample_table(); - - let dir = std::env::temp_dir(); - let path = dir.join(format!("rayforce_rs_csv_{}.csv", std::process::id())); - let path_str = path.to_str().unwrap(); - - t.write_csv(path_str).unwrap(); - assert!(path.exists()); - - let loaded = Table::read_csv(&["SYMBOL", "F64", "I64"], path_str).unwrap(); - assert_eq!(loaded.ncols(), 3); - assert_eq!(loaded.nrows(), 3); - assert_eq!( - loaded.column_at(1).unwrap().as_slice::().unwrap(), - &[101.5, 202.0, 303.25] - ); - - let _ = std::fs::remove_file(&path); + Runtime::scope(|_rt| { + let t = sample_table(); + + let dir = std::env::temp_dir(); + let path = dir.join(format!("rayforce_rs_csv_{}.csv", std::process::id())); + let path_str = path.to_str().unwrap(); + + t.write_csv(path_str).unwrap(); + assert!(path.exists()); + + let loaded = Table::read_csv(&["SYMBOL", "F64", "I64"], path_str).unwrap(); + assert_eq!(loaded.ncols(), 3); + assert_eq!(loaded.nrows(), 3); + assert_eq!( + loaded.column_at(1).unwrap().as_slice::().unwrap(), + &[101.5, 202.0, 303.25] + ); + + let _ = std::fs::remove_file(&path); + Ok(()) + }) + .unwrap(); } #[test] fn splayed_roundtrip() { - let _rt = Runtime::new().unwrap(); - let t = sample_table(); - - let base = std::env::temp_dir().join(format!("rayforce_rs_splay_{}", std::process::id())); - let _ = std::fs::remove_dir_all(&base); - let dir = base.join("t"); - std::fs::create_dir_all(&dir).unwrap(); - let dir_str = dir.to_str().unwrap(); - // Symbol columns require a symfile; supply an explicit one for both ends. - let sym = base.join("sym"); - let sym_str = sym.to_str().unwrap(); - - t.save_splayed(dir_str, Some(sym_str)).unwrap(); - let loaded = Table::load_splayed(dir_str, Some(sym_str)).unwrap(); - assert_eq!(loaded.shape(), (3, 3)); - assert_eq!( - loaded.column("size").unwrap().as_slice::().unwrap(), - &[10, 20, 30] - ); - - let _ = std::fs::remove_dir_all(&base); + Runtime::scope(|_rt| { + let t = sample_table(); + + let base = std::env::temp_dir().join(format!("rayforce_rs_splay_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let dir = base.join("t"); + std::fs::create_dir_all(&dir).unwrap(); + let dir_str = dir.to_str().unwrap(); + // Symbol columns require a symfile; supply an explicit one for both ends. + let sym = base.join("sym"); + let sym_str = sym.to_str().unwrap(); + + t.save_splayed(dir_str, Some(sym_str)).unwrap(); + let loaded = Table::load_splayed(dir_str, Some(sym_str)).unwrap(); + assert_eq!(loaded.shape(), (3, 3)); + assert_eq!( + loaded.column("size").unwrap().as_slice::().unwrap(), + &[10, 20, 30] + ); + + let _ = std::fs::remove_dir_all(&base); + Ok(()) + }) + .unwrap(); } #[test] @@ -130,34 +154,38 @@ fn splayed_sym_values_roundtrip() { // resolving them against the runtime domain (the pre-fix behaviour) yields // garbage (e.g. "+"). `splayed_roundtrip` above only checks an i64 column, // so it never exercised symbol resolution. - let _rt = Runtime::new().unwrap(); - let t = Table::new( - &["k", "v"], - &[ - Value::sym_vec(&["abcdef123456", "xyz"]), - Value::vec(&[1i64, 2]), - ], - ) + Runtime::scope(|_rt| { + let t = Table::new( + &["k", "v"], + &[ + Value::sym_vec(&["abcdef123456", "xyz"]), + Value::vec(&[1i64, 2]), + ], + ) + .unwrap(); + + let base = + std::env::temp_dir().join(format!("rayforce_rs_splay_sym_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let dir = base.join("t"); + std::fs::create_dir_all(&dir).unwrap(); + let dir_str = dir.to_str().unwrap(); + + // /.sym dotfile convention (no explicit sym path). + t.save_splayed(dir_str, None).unwrap(); + let loaded = Table::load_splayed(dir_str, None).unwrap(); + + assert_eq!(loaded.shape(), (2, 2)); + let k = loaded.column("k").unwrap(); + assert_eq!(k.get(0).unwrap().as_sym().unwrap(), "abcdef123456"); + assert_eq!(k.get(1).unwrap().as_sym().unwrap(), "xyz"); + assert_eq!( + loaded.column("v").unwrap().as_slice::().unwrap(), + &[1, 2] + ); + + let _ = std::fs::remove_dir_all(&base); + Ok(()) + }) .unwrap(); - - let base = std::env::temp_dir().join(format!("rayforce_rs_splay_sym_{}", std::process::id())); - let _ = std::fs::remove_dir_all(&base); - let dir = base.join("t"); - std::fs::create_dir_all(&dir).unwrap(); - let dir_str = dir.to_str().unwrap(); - - // /.sym dotfile convention (no explicit sym path). - t.save_splayed(dir_str, None).unwrap(); - let loaded = Table::load_splayed(dir_str, None).unwrap(); - - assert_eq!(loaded.shape(), (2, 2)); - let k = loaded.column("k").unwrap(); - assert_eq!(k.get(0).unwrap().as_sym().unwrap(), "abcdef123456"); - assert_eq!(k.get(1).unwrap().as_sym().unwrap(), "xyz"); - assert_eq!( - loaded.column("v").unwrap().as_slice::().unwrap(), - &[1, 2] - ); - - let _ = std::fs::remove_dir_all(&base); }