From 83e029fed0e4374aed63859a78e535e54cabb5bb Mon Sep 17 00:00:00 2001 From: Ihor Filimonov <254675014+ihrfv@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:43:13 +0200 Subject: [PATCH 01/16] build(sys): opt into the core's debug flavour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `make lib` archives release objects, so the engine's stale retain/release detector — `ray_dfd_check_live`, guarded by `#ifdef DEBUG` in the core's src/mem/cow.c — has never been compiled into anything this crate links. It is the only tool that can see a use-after-free here: the engine allocates through mmap and never calls malloc, which is exactly what AddressSanitizer, LeakSanitizer and Valgrind track, and Miri cannot execute the C library at all. RAYFORCE_CORE_DEBUG=1 now overrides RELEASE_CFLAGS with the Makefile's DEBUG_CFLAGS minus the sanitizers, reusing make's own $(WARNS)/$(STD)/ $(RAY_MARCH) so only the flavour delta is restated. Arm the detector at runtime with RAY_DFD=1. Both flavours compile to the same object names, so make would call the other flavour's objects up to date and archive a mixed library. The flavour therefore joins RAY_VERSION and GIT_HASH in the string invalidate_on_stamp_change already compares, and a change drops the objects — one mechanism for every flag make does not track, rather than a second stamp file beside it. That check now runs for a RAYFORCE_SRC checkout too, which previously had none: only the vendored copy stamps a version, but either can flip flavour. Co-Authored-By: Claude Opus 5 --- rayforce-sys/build.rs | 53 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/rayforce-sys/build.rs b/rayforce-sys/build.rs index a94a0d7..71970fd 100644 --- a/rayforce-sys/build.rs +++ b/rayforce-sys/build.rs @@ -345,6 +345,11 @@ 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. 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,6 +389,39 @@ 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. @@ -396,14 +434,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") From 4ac4fab52a375156bf3203bcd6262cc23940fb49 Mon Sep 17 00:00:00 2001 From: Ihor Filimonov <254675014+ihrfv@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:43:25 +0200 Subject: [PATCH 02/16] ci: run the suite against a debug-flavour core Adds a `core: [release, debug]` matrix axis to the test job. The debug leg sets RAYFORCE_CORE_DEBUG=1 and RAY_DFD=1, so the engine's stale retain/release detector is armed for the whole suite; the release leg is what it was. fmt and clippy are flavour-independent and stay on the release leg only. The suite is green at this commit, so making the debug leg a required check does not start it out red. It has teeth all the same: with tests/soundness.rs from later in this branch copied in, it aborts with `DFD: ray_release (stale release of freed block)` and a backtrace through Value::drop. The fixes that follow are the bugs it reports. The check names change from `test` to `test (release)` / `test (debug)`; branch protection needs updating to require both. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21bf4fb..b3ef35f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,16 @@ 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] steps: # The C core and the rayforce-q client are submodules under # rayforce-sys/vendor/, so this one checkout brings the whole build. @@ -45,15 +55,23 @@ 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 - name: Test + env: + # Empty on the release leg. build.rs and the core's own dfd_enabled() + # both treat empty and "0" as off, so the release leg is unaffected. + RAYFORCE_CORE_DEBUG: ${{ matrix.core == 'debug' && '1' || '' }} + RAY_DFD: ${{ matrix.core == 'debug' && '1' || '' }} run: cargo test --workspace # The vendored sources must actually land in the .crate — that is the From 6a9df0d5a7ad4ada2dcd8733f622e215cd2de7db Mon Sep 17 00:00:00 2001 From: Ihor Filimonov <254675014+ihrfv@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:54:42 +0200 Subject: [PATCH 03/16] test(ipc): fail instead of skipping when a server is required MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without a rayforce binary these tests return early, and an early return reports as a pass — so the absence of coverage looks identical to the coverage passing. RAYFORCE_REQUIRE_SERVER=1 turns the skip into a panic naming what to do about it, so a binary path that stops resolving fails the job instead of quietly going green. Co-Authored-By: Claude Opus 5 --- rayforce/tests/ipc.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/rayforce/tests/ipc.rs b/rayforce/tests/ipc.rs index b80502a..839bbed 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; From 3218327351b51d53f2ef8d6d626127f61a7a6a61 Mon Sep 17 00:00:00 2001 From: Ihor Filimonov <254675014+ihrfv@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:54:42 +0200 Subject: [PATCH 04/16] ci: build the core binary so the IPC tests actually run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/ipc.rs drives TcpClient against a spawned server and was skipping in CI for want of a binary — silently, since a skip reports as a pass. The poll-teardown fix later in this branch lands on exactly that path, so without this it would ship with no executed coverage at all. The build step runs after cargo, so build.rs has already staged the vendored core into OUT_DIR and compiled its objects there at this leg's flavour; `make release` is pointed at that same staged tree and reuses them untouched, adding only main.c plus the link. A quarter second, not a second engine build — which is why the step globs for the staged tree rather than building from rayforce-sys/vendor/rayforce, and fails loudly if it cannot find one instead of quietly starting that second build. main.c compiles at the Makefile's default RELEASE_CFLAGS on both legs. In the public header -DDEBUG reaches only the RAY_ASSERT_VALUE macro (rayforce.h:216) and no struct, so it cannot disagree with the engine objects about layout; DFD lives in src/mem/{cow,heap}.c, which are engine objects, so the debug leg's server does check its own side. RAYFORCE_BINARY is absolute. cargo runs each test binary with the cwd set to its own crate root, not the workspace root, so the workspace-relative path the glob produces does not resolve from inside rayforce/ — and the failure is a panic in every IPC test rather than a skip, which is the point of the commit before this one. WARNS, RAY_VERSION and GIT_HASH are read back out of build.rs the way scripts/check-vendored-pin.sh already reads them, rather than restated where they could drift: main.c must not reintroduce the -Werror the vendored build drops on purpose, and `git` resolving a version from inside OUT_DIR searches upward, so leaving it unset stamps this repository's HEAD into the server binary. Unset it reported itself as 0.0.0 (69aab1e); it now reports 2.5.8 (f0d4bb4), the pinned core. RAYFORCE_CORE_DEBUG and RAY_DFD move to job scope so the binary is built at the same flavour the tests run against — step scope would have let build.rs see a flavour flip at `cargo test` time and invalidate the objects the binary was linked from. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 48 +++++++++++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3ef35f..16044f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,13 @@ jobs: # 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. @@ -66,12 +73,43 @@ jobs: 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" + - name: Test - env: - # Empty on the release leg. build.rs and the core's own dfd_enabled() - # both treat empty and "0" as off, so the release leg is unaffected. - RAYFORCE_CORE_DEBUG: ${{ matrix.core == 'debug' && '1' || '' }} - RAY_DFD: ${{ matrix.core == 'debug' && '1' || '' }} run: cargo test --workspace # The vendored sources must actually land in the .crate — that is the From e8f6f0fc0c8b4c091258d32d0ce5b6a0eda6df28 Mon Sep 17 00:00:00 2001 From: Ihor Filimonov <254675014+ihrfv@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:11:00 +0200 Subject: [PATCH 05/16] fix: the runtime tears down the event loop it owns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ray_runtime_destroy does not touch the poll, so a process that opened a TcpClient leaked it: connect installs a poll on first use and nothing ever destroys it. The runtime now takes it down in Drop, before the heap goes — closing a selector releases engine objects held for it, so the order is not incidental. Making the loop runtime-scoped is also what lets a handle on it be a plain borrow rather than something with an ownership claim to get wrong. Found by reading, not by the debug leg: this is a leak, and the engine allocates through mmap, which no leak checker tracks — not DFD, not LeakSanitizer, not Valgrind. The CI change earlier in this branch at least makes tests/ipc.rs execute the path, so a future regression that corrupts rather than leaks would be caught. Co-Authored-By: Claude Opus 5 --- rayforce/src/runtime.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/rayforce/src/runtime.rs b/rayforce/src/runtime.rs index 047d121..2d538ae 100644 --- a/rayforce/src/runtime.rs +++ b/rayforce/src/runtime.rs @@ -95,7 +95,18 @@ pub fn get_global(name: &str) -> Result { impl Drop for Runtime { fn drop(&mut self) { - unsafe { sys::ray_runtime_destroy(self.rt) }; + 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(std::ptr::null_mut()); + sys::ray_poll_destroy(poll.cast()); + } + sys::ray_runtime_destroy(self.rt); + } LIVE.store(false, Ordering::SeqCst); } } From 211abaa5aa424df94d01b6c101efbb48bf8417ed Mon Sep 17 00:00:00 2001 From: Ihor Filimonov <254675014+ihrfv@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:59:43 +0200 Subject: [PATCH 06/16] fix(q): QConnection belongs to the runtime thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was a bare { fd: i32 }, so it was Send and Sync by inference while every other handle in the crate — Value, Runtime, TcpClient — is neither. execute interns symbols and builds engine objects, both of which are the owning thread's, and Sync additionally let two threads interleave writes on one socket. No runtime detector can regress-test this, the debug leg included: it is a compile-time property, so it needs a compile_fail doctest. Paired with a control that is identical but for the Send bound, because a compile_fail block passes whenever the snippet fails to build for any reason — a renamed type would otherwise read as a pass. Co-Authored-By: Claude Opus 5 --- rayforce/src/q.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/rayforce/src/q.rs b/rayforce/src/q.rs index 0809749..154597c 100644 --- a/rayforce/src/q.rs +++ b/rayforce/src/q.rs @@ -13,6 +13,7 @@ //! ``` use std::ffi::CString; +use std::marker::PhantomData; use rayforce_sys as sys; @@ -20,8 +21,27 @@ use crate::error::{check, RayError, Result}; use crate::value::Value; /// An open connection to a Q server. Closed on drop. +/// +/// `!Send`/`!Sync`: `execute` interns symbols and builds engine objects, both +/// of which belong to the thread that owns the [`crate::Runtime`]. Moving one +/// to another thread must not compile: +/// +/// ```compile_fail +/// fn assert_send() {} +/// assert_send::(); +/// ``` +/// +/// The control for that test — identical but for the `Send` bound. A +/// `compile_fail` block passes whenever the code fails to build *for any +/// reason*, so without this a renamed type or a typo would read as a pass: +/// +/// ``` +/// fn assert_exists() {} +/// assert_exists::(); +/// ``` pub struct QConnection { fd: i32, + _not_send: PhantomData<*mut ()>, } impl QConnection { @@ -63,7 +83,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 From fe4730407f8fc92bbec7dbf792e67d100d68eaf3 Mon Sep 17 00:00:00 2001 From: Ihor Filimonov <254675014+ihrfv@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:11:46 +0200 Subject: [PATCH 07/16] fix: warning-free build with --no-default-features RayError is only constructed by the chrono-gated conversions, so building without the chrono feature left its import unused. No CI job builds that configuration, so it went unnoticed until a consumer opted out. Co-Authored-By: Claude Opus 5 --- rayforce/src/convert.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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. From 3dce7b76ede90a762578359d56e5548b23698ae0 Mon Sep 17 00:00:00 2001 From: Ihor Filimonov <254675014+ihrfv@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:45:18 +0200 Subject: [PATCH 08/16] fix: a Value cannot outlive its Runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropping the runtime unmaps the engine heap, so a handle still alive afterwards was releasing into memory that is no longer mapped — a segfault reachable from entirely safe code, and one that surfaced at process exit far from its cause. The debug leg added earlier in this branch reproduces it as `DFD: ray_release (stale release of freed block)`. Nothing checked at the point of use would have helped. `ray_t.rc` counts references to an *object*; `ray_runtime_destroy` munmaps every pool without consulting it (core `heap.c`, `ray_heap_destroy`), so an object with `rc == 5` is unmapped exactly like one with `rc == 1`. By the time a stale handle is used the thing to check is the pointer, and the pointer is what is invalid. Only never producing such a handle helps. So bound the runtime's life rather than track it. `Runtime::new` is private and `Runtime::scope` is the only way in: it creates the runtime, hands the closure a `&Runtime` — which cannot be dropped or moved out of — and tears it down on the way out, on the error path and on unwind alike. Values built inside are dropped before the heap they point into goes away, because the closure's locals go first. What keeps them inside is the `!Send` marker the crate already carried for its own reasons. `R: Send` on the return type rejects a `Value`, and transitively `Option`, `Vec`, `Box`; it covers references too, since `&T: Send` needs `T: Sync`. `F: Send` on the closure rejects assigning one into a variable declared outside, since a closure is `Send` only if every capture is. Both are `compile_fail` doctests, each paired with a control that must compile — and both reject with `required by a bound in Runtime::scope`, not by accident. 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. That is documented on `Runtime::scope`. Every test, the example and the benchmarks move inside a scope. The benchmarks get a hand-rolled `main`, since Criterion's has no closure to wrap; it replaces the runtime they used to leak for the process lifetime. Co-Authored-By: Claude Opus 5 --- README.md | 46 ++--- rayforce/benches/benchmarks.rs | 32 ++-- rayforce/examples/lambda_demo.rs | 101 +++++----- rayforce/src/lambda.rs | 59 +++--- rayforce/src/lib.rs | 12 +- rayforce/src/q.rs | 9 +- rayforce/src/runtime.rs | 187 ++++++++++++++---- rayforce/src/value.rs | 83 ++++++-- rayforce/tests/containers.rs | 316 +++++++++++++++++-------------- rayforce/tests/expr.rs | 157 ++++++++------- rayforce/tests/ipc.rs | 54 ++++-- rayforce/tests/lambda.rs | 157 ++++++++------- rayforce/tests/q.rs | 54 +++--- rayforce/tests/q_real.rs | 61 +++--- rayforce/tests/query.rs | 268 ++++++++++++++------------ rayforce/tests/runtime.rs | 68 ++++--- rayforce/tests/scalars.rs | 188 ++++++++++-------- rayforce/tests/serde.rs | 151 ++++++++------- rayforce/tests/soundness.rs | 60 ++++++ rayforce/tests/table.rs | 244 +++++++++++++----------- 20 files changed, 1393 insertions(+), 914 deletions(-) create mode 100644 rayforce/tests/soundness.rs diff --git a/README.md b/README.md index 966a120..9f4d150 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 diff --git a/rayforce/benches/benchmarks.rs b/rayforce/benches/benchmarks.rs index 27cbd14..cce4137 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,7 +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"]; @@ -131,7 +117,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 +142,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..ce7a67f 100644 --- a/rayforce/examples/lambda_demo.rs +++ b/rayforce/examples/lambda_demo.rs @@ -5,55 +5,58 @@ 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/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..d02ccaf 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; diff --git a/rayforce/src/q.rs b/rayforce/src/q.rs index 154597c..3728f5e 100644 --- a/rayforce/src/q.rs +++ b/rayforce/src/q.rs @@ -7,9 +7,12 @@ //! //! ```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; diff --git a/rayforce/src/runtime.rs b/rayforce/src/runtime.rs index 2d538ae..79ff207 100644 --- a/rayforce/src/runtime.rs +++ b/rayforce/src/runtime.rs @@ -1,40 +1,144 @@ //! 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. use crate::error::{check, materialize, RayError, Result}; use crate::value::Value; use rayforce_sys as sys; use std::ffi::CString; use std::marker::PhantomData; +use std::ptr; use std::sync::atomic::{AtomicBool, Ordering}; +/// Is a runtime live in this process? The core permits exactly one. static LIVE: AtomicBool = AtomicBool::new(false); -/// An owned, live RayforceDB runtime. Only one may exist per process at a time. +/// 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. + /// + /// # The one escape these bounds miss + /// + /// 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", + "a rayforce runtime is already live in this process — \ + Runtime::scope cannot be nested", )); } - 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")); @@ -62,9 +166,45 @@ impl 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); + } + LIVE.store(false, Ordering::SeqCst); + } +} + +/// Is a runtime currently live in this process? +/// +/// True only inside a [`Runtime::scope`] body. +pub fn is_live() -> bool { + LIVE.load(Ordering::SeqCst) +} + +/// Panic unless a runtime is live. +/// +/// The engine has no guard of its own: `ray_eval` and the atom constructors +/// dereference a thread-local VM that is null with no live runtime. This is an +/// unconditional assertion, not a `debug_assert` — the release build has exactly +/// the same hole. +#[inline] +pub(crate) fn assert_live(what: &str) { + assert!(is_live(), "rayforce: {what} requires a live Runtime"); +} + /// Bind `value` to a global name. Requires a live [`Runtime`]. pub fn set_global(name: &str, value: &Value) -> Result<()> { - debug_assert!(is_live(), "set_global called without a live Runtime"); + assert_live("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()); @@ -79,7 +219,7 @@ pub fn set_global(name: &str, value: &Value) -> Result<()> { /// Look up a global binding. Requires a live [`Runtime`]. pub fn get_global(name: &str) -> Result { - debug_assert!(is_live(), "get_global called without a live Runtime"); + assert_live("get_global"); unsafe { let id = sys::ray_sym_intern(name.as_ptr() as *const _, name.len()); let v = sys::ray_env_get(id); @@ -93,34 +233,11 @@ pub fn get_global(name: &str) -> Result { } } -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(std::ptr::null_mut()); - sys::ray_poll_destroy(poll.cast()); - } - 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`]. /// /// 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_live("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()); @@ -134,7 +251,7 @@ pub fn eval(source: &str) -> Result { /// Evaluate an already-compiled AST [`Value`] (e.g. a query). Requires a live /// [`Runtime`]. pub fn eval_value(obj: &Value) -> Result { - debug_assert!(is_live(), "eval_value called without a live Runtime"); + assert_live("eval_value"); unsafe { let r = sys::ray_eval(obj.as_ptr()); if r.is_null() { 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/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 839bbed..7a67512 100644 --- a/rayforce/tests/ipc.rs +++ b/rayforce/tests/ipc.rs @@ -91,42 +91,54 @@ 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. - let port = free_port(); - assert!(TcpClient::connect("127.0.0.1", port, "", "").is_err()); + Runtime::scope(|_rt| { + // Nothing listening on this port. + let port = free_port(); + assert!(TcpClient::connect("127.0.0.1", port, "", "").is_err()); + Ok(()) + }) + .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/q.rs b/rayforce/tests/q.rs index dc3794f..ae7a2c0 100644 --- a/rayforce/tests/q.rs +++ b/rayforce/tests/q.rs @@ -77,36 +77,42 @@ 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..6075aaa 100644 --- a/rayforce/tests/q_real.rs +++ b/rayforce/tests/q_real.rs @@ -21,33 +21,36 @@ 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..4ed45c4 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(); +fn recreate_runtime_after_scope() { + assert!(!rayforce::is_live()); + Runtime::scope(|_rt| { assert!(rayforce::is_live()); assert_eq!(eval("(* 6 7)").unwrap().format(), "42"); - } + Ok(()) + }) + .unwrap(); assert!(!rayforce::is_live()); // 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..3b607e6 100644 --- a/rayforce/tests/scalars.rs +++ b/rayforce/tests/scalars.rs @@ -4,124 +4,154 @@ 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..27dc6b5 --- /dev/null +++ b/rayforce/tests/soundness.rs @@ -0,0 +1,60 @@ +//! 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, 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?" + ); +} diff --git a/rayforce/tests/table.rs b/rayforce/tests/table.rs index 83c8435..246a6c6 100644 --- a/rayforce/tests/table.rs +++ b/rayforce/tests/table.rs @@ -12,115 +12,136 @@ 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 +151,37 @@ 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); } From d75ffb842c7a90fb451bd0293aca6ccdeb3dc367 Mon Sep 17 00:00:00 2001 From: Ihor Filimonov <254675014+ihrfv@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:45:19 +0200 Subject: [PATCH 09/16] fix: the connection types are confined to their scope too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TcpClient` and `QConnection` had no liveness tracking of any kind, so nothing covered them: their `Drop`s call `ray_ipc_close` and `q_close`, both of which reach into the runtime, and a client outliving its `Runtime` made those calls against an unmapped heap. `Runtime::scope` orders that for them — but only if they cannot leave it, and only `!Send` says so. `QConnection` had the `Send` half of its marker pair; `TcpClient` had the markers but no `compile_fail` tests for them, and neither had the `Sync` half. Both now carry the full pair with a positive control, since those markers are exactly what the scope's `R: Send` and `F: Send` bounds read. The ordering itself falls out of the closure: its locals are dropped before it returns, and the runtime only after. `a_client_is_closed_before_its_scope_ends` leaves the drop implicit on purpose — an explicit `drop(client)` would prove nothing about the ordering. `connect` now asserts a live runtime rather than trusting the caller. A refused connection returns before a `TcpClient` exists, so its `Drop` never runs and nothing is left behind; `a_refused_connection_leaves_the_scope_usable` pins that. Co-Authored-By: Claude Opus 5 --- rayforce/src/ipc.rs | 30 ++++++++++++++++++++++++++++++ rayforce/src/q.rs | 29 +++++++++++++++++++++-------- rayforce/tests/ipc.rs | 22 ++++++++++++++++++++++ rayforce/tests/soundness.rs | 17 ++++++++++++++++- 4 files changed, 89 insertions(+), 9 deletions(-) diff --git a/rayforce/src/ipc.rs b/rayforce/src/ipc.rs index 4f9dd7e..737dcd7 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_live; 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_live("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/q.rs b/rayforce/src/q.rs index 3728f5e..a37c051 100644 --- a/rayforce/src/q.rs +++ b/rayforce/src/q.rs @@ -21,23 +21,31 @@ use std::marker::PhantomData; use rayforce_sys as sys; use crate::error::{check, RayError, Result}; +use crate::runtime::assert_live; use crate::value::Value; /// An open connection to a Q server. Closed on drop. /// -/// `!Send`/`!Sync`: `execute` interns symbols and builds engine objects, both -/// of which belong to the thread that owns the [`crate::Runtime`]. Moving one -/// to another thread must not compile: +/// 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::(); /// ``` -/// -/// The control for that test — identical but for the `Send` bound. A -/// `compile_fail` block passes whenever the code fails to build *for any -/// reason*, so without this a renamed type or a typo would read as a pass: -/// +/// ```compile_fail +/// fn assert_sync() {} +/// assert_sync::(); +/// ``` +/// Control — `compile_fail` passes on *any* build failure, a rename included: /// ``` /// fn assert_exists() {} /// assert_exists::(); @@ -63,6 +71,7 @@ impl QConnection { password: &str, timeout_ms: i32, ) -> Result { + assert_live("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 = @@ -117,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) }; } } @@ -130,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_live("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/tests/ipc.rs b/rayforce/tests/ipc.rs index 7a67512..7124a7c 100644 --- a/rayforce/tests/ipc.rs +++ b/rayforce/tests/ipc.rs @@ -142,3 +142,25 @@ fn connect_failure_is_an_error() { }) .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(); + 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/soundness.rs b/rayforce/tests/soundness.rs index 27dc6b5..c03dde1 100644 --- a/rayforce/tests/soundness.rs +++ b/rayforce/tests/soundness.rs @@ -12,7 +12,7 @@ //! `Runtime::scope` itself. What is left to check here is that the scope really //! does tear down, on every path out. -use rayforce::{Runtime, Value}; +use rayforce::{Runtime, TcpClient, Value}; #[test] fn values_built_in_a_scope_are_dropped_with_it() { @@ -58,3 +58,18 @@ fn value_is_one_pointer_wide() { "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(); +} From 0c5b03a34d89ec0f8c2b1d4e0b2eef5cad8db86e Mon Sep 17 00:00:00 2001 From: Ihor Filimonov <254675014+ihrfv@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:31:09 +0200 Subject: [PATCH 10/16] fix: building a value requires a live runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Value::i64(1)` with no runtime was safe Rust calling straight into the engine, with not even a debug assertion in the way. Nothing crashed, which is why it survived: `ray_alloc` lazily mmaps a thread-local heap when none exists (core `heap.c:1386-1390`), so the value landed in an orphan heap no `Runtime` owned. The sharp case is symbols, which are runtime-scoped. `Value::sym("hello")` with no runtime returned an *empty* symbol — `ray_sym_intern` had no table to intern into, so the string was dropped on the floor with no error anywhere. Verified before adding the guard: it formatted as `'`. Guard every entry point that conjures a value out of nothing — the fifteen atom constructors, the five vector builders, both list builders, `dict`, and the four `Table` loaders. Accessors on an existing handle are left alone: holding one is already proof the heap is mapped. The rule this settles on is one predicate, not two: a live `Runtime` guard is required for everything except reading and dropping handles you already hold. So `eval` and the constructors answer to the same `is_live()`, and a `TcpClient` whose runtime is gone can still be closed but not used. `tests/no_runtime.rs` gets its own binary, since the property is about a process where no runtime was ever created. Co-Authored-By: Claude Opus 5 --- rayforce/src/dict.rs | 2 ++ rayforce/src/list.rs | 3 +++ rayforce/src/scalars.rs | 16 ++++++++++++++++ rayforce/src/table.rs | 5 +++++ rayforce/src/vector.rs | 6 ++++++ rayforce/tests/no_runtime.rs | 27 +++++++++++++++++++++++++++ 6 files changed, 59 insertions(+) create mode 100644 rayforce/tests/no_runtime.rs diff --git a/rayforce/src/dict.rs b/rayforce/src/dict.rs index e9f9e3d..d611775 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_live; 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_live("Value::dict"); unsafe { let d = sys::ray_dict_new(keys.into_raw(), values.into_raw()); match check(d) { diff --git a/rayforce/src/list.rs b/rayforce/src/list.rs index 4b6566c..5a5f0de 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_live; 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_live("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_live("Value::empty_list"); unsafe { match check(sys::ray_list_new(capacity)) { Ok(p) => Value::from_owned(p), diff --git a/rayforce/src/scalars.rs b/rayforce/src/scalars.rs index d3e92a0..e6cfffd 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_live; 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_live("Value::bool"); unsafe { own(sys::ray_bool(v)) } } /// An unsigned byte atom (`-RAY_U8`). pub fn u8(v: u8) -> Value { + assert_live("Value::u8"); unsafe { own(sys::ray_u8(v)) } } /// A 16-bit signed integer atom (`-RAY_I16`). pub fn i16(v: i16) -> Value { + assert_live("Value::i16"); unsafe { own(sys::ray_i16(v)) } } /// A 32-bit signed integer atom (`-RAY_I32`). pub fn i32(v: i32) -> Value { + assert_live("Value::i32"); unsafe { own(sys::ray_i32(v)) } } /// A 64-bit signed integer atom (`-RAY_I64`). pub fn i64(v: i64) -> Value { + assert_live("Value::i64"); unsafe { own(sys::ray_i64(v)) } } /// A 32-bit float atom (`-RAY_F32`). pub fn f32(v: f32) -> Value { + assert_live("Value::f32"); unsafe { own(sys::ray_f32(v)) } } /// A 64-bit float atom (`-RAY_F64`). pub fn f64(v: f64) -> Value { + assert_live("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_live("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_live("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_live("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_live("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_live("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_live("Value::timestamp_nanos"); unsafe { own(sys::ray_timestamp(ns)) } } /// A GUID atom from 16 raw bytes. pub fn guid(bytes: &[u8; 16]) -> Value { + assert_live("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_live("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..73a32c6 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_live; 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_live("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_live("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_live("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_live("Table::load_parted"); let root_v = Value::string(root); let name_v = Value::sym(name); unsafe { diff --git a/rayforce/src/vector.rs b/rayforce/src/vector.rs index e5f9bdd..f04b857 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_live; 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_live("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_live("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_live("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_live("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_live("Value::empty_vec"); unsafe { match check(sys::ray_vec_new(abs_type, capacity)) { Ok(p) => Value::from_owned(p), diff --git a/rayforce/tests/no_runtime.rs b/rayforce/tests/no_runtime.rs new file mode 100644 index 0000000..0691e2c --- /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::is_live()); + 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::is_live()); + let _ = rayforce::Value::sym("hello"); +} From 168ef528f83759cba7e83bd17969a9646884aa49 Mon Sep 17 00:00:00 2001 From: Ihor Filimonov <254675014+ihrfv@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:45:44 +0200 Subject: [PATCH 11/16] docs: the CI tooling, the bugs it unveiled, and the scoped runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changelog entries for everything in this branch: the debug-flavour engine leg and the IPC tests that now actually run, the four fixes, and the two behaviour changes callers will notice — `Runtime::scope` replacing `Runtime::new`, and nothing engine-backed being able to leave a scope. `technical-details.md` claimed the borrow checker was what kept handles honest. It isn't, and the page owed readers the actual mechanism: the refcount they can see counts handles to an object, `ray_runtime_destroy` unmaps the heap without consulting it, and what closes the gap is bounding the runtime's life rather than tracking handles into it. Its "what a dropped `Runtime` still allows" section becomes "what keeps a `Value` inside its scope", since that is now the question. Adds `QConnection` to the `!Send`/`!Sync` list it was missing from. Every snippet across the docs site moves inside a `Runtime::scope` — 66 code blocks in 35 files, since `Runtime::new` is no longer callable. That includes the landing page, `docs/index.md`, which sits outside `content/` and is the first Rust anyone reading the site sees. These are not compiled by CI, so the new hand-written ones in `technical-details.md` were checked by running them as tests before being pasted in. Co-Authored-By: Claude Opus 5 --- docs/docs/content/CHANGELOG.md | 81 +++++++++++++ .../documentation/data-types/boolean.md | 3 +- .../content/documentation/data-types/dict.md | 3 +- .../content/documentation/data-types/float.md | 3 +- .../documentation/data-types/functions.md | 3 +- .../content/documentation/data-types/guid.md | 3 +- .../documentation/data-types/integers.md | 3 +- .../content/documentation/data-types/list.md | 3 +- .../documentation/data-types/overview.md | 3 +- .../documentation/data-types/string.md | 3 +- .../documentation/data-types/symbol.md | 3 +- .../documentation/data-types/temporal.md | 3 +- .../documentation/data-types/values.md | 3 +- .../documentation/data-types/vector.md | 3 +- docs/docs/content/documentation/ipc.md | 73 ++++++------ docs/docs/content/documentation/overview.md | 3 +- .../documentation/query-guide/expressions.md | 3 +- .../documentation/query-guide/group-by.md | 6 +- .../documentation/query-guide/insert.md | 6 +- .../documentation/query-guide/joins.md | 6 +- .../documentation/query-guide/order-by.md | 6 +- .../documentation/query-guide/overview.md | 3 +- .../documentation/query-guide/select.md | 6 +- .../documentation/query-guide/update.md | 6 +- .../documentation/query-guide/upsert.md | 3 +- .../documentation/query-guide/where.md | 6 +- .../content/documentation/serialization.md | 108 ++++++++++-------- .../documentation/table/access-values.md | 87 +++++++------- .../content/documentation/table/create.md | 63 +++++----- .../content/documentation/table/overview.md | 33 +++--- .../documentation/table/save-and-fetch.md | 55 +++++---- .../documentation/table/splayed-and-parted.md | 71 ++++++------ .../content/documentation/table/transform.md | 59 +++++----- docs/docs/content/get-started/overview.md | 80 ++++++------- .../content/get-started/technical-details.md | 85 ++++++++++---- docs/docs/index.md | 39 ++++--- 36 files changed, 570 insertions(+), 357 deletions(-) diff --git a/docs/docs/content/CHANGELOG.md b/docs/docs/content/CHANGELOG.md index b1691cb..47a7eaa 100644 --- a/docs/docs/content/CHANGELOG.md +++ b/docs/docs/content/CHANGELOG.md @@ -3,6 +3,87 @@ 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. + +- **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 + +- **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. + +- **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 the same + `is_live()`, which is true only inside a scope. + +### Fixed + +- **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/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(()) +})?; ``` From b48bd8333ccf5038289d65cc7336d4128fa50da6 Mon Sep 17 00:00:00 2001 From: Ihor Filimonov <254675014+ihrfv@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:08:07 +0200 Subject: [PATCH 12/16] style: rustfmt the tests, bench and example CI's release leg runs `cargo fmt --all -- --check` before clippy and the tests, so these six files failed the whole leg on whitespace alone. Only wrapping and the blank line rustfmt strips after an opening brace changed; no code. Co-Authored-By: Claude Opus 5 --- rayforce/benches/benchmarks.rs | 1 - rayforce/examples/lambda_demo.rs | 1 - rayforce/tests/q.rs | 1 - rayforce/tests/q_real.rs | 3 ++- rayforce/tests/scalars.rs | 5 ++--- rayforce/tests/table.rs | 10 +++++++--- 6 files changed, 11 insertions(+), 10 deletions(-) diff --git a/rayforce/benches/benchmarks.rs b/rayforce/benches/benchmarks.rs index cce4137..2b96db0 100644 --- a/rayforce/benches/benchmarks.rs +++ b/rayforce/benches/benchmarks.rs @@ -76,7 +76,6 @@ fn bench_aggregation(c: &mut Criterion) { } fn bench_query(c: &mut Criterion) { - // 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(); diff --git a/rayforce/examples/lambda_demo.rs b/rayforce/examples/lambda_demo.rs index ce7a67f..50879d7 100644 --- a/rayforce/examples/lambda_demo.rs +++ b/rayforce/examples/lambda_demo.rs @@ -6,7 +6,6 @@ use rayforce::{col, sum, Fn, Runtime, Table, Value}; fn main() { // Нужен живой рантайм (один на процесс). Runtime::scope(|_rt| { - // 1. Создаём лямбду из исходника Rayfall. let square = Fn::new("(fn [x] (* x x))").unwrap(); println!("лямбда: {square}"); diff --git a/rayforce/tests/q.rs b/rayforce/tests/q.rs index ae7a2c0..41b1fa6 100644 --- a/rayforce/tests/q.rs +++ b/rayforce/tests/q.rs @@ -78,7 +78,6 @@ fn spawn_mock(response: Vec) -> u16 { #[test] fn q_pulls_a_table() { Runtime::scope(|_rt| { - let response = msg(&table( &["seq", "sym"], &[long_vec(&[1, 2, 3]), sym_vec(&["AAPL", "MSFT", "GOOG"])], diff --git a/rayforce/tests/q_real.rs b/rayforce/tests/q_real.rs index 6075aaa..d975d54 100644 --- a/rayforce/tests/q_real.rs +++ b/rayforce/tests/q_real.rs @@ -44,7 +44,8 @@ fn real_q_roundtrips_atoms_vectors_and_tables() { 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(); + 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]); diff --git a/rayforce/tests/scalars.rs b/rayforce/tests/scalars.rs index 3b607e6..25cda87 100644 --- a/rayforce/tests/scalars.rs +++ b/rayforce/tests/scalars.rs @@ -48,8 +48,8 @@ fn symbol_and_string() { fn guid_roundtrip() { Runtime::scope(|_rt| { let bytes = [ - 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, 0x32, - 0x10, + 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()); @@ -139,7 +139,6 @@ fn matches_engine_evaluation() { fn chrono_roundtrips() { use chrono::{NaiveDate, NaiveTime, TimeZone, Utc}; Runtime::scope(|_rt| { - let d = NaiveDate::from_ymd_opt(2021, 6, 15).unwrap(); assert_eq!(d.to_value().extract::().unwrap(), d); diff --git a/rayforce/tests/table.rs b/rayforce/tests/table.rs index 246a6c6..d9b92fe 100644 --- a/rayforce/tests/table.rs +++ b/rayforce/tests/table.rs @@ -79,11 +79,14 @@ fn matches_engine_table() { // 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(); + 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")); + assert!( + rendered.contains("sym") && rendered.contains("price") && rendered.contains("size") + ); let _ = engine; // engine literal is advisory; not asserted on Ok(()) }) @@ -161,7 +164,8 @@ fn splayed_sym_values_roundtrip() { ) .unwrap(); - let base = std::env::temp_dir().join(format!("rayforce_rs_splay_sym_{}", std::process::id())); + 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(); From 60bde8963b2f3ca50ebcf74b37d9193a6cb6eac4 Mon Sep 17 00:00:00 2001 From: Ihor Filimonov <254675014+ihrfv@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:44:07 +0200 Subject: [PATCH 13/16] fix: the runtime guard is a thread property, not a process one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LIVE was a process-wide AtomicBool, but everything it guards is thread-local: the core's VM (__VM, src/core/runtime.c) and its heap (ray_tl_heap, src/mem/heap.c) both are. So inside a scope any other thread saw a live runtime and every guard passed. Runtime::scope(|_rt| { std::thread::spawn(|| { rayforce::eval("(+ 1 1)"); }).join() ... }) crashed in ray_eval_str, which dereferences __VM with no null check — safe code, no unsafe anywhere. Constructors were quieter but not better: off-thread Value::sym("hello") succeeded, because ray_alloc maps a fresh per-thread heap when none exists, and nothing will ever unmap that one. Split the two questions instead of conflating them. Creating a runtime stays process-wide and untouched: __RUNTIME is an unguarded global that a second ray_runtime_create would overwrite in silence, so the compare-exchange remains the only refusal. Calling in becomes a thread-local Cell, set beside ray_runtime_create and cleared in Drop — a pair that cannot drift, since Runtime is !Send and never leaves scope. Every guarded entry point already funnelled through one assertion, so only its body changed. is_live() becomes on_runtime_thread(), which is the question a caller actually has. A false answer no longer implies a runtime can be created here, so both refusals now name which invariant bit: "called off the runtime's thread" against "requires a live Runtime", and "already live on another thread" against "cannot be nested". Rustdoc claimed a thread_local! stash was "the single remaining way to build a dangling handle from safe code". It was not — spawning was easier and worse. The section now says what the Send bounds do and do not reach, and that the thread half is a runtime check by necessity. a_second_thread_cannot_start_a_runtime pins the invariant this must leave alone; it passes before the change as well as after. Co-Authored-By: Claude Opus 5 --- rayforce/src/dict.rs | 4 +- rayforce/src/ipc.rs | 4 +- rayforce/src/lib.rs | 2 +- rayforce/src/list.rs | 6 +- rayforce/src/q.rs | 6 +- rayforce/src/runtime.rs | 123 ++++++++++++++++++++++++++--------- rayforce/src/scalars.rs | 32 ++++----- rayforce/src/table.rs | 10 +-- rayforce/src/vector.rs | 12 ++-- rayforce/tests/no_runtime.rs | 4 +- rayforce/tests/runtime.rs | 6 +- rayforce/tests/soundness.rs | 57 ++++++++++++++++ 12 files changed, 191 insertions(+), 75 deletions(-) diff --git a/rayforce/src/dict.rs b/rayforce/src/dict.rs index d611775..6638737 100644 --- a/rayforce/src/dict.rs +++ b/rayforce/src/dict.rs @@ -2,7 +2,7 @@ use crate::error::{check, RayError, Result}; use crate::raw; -use crate::runtime::assert_live; +use crate::runtime::assert_on_runtime_thread; use crate::value::Value; use rayforce_sys as sys; @@ -11,7 +11,7 @@ impl Value { /// /// Consumes both arguments (the core takes ownership of each). pub fn dict(keys: Value, values: Value) -> Value { - assert_live("Value::dict"); + 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 737dcd7..650fdf6 100644 --- a/rayforce/src/ipc.rs +++ b/rayforce/src/ipc.rs @@ -5,7 +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_live; +use crate::runtime::assert_on_runtime_thread; use crate::value::Value; use rayforce_sys as sys; use std::ffi::CString; @@ -57,7 +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_live("TcpClient::connect"); + 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 = diff --git a/rayforce/src/lib.rs b/rayforce/src/lib.rs index d02ccaf..1889e20 100644 --- a/rayforce/src/lib.rs +++ b/rayforce/src/lib.rs @@ -42,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 5a5f0de..1d49e37 100644 --- a/rayforce/src/list.rs +++ b/rayforce/src/list.rs @@ -6,14 +6,14 @@ use crate::error::{check, RayError, Result}; use crate::raw::{self, Raw}; -use crate::runtime::assert_live; +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_live("Value::list"); + assert_on_runtime_thread("Value::list"); unsafe { let mut l = match check(sys::ray_list_new(items.len() as i64)) { Ok(p) => p, @@ -32,7 +32,7 @@ impl Value { /// An empty list with the given capacity. pub fn empty_list(capacity: i64) -> Value { - assert_live("Value::empty_list"); + 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 a37c051..8317d98 100644 --- a/rayforce/src/q.rs +++ b/rayforce/src/q.rs @@ -21,7 +21,7 @@ use std::marker::PhantomData; use rayforce_sys as sys; use crate::error::{check, RayError, Result}; -use crate::runtime::assert_live; +use crate::runtime::assert_on_runtime_thread; use crate::value::Value; /// An open connection to a Q server. Closed on drop. @@ -71,7 +71,7 @@ impl QConnection { password: &str, timeout_ms: i32, ) -> Result { - assert_live("QConnection::connect"); + 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 = @@ -142,7 +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_live("q::decode_response"); + 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 79ff207..10f8fb1 100644 --- a/rayforce/src/runtime.rs +++ b/rayforce/src/runtime.rs @@ -10,18 +10,42 @@ //! 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}; -/// Is a runtime live in this process? The core permits exactly one. +/// 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); +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. /// @@ -108,15 +132,24 @@ impl Runtime { /// borrow — is refused too, with a diagnostic about threads when no thread /// is involved. Move such values into the closure, or construct them inside. /// - /// # The one escape these bounds miss + /// # What the bounds do not cover /// - /// 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. + /// 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, @@ -133,16 +166,24 @@ impl Runtime { .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) .is_err() { - return Err(RayError::binding( - "a rayforce runtime is already live in this process — \ - Runtime::scope cannot be nested", - )); + // 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, 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, @@ -180,31 +221,49 @@ impl Drop for Runtime { } 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 a runtime currently live in this process? +/// 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. /// -/// True only inside a [`Runtime::scope`] body. -pub fn is_live() -> bool { - LIVE.load(Ordering::SeqCst) +/// 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 a runtime is live. +/// Panic unless this thread owns the runtime. /// -/// The engine has no guard of its own: `ray_eval` and the atom constructors -/// dereference a thread-local VM that is null with no live runtime. This is an -/// unconditional assertion, not a `debug_assert` — the release build has exactly -/// the same hole. +/// 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_live(what: &str) { - assert!(is_live(), "rayforce: {what} requires a live Runtime"); +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`]. +/// Bind `value` to a global name. Requires a live [`Runtime`] on this thread. pub fn set_global(name: &str, value: &Value) -> Result<()> { - assert_live("set_global"); + 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()); @@ -217,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 { - assert_live("get_global"); + 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); @@ -233,11 +292,11 @@ pub fn get_global(name: &str) -> Result { } } -/// 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 { - assert_live("eval"); + 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()); @@ -249,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 { - assert_live("eval_value"); + 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 e6cfffd..bca7bfd 100644 --- a/rayforce/src/scalars.rs +++ b/rayforce/src/scalars.rs @@ -7,7 +7,7 @@ use crate::error::{check, RayError, Result}; use crate::raw::{self, Raw}; -use crate::runtime::assert_live; +use crate::runtime::assert_on_runtime_thread; use crate::value::Value; use rayforce_sys as sys; @@ -27,43 +27,43 @@ impl Value { /// A boolean atom (`-RAY_BOOL`). pub fn bool(v: bool) -> Value { - assert_live("Value::bool"); + 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_live("Value::u8"); + 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_live("Value::i16"); + 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_live("Value::i32"); + 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_live("Value::i64"); + 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_live("Value::f32"); + 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_live("Value::f64"); + 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_live("Value::sym"); + 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)) @@ -72,7 +72,7 @@ impl Value { /// A string atom (`-RAY_STR`). pub fn string(s: &str) -> Value { - assert_live("Value::string"); + assert_on_runtime_thread("Value::string"); unsafe { own(sys::ray_str(s.as_ptr() as *const _, s.len())) } } @@ -80,7 +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_live("Value::name_ref"); + 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)); @@ -91,29 +91,29 @@ impl Value { /// A date atom: raw days since 2000-01-01. pub fn date_days(days: i32) -> Value { - assert_live("Value::date_days"); + 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_live("Value::time_millis"); + 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_live("Value::timestamp_nanos"); + 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_live("Value::guid"); + 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_live("Value::typed_null"); + 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 73a32c6..af70e64 100644 --- a/rayforce/src/table.rs +++ b/rayforce/src/table.rs @@ -7,7 +7,7 @@ use crate::error::{check, RayError, Result}; use crate::raw; -use crate::runtime::assert_live; +use crate::runtime::assert_on_runtime_thread; use crate::value::Value; use rayforce_sys as sys; @@ -25,7 +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_live("Table::new"); + assert_on_runtime_thread("Table::new"); if names.len() != columns.len() { return Err(RayError::binding(format!( "table: {} names but {} columns", @@ -153,7 +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_live("Table::read_csv"); + assert_on_runtime_thread("Table::read_csv"); let upper: Vec = column_types .iter() .map(|t| normalize_type_token(t.as_ref())) @@ -199,7 +199,7 @@ impl Table { /// Load a splayed table from `dir`. pub fn load_splayed(dir: &str, sym_path: Option<&str>) -> Result
{ - assert_live("Table::load_splayed"); + assert_on_runtime_thread("Table::load_splayed"); let dir_v = Value::string(dir); let sym_v = sym_path.map(Value::string); unsafe { @@ -219,7 +219,7 @@ impl Table { /// Load a partitioned table named `name` rooted at `root`. pub fn load_parted(root: &str, name: &str) -> Result
{ - assert_live("Table::load_parted"); + 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/vector.rs b/rayforce/src/vector.rs index f04b857..c8a3261 100644 --- a/rayforce/src/vector.rs +++ b/rayforce/src/vector.rs @@ -10,7 +10,7 @@ use crate::error::{check, RayError, Result}; use crate::raw::{self, Raw}; -use crate::runtime::assert_live; +use crate::runtime::assert_on_runtime_thread; use crate::value::Value; use core::ffi::c_void; use rayforce_sys as sys; @@ -46,7 +46,7 @@ impl Value { /// Build a vector from a slice of fixed-width elements (single `memcpy`). pub fn vec(data: &[T]) -> Value { - assert_live("Value::vec"); + assert_on_runtime_thread("Value::vec"); unsafe { let p = check(sys::ray_vec_from_raw( T::RAY_TYPE, @@ -62,7 +62,7 @@ impl Value { /// Build a boolean vector (`RAY_BOOL`) from a slice of `bool`. pub fn bool_vec(data: &[bool]) -> Value { - assert_live("Value::bool_vec"); + 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( @@ -79,7 +79,7 @@ impl Value { /// Build a symbol vector, interning each string. pub fn sym_vec>(items: &[S]) -> Value { - assert_live("Value::sym_vec"); + assert_on_runtime_thread("Value::sym_vec"); unsafe { let mut v = match check(sys::ray_sym_vec_new( sys::RAY_SYM_W64 as u8, @@ -99,7 +99,7 @@ impl Value { /// Build a string vector (`RAY_STR`). pub fn str_vec>(items: &[S]) -> Value { - assert_live("Value::str_vec"); + 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, @@ -119,7 +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_live("Value::empty_vec"); + 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/no_runtime.rs b/rayforce/tests/no_runtime.rs index 0691e2c..cbe8103 100644 --- a/rayforce/tests/no_runtime.rs +++ b/rayforce/tests/no_runtime.rs @@ -12,7 +12,7 @@ #[test] #[should_panic(expected = "requires a live Runtime")] fn an_atom_cannot_be_built_without_a_runtime() { - assert!(!rayforce::is_live()); + assert!(!rayforce::on_runtime_thread()); let _ = rayforce::Value::i64(41); } @@ -22,6 +22,6 @@ 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::is_live()); + assert!(!rayforce::on_runtime_thread()); let _ = rayforce::Value::sym("hello"); } diff --git a/rayforce/tests/runtime.rs b/rayforce/tests/runtime.rs index 4ed45c4..cb8a1cf 100644 --- a/rayforce/tests/runtime.rs +++ b/rayforce/tests/runtime.rs @@ -18,14 +18,14 @@ fn eval_arithmetic() { #[test] fn recreate_runtime_after_scope() { - assert!(!rayforce::is_live()); + assert!(!rayforce::on_runtime_thread()); Runtime::scope(|_rt| { - assert!(rayforce::is_live()); + assert!(rayforce::on_runtime_thread()); assert_eq!(eval("(* 6 7)").unwrap().format(), "42"); Ok(()) }) .unwrap(); - assert!(!rayforce::is_live()); + assert!(!rayforce::on_runtime_thread()); // A second runtime in the same process must work (tests depend on this). Runtime::scope(|_rt| { assert_eq!(eval("(- 10 3)").unwrap().format(), "7"); diff --git a/rayforce/tests/soundness.rs b/rayforce/tests/soundness.rs index c03dde1..fe4e5b9 100644 --- a/rayforce/tests/soundness.rs +++ b/rayforce/tests/soundness.rs @@ -73,3 +73,60 @@ fn a_refused_connection_leaves_the_scope_usable() { .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(); +} From 776411d8a36e19702fd086e9f6f3fc45f809dec9 Mon Sep 17 00:00:00 2001 From: Ihor Filimonov <254675014+ihrfv@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:44:26 +0200 Subject: [PATCH 14/16] ci: assert the debug leg still carries the detector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole value of the core: [release, debug] axis is that -DDEBUG reached the archive, and nothing checked that it did. If the RAYFORCE_CORE_DEBUG plumbing breaks — an env rename, a build.rs refactor, a change in how Actions evaluates `&& '1' || ''` — the debug leg quietly becomes a second release run and stays green forever. That is the same shape as the tests/ipc.rs early return this branch closes, and it was verified once by hand rather than by the pipeline. Both legs now assert the archive they built, in opposite directions: ray_dfd_check_live present on debug, absent on release. The control matters as much as the check — a grep that matched nothing anywhere would otherwise pass on the release leg too, which is exactly the failure being guarded against. It is the same pairing every compile_fail doctest here already carries. grep -c rather than grep -q, for a reason observed while testing this: -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 the check into one that passes for the wrong reason. This proves the detector is compiled in, not that it is armed. Arming is dfd_enabled() reading RAY_DFD (src/mem/heap.c) and the only proof of that is a deliberate double release, which aborts the process and so cannot live in the suite. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16044f0..a014fa6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,6 +108,41 @@ jobs: 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 From 9af4445e9b97f5a9c4200dfce0d46011e216cdbd Mon Sep 17 00:00:00 2001 From: Ihor Filimonov <254675014+ihrfv@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:44:26 +0200 Subject: [PATCH 15/16] docs(sys): a flavour switch rebuilds a RAYFORCE_SRC checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving invalidate_on_stamp_change out of `if stamp_version` was deliberate and necessary — core_flavour() is consulted for either tree, so a RAYFORCE_SRC checkout can flip flavour and would otherwise archive a library mixing release and debug objects, which share every filename. Only the documentation lagged. Both doc comments still framed stamping as the OUT_DIR case, and neither said what the function does to a directory the user owns: on the first build after a flavour change it deletes every object under src/ and the librayforce.a beside them, and leaves a .stamp file that upstream's .gitignore does not cover. The installation page claimed such a checkout is "built in place so your incremental state ... [is] preserved", which is now true except across a flavour switch. Say so in both places a reader meets RAYFORCE_SRC. Co-Authored-By: Claude Opus 5 --- README.md | 6 ++++++ docs/docs/content/get-started/installation.md | 10 ++++++++++ rayforce-sys/build.rs | 15 ++++++++++++++- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9f4d150..1b98ff8 100644 --- a/README.md +++ b/README.md @@ -148,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/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/rayforce-sys/build.rs b/rayforce-sys/build.rs index 71970fd..bcbebfd 100644 --- a/rayforce-sys/build.rs +++ b/rayforce-sys/build.rs @@ -350,6 +350,16 @@ fn walk(root: &Path, visit: &mut dyn FnMut(&Path)) { /// 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) { @@ -424,7 +434,10 @@ fn core_flavour() -> Flavour { /// 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 From 2cb2528da9838a0ab70f7ce35c1a45eead6b4f2d Mon Sep 17 00:00:00 2001 From: Ihor Filimonov <254675014+ihrfv@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:44:26 +0200 Subject: [PATCH 16/16] docs: changelog the off-thread fix and the two CI/build changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also corrects the "one predicate" entry, which named is_live() — now on_runtime_thread(), and answering a per-thread rather than a per-process question. Co-Authored-By: Claude Opus 5 --- docs/docs/content/CHANGELOG.md | 39 +++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/docs/docs/content/CHANGELOG.md b/docs/docs/content/CHANGELOG.md index 47a7eaa..a1dd126 100644 --- a/docs/docs/content/CHANGELOG.md +++ b/docs/docs/content/CHANGELOG.md @@ -15,7 +15,10 @@ All notable changes to `rayforce` are documented here. This project adheres to 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. + 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 @@ -26,6 +29,15 @@ All notable changes to `rayforce` are documented here. This project adheres to ### 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 @@ -42,13 +54,30 @@ All notable changes to `rayforce` are documented here. This project adheres to `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. -- **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 the same - `is_live()`, which is true only inside a scope. +- **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