Skip to content

ci: detect memory misuse before merge, and fix the five lifetime bugs it surfaces - #5

Merged
protocolstardust merged 16 commits into
RayforceDB:masterfrom
ihrfv:feat/ci-soundness
Aug 31, 2026
Merged

protocolstardust merged 16 commits into
RayforceDB:masterfrom
ihrfv:feat/ci-soundness

Conversation

@ihrfv

@ihrfv ihrfv commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Sits on top of #4 (the vendored-submodule build), now merged — the diff is this
branch's own 12 commits.

Why

CI ran fmt, clippy, and cargo test against a release build of the C
engine. Nothing in that pipeline can see a use-after-free, so five lifetime
defects — all reachable from safe code, one of them a segfault from ten lines —
sat in the tree unnoticed.

So this branch adds the detector first, then fixes what it reports: the four CI
commits lead, and the fixes that follow are the bugs the new leg finds.

The detector

Miri cannot run this crate at all. It interprets MIR and cannot execute compiled
foreign code, while every entry point here crosses into librayforce.a
Runtime::scope calls ray_runtime_create, Value::drop calls ray_release.
cargo miri test dies on the first one. There is no pure-Rust subset worth
interpreting.

AddressSanitizer is no better, and the engine's own source says so. The heap is
mmap-backed — ray_sys_alloc (src/mem/sys.c) goes straight to ray_vm_alloc
and never calls malloc, which is exactly what ASan, LeakSanitizer and Valgrind
track. From src/mem/heap.c:

ASan cannot see use-after-free inside the pool allocator, so this is the tool
of choice for chasing double releases.

"This" is DFD, a stale-pointer detector already in the core: a shadow set of
block addresses currently on a freelist, consulted by ray_retain and
ray_release (src/mem/cow.c), aborting with a backtrace on a hit. It is gated
on the core being compiled with -DDEBUG and on RAY_DFD=1 at runtime.
build.rs ran make lib, which archives release objects — so the detector built
for this exact bug class had never been compiled into anything this crate links.

RAYFORCE_CORE_DEBUG=1 now builds that flavour. The flavour joins RAY_VERSION
and GIT_HASH in the stamp invalidate_on_stamp_change already compares, since
both flavours compile to the same object names and make tracks header
dependencies but not flag changes — without it, a flavour switch would archive a
mixed library.

Two CI gaps closed

The debug leg. A core: [release, debug] matrix axis. fmt and clippy are
flavour-independent and stay on the release leg. The check names become
test (release) / test (debug)branch protection needs updating to require
both.

tests/ipc.rs was not running. It spawns a real server to exercise
TcpClient and returned early without one — and an early return reports as a
pass, so the absence of coverage was indistinguishable from the coverage
passing. That is the path two of the fixes below land on, so they would have
shipped with nothing executed. CI now builds the server binary, and
RAYFORCE_REQUIRE_SERVER=1 turns a missing one into a failure rather than a
skip. The binary is built in the staged OUT_DIR tree that build.rs just
compiled, so it costs one main.c compile plus a link — measured at 0.23s, not a
second engine build.

tests/q_real.rs still opts out via RAYFORCE_Q_ADDR. It needs a real q
server, which cannot be provisioned on a runner. That skip is genuine and is now
the only one.

The root cause the bugs share

Two things are shared across the FFI boundary, and only one was counted.

ray_t.rc counts references to an object, and the crate tracked it correctly:
Clone retains, Drop releases. Nothing counted references to the heap those
objects live in — and that is the one that matters, because
ray_runtime_destroy never consults rc. It munmaps every pool outright
(ray_heap_destroy, src/mem/heap.c). An object with rc == 5 is unmapped
exactly like one with rc == 1.

So a handle outliving its runtime does not point at freed bytes that happen to
still be readable. It points at unmapped address space, and there is no check
at the point of use that can make dereferencing it safe: by then the thing to
check is the pointer, and the pointer is what became invalid.

Rather than add the missing count, this branch removes the shape.
Runtime::new becomes private and Runtime::scope(|rt| { … }) is the only way
to a runtime. It creates the runtime, hands the closure a &Runtime that cannot
be dropped or moved out of, and tears it down when the closure returns — on the
error path and on unwind alike. The closure's locals go first, so every Value
is dropped before the heap it points into is unmapped.

What keeps values inside the scope is the !Send marker the crate already
carried, read as a bound. 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 consequences are worth stating plainly:

  • Value carries no bookkeeping. It stays one pointer wide, with nothing
    added to clone or drop. A heap-level refcount would have cost a relaxed atomic
    on every construct, clone and drop; bounding the runtime's life costs nothing
    at runtime at all.
  • The diagnostic talks about threads when no thread is involved. An unrelated
    !Send capture — an Rc, a RefCell borrow — is refused too. Construct such
    values inside the closure, or move them in.
  • One escape slips through. A closure that stashes into a thread_local!
    captures nothing, so it satisfies the bound. That is documented on
    Runtime::scope rather than papered over.
  • A nested scope errors instead of starting a second runtime; the core
    permits one heap at a time.

The bugs

A Value outliving its Runtime segfaults. The case above, and the one the
debug leg actually reports. It surfaced at process exit, far from its cause.

The connection types close into an unmapped heap. TcpClient and
QConnection had no liveness tracking of any kind, and their Drops call
ray_ipc_close / q_close, both of which reach into the runtime. Both are now
!Send/!Sync like every other handle, which is what the scope's bounds read,
and both Drops run before the runtime's.

Building a value did not require a runtime. Value::i64(1) with no runtime
was safe Rust calling straight into the engine with no check at all. Nothing
crashed, 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 is symbols, which
are runtime-scoped — Value::sym("hello") returned an empty symbol, dropping
the string with no error anywhere. Verified before the fix: it formatted as '.

The runtime leaks its event loop. TcpClient::connect installs a poll on
first use and ray_runtime_destroy never touches it. Teardown now takes it down
before the heap goes — closing a selector releases engine objects held for it,
so that order is load-bearing. Found by reading, not by the debug leg: this is a
leak, and no leak checker tracks mmap. What CI adds here is that the path now
executes at all.

QConnection was Send and Sync. A bare file descriptor with no
PhantomData, so it inferred both, while Value, Runtime and TcpClient are
neither. execute interns symbols and builds engine objects belonging to the
runtime's thread, and Sync additionally allowed two threads to interleave
writes on one socket. Compile-time properties need compile-time tests, so all
four handle-carrying types now ship compile_fail doctests — each paired with a
control identical but for the bound, since a compile_fail block passes whenever
the snippet fails to build for any reason.

Building with --no-default-features (no chrono) is warning-free again.

The rule this settles on

One predicate, not two: a live Runtime guard 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.

Migration

Mechanical — delete let _rt = Runtime::new()?; and wrap the body in
Runtime::scope(|rt| { … Ok(()) })?. Every test, the example, the benchmarks and
65 snippets across 34 docs pages moved that way.

Cost

Value stays 8 bytes with no per-handle bookkeeping. No API changes shape apart
from Runtime::new becoming Runtime::scope. The debug leg builds the engine at
-O0, which is fast — the release -O3 -march=native build is the slow one.

Verification

Run locally against the vendored core this branch now sits on (v2.5.8,
f0d4bb4):

  • The detector is armed, proven by positive control. A deliberate double
    release through the sys escape hatch aborts with
    === DFD: ray_release (stale release of freed block) on FREED block 0x... ===
    and a backtrace through ray_release. The debug archive contains
    ray_dfd_check_live; the release archive does not (nm reports 2 symbols vs
    0). A clean run therefore means something.
  • Both legs green at HEAD, 109 tests each (87 integration + 22 doctests),
    zero IPC skips, fmt and clippy -D warnings clean, --no-default-features
    warning-free — locally, and on this PR's own CI run, where test (release) and
    test (debug) both pass and the four server-backed IPC tests report ok on
    each leg.
  • tests/ipc.rs runs against a real spawned server, including a client that
    is closed before its scope ends and a failed connect. The server binary carries
    DFD on the
    debug leg and reports 2.5.8 (f0d4bb4) — the pinned core, not this
    repository's HEAD, which is what an unset RAY_VERSION would have stamped from
    inside OUT_DIR.
  • Every commit builds, checked one by one, so the history bisects.
  • The empty-symbol bug was reproduced before being fixed, and tests/no_runtime.rs
    pins it in its own test binary — the property is about a process where no
    runtime was ever created, which cannot be arranged in a file that also builds one.

🤖 Generated with Claude Code

ihrfv and others added 12 commits August 27, 2026 13:04
`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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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<Value>`, `Vec<Value>`, `Box<Value>`; 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 <noreply@anthropic.com>
`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 <noreply@anthropic.com>
`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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@ihrfv

ihrfv commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Branch protection: the check names changed

This PR splits the test job across a core: [release, debug] matrix, so the single
test check becomes two — test (release) and test (debug). Both are reporting on
this PR already and both pass (run
33077685036:
release 2m44s, debug 1m40s).

The debug leg is the one that carries the point of the branch — it builds the core with
-DDEBUG and runs with RAY_DFD=1, which is the only configuration where the engine's
stale retain/release detector exists at all. If it isn't a required check, a future
use-after-free merges green.

master currently has no protection rule (GET /repos/RayforceDB/rayforce-rs/branches/master
reports "protected": false, and /rulesets is empty), so this would be creating one
rather than editing it. Minimal version, if that's wanted:

gh api -X PUT repos/RayforceDB/rayforce-rs/branches/master/protection --input - <<'JSON'
{
  "required_status_checks": {
    "strict": false,
    "checks": [{"context": "test (release)"}, {"context": "test (debug)"}]
  },
  "enforce_admins": false,
  "required_pull_request_reviews": null,
  "restrictions": null,
  "required_linear_history": false,
  "required_conversation_resolution": true
}
JSON

required_linear_history stays false on purpose — PRs here land as merge commits
(82f2b34), and enabling it would reject them.

Entirely a repo-settings call, not a code one; happy to leave it as-is if you'd rather
keep master unprotected.

@ihrfv

ihrfv commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

@protocolstardust Could you please take a look at the PR? thanks :)

@protocolstardust protocolstardust left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really like the shape of this — bounding the runtime's life instead of adding a heap refcount is the right call, and I checked the poll teardown against the core: ray_runtime_destroy genuinely never touches rt->poll, and ray_poll_destroy really does release engine objects via close_fn, so that ordering is load-bearing exactly like the comment says. Ran both legs locally too, all green, and nm does show the DFD symbols in the debug archive and not in the release one.

A few questions before this goes in.

The liveness flag is process-global, but the thing it guards is thread-local

LIVE is a process-wide AtomicBool, so inside a scope any other thread sees is_live() == true and every guard passes. This segfaults for me:

Runtime::scope(|_rt| {
    std::thread::spawn(|| rayforce::eval("(+ 1 1)")).join().unwrap();
    Ok(())
})

No unsafe anywhere — the spawned closure captures nothing, so it's Send, and the outer one stays Send too. Constructors are quieter but not better: off-thread Value::sym("hello") cheerfully returns "hello" out of an orphan heap nobody will ever unmap.

Any reason not to hang a thread_local! { Cell<bool> } off Runtime::new/Drop and have assert_live read that, keeping LIVE for the "already live" refusal? Asking partly because Runtime::scope's docs say the thread_local! stash is "the single remaining way to build a dangling handle from safe code" — if we're making that claim I'd like it to hold. And if it means changing what the public is_live() means, I'd rather that happened in this major than the next one.

Can the debug leg tell us when it stops detecting?

The whole value of the new axis is that -DDEBUG made it into the archive, and nothing checks that it did. If the RAYFORCE_CORE_DEBUG plumbing ever breaks — env rename, a change in how Actions evaluates that && '1' || '', a build.rs refactor — the leg quietly becomes a second release run and stays green forever. Which is the same shape as the tests/ipc.rs early-return you're fixing here. Would nm ... | grep -q ray_dfd_check_live on the debug leg be worth the line? I confirmed it discriminates cleanly (2 symbols vs 0).

invalidate_on_stamp_change moving out of if stamp_version

Was the effect on RAYFORCE_SRC checkouts intended? As far as I can tell the first cargo build after this now deletes every src/**/*.o and librayforce.a in a directory the user owns, and leaves an untracked .stamp behind. I think it's actually necessary for the flavour switch to be safe — just want to confirm it's deliberate, and if so it probably deserves a changelog line and a tweak to the function's doc comment, which still frames stamping as the OUT_DIR-only case.

ihrfv and others added 4 commits August 31, 2026 15:44
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@ihrfv

ihrfv commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — all three land, and the first is a genuine merge blocker. Fixed in four commits on top of b48bd83. Both legs green against the pinned core (v2.5.8, f0d4bb4): 112 tests each — 90 integration + 22 doctests — zero IPC skips, fmt and clippy -D warnings clean, --no-default-features warning-free.

The liveness flag — you're right, and it was worse than a dangling handle

No reason not to; done exactly as you describe. LIVE keeps the "already live" refusal and stays process-wide. A thread_local! { Cell<bool> }, set beside ray_runtime_create and cleared in Drop, is what the assertion reads. The pair cannot drift: Runtime is !Send and never leaves scope, so set and clear always run on one thread.

Two additions to your diagnosis.

Your repro doesn't compile as written. thread::spawn requires T: Send, and Result<Value> isn't — Value carries PhantomData<*mut ()>. Discard the value inside the thread and it compiles and crashes exactly as you said. It is now eval_off_the_runtime_thread_is_refused; on the commit before the fix it takes the whole test binary down with signal: 11, SIGSEGV, in ray_eval_str, which dereferences __VM->nfo with no null check (src/lang/eval.c).

Keeping a second runtime impossible mattered more than it looked. __RUNTIME (src/core/runtime.c:46) is a plain global — a second ray_runtime_create overwrites the first and C says nothing. So the compare-exchange is the only refusal there is, and it stays exactly where it was. a_second_thread_cannot_start_a_runtime pins that, and it passes before the change as well as after; that's what makes it a regression guard rather than a new claim.

On naming: is_live() is now on_runtime_thread(). Splitting the predicates creates a state that did not exist before — live in the process, not on my thread — and is_live() returning false there would have been actively misleading, since it would sit next to a Runtime::scope that refuses. Both refusals now name which bit they are: called off the runtime's thread vs requires a live Runtime, and already live on another thread vs cannot be nested. Breaking, and better in this change than a release later, as you say.

The thread_local!-stash claim is corrected rather than kept — spawning was the easier route and the worse outcome. That section now says what the Send bounds do and don't reach, and that the thread half is a runtime check by necessity: the type system isn't tracking which thread a call happens on.

The debug leg — added, and the one-liner needed a second look

Worth the line. Both legs assert now, in opposite directions: without the release-side control, a grep matching nothing anywhere would pass too — the same pairing every compile_fail doctest here already carries.

grep -c, not grep -q, for a reason I hit while testing it. -q exits on the first match, nm then takes SIGPIPE, and under pipefail the pipeline reports failure — so a present symbol reads as absent. My first debug run printed absent while grep -c on the same archive found 2. Actions' default shell doesn't set pipefail, so it would have worked by luck; under one that does, the check inverts into something that passes for the wrong reason on the release leg. Same shape as the bug it exists to catch. Verified under set -o pipefail against both real archives: 2 on debug, 0 on release.

It proves the detector is compiled in, not that it is armed — that's dfd_enabled() reading RAY_DFD (src/mem/heap.c), and the only proof of arming is the deliberate double release, which aborts the process and so can't live in the suite. Said in the step comment rather than left implied.

invalidate_on_stamp_change — deliberate, and the docs were behind

Intended: 83e029f's message says so — "That check now runs for a RAYFORCE_SRC checkout too, which previously had none". And necessary, as you suspected: core_flavour() is consulted regardless of stamp_version, so a RAYFORCE_SRC tree can flip flavour and would otherwise archive a library mixing release and debug objects, which share every filename.

The claim was stale in more places than the one you found, so it's documented in four:

  • invalidate_on_stamp_change — a section saying it runs for both trees, and what it does to a directory the user owns.
  • build_core_libstamp_version selects RAY_VERSION/GIT_HASH and nothing else; invalidation is deliberately not gated on it.
  • docs/.../installation.md claimed a RAYFORCE_SRC checkout is "built in place so your incremental state ... [is] preserved". True except across a flavour switch, which is now called out.
  • README, the same note. Plus the CHANGELOG line.

You're right about .stamp: the core's .gitignore covers *.o/*.d/*.a but not it, so it shows up untracked in a core developer's own checkout. Left as a note here rather than opening a one-liner against RayforceDB/rayforce — say the word and I will.

Still outstanding

Branch protection: test (release) and test (debug) both need to be required checks. The gh api call is in the earlier comment; unchanged by any of the above.

@ihrfv

ihrfv commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up after merge:

bump the version to 1.1.0

.github/workflows/release.yml verifies the tag against the workspace version and fails when they disagree, so the bump has to land as its own commit before tagging. Proposing 1.1.0 in the workspace version key; both crates inherit it.

One thing worth having on the record rather than by accident: tag 1.0.1 shipped pub fn new() and pub fn is_live(), and this branch makes the first private and renames the second to on_runtime_thread(). cargo semver-checks will therefore read the delta as major, and strict semver would say 2.0.0. 1.1.0 is the call here — noting the gap so it's a choice rather than an oversight, and so nobody is surprised by the tooling later.

Migration for anyone on 1.0.x is the two lines already in the changelog: replace let _rt = Runtime::new()?; with Runtime::scope(|rt| { … Ok(()) })?, and is_live() with on_runtime_thread().

Update the merge restriction

instead of a single test check there are now test(debug) and test(release) checks

@ihrfv
ihrfv requested a review from protocolstardust August 31, 2026 14:10
@protocolstardust
protocolstardust merged commit 573b4ce into RayforceDB:master Aug 31, 2026
2 checks passed
@ihrfv
ihrfv deleted the feat/ci-soundness branch August 31, 2026 15:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants