ci: detect memory misuse before merge, and fix the five lifetime bugs it surfaces - #5
Conversation
`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>
Branch protection: the check names changedThis PR splits the test job across a The debug leg is the one that carries the point of the branch — it builds the core with
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
Entirely a repo-settings call, not a code one; happy to leave it as-is if you'd rather |
|
@protocolstardust Could you please take a look at the PR? thanks :) |
There was a problem hiding this comment.
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.
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>
|
Thanks — all three land, and the first is a genuine merge blocker. Fixed in four commits on top of The liveness flag — you're right, and it was worse than a dangling handleNo reason not to; done exactly as you describe. Two additions to your diagnosis. Your repro doesn't compile as written. Keeping a second runtime impossible mattered more than it looked. On naming: The The debug leg — added, and the one-liner needed a second lookWorth 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
It proves the detector is compiled in, not that it is armed — that's
|
Follow-up after merge:bump the version to 1.1.0
One thing worth having on the record rather than by accident: tag Migration for anyone on 1.0.x is the two lines already in the changelog: replace Update the merge restrictioninstead of a single |
Why
CI ran
fmt,clippy, andcargo testagainst a release build of the Cengine. 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::scopecallsray_runtime_create,Value::dropcallsray_release.cargo miri testdies on the first one. There is no pure-Rust subset worthinterpreting.
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 toray_vm_allocand never calls
malloc, which is exactly what ASan, LeakSanitizer and Valgrindtrack. From
src/mem/heap.c:"This" is DFD, a stale-pointer detector already in the core: a shadow set of
block addresses currently on a freelist, consulted by
ray_retainandray_release(src/mem/cow.c), aborting with a backtrace on a hit. It is gatedon the core being compiled with
-DDEBUGand onRAY_DFD=1at runtime.build.rsranmake lib, which archives release objects — so the detector builtfor this exact bug class had never been compiled into anything this crate links.
RAYFORCE_CORE_DEBUG=1now builds that flavour. The flavour joinsRAY_VERSIONand
GIT_HASHin the stampinvalidate_on_stamp_changealready compares, sinceboth flavours compile to the same object names and
maketracks headerdependencies 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.fmtandclippyareflavour-independent and stay on the release leg. The check names become
test (release)/test (debug)— branch protection needs updating to requireboth.
tests/ipc.rswas not running. It spawns a real server to exerciseTcpClientand returned early without one — and an early return reports as apass, 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=1turns a missing one into a failure rather than askip. The binary is built in the staged
OUT_DIRtree thatbuild.rsjustcompiled, so it costs one
main.ccompile plus a link — measured at 0.23s, not asecond engine build.
tests/q_real.rsstill opts out viaRAYFORCE_Q_ADDR. It needs a realqserver, 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.rccounts references to an object, and the crate tracked it correctly:Cloneretains,Dropreleases. Nothing counted references to the heap thoseobjects live in — and that is the one that matters, because
ray_runtime_destroynever consultsrc. It munmaps every pool outright(
ray_heap_destroy,src/mem/heap.c). An object withrc == 5is unmappedexactly 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::newbecomes private andRuntime::scope(|rt| { … })is the only wayto a runtime. It creates the runtime, hands the closure a
&Runtimethat cannotbe 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
Valueis dropped before the heap it points into is unmapped.
What keeps values inside the scope is the
!Sendmarker the crate alreadycarried, read as a bound.
scoperequiresSendof its return type and of theclosure, and
Value,Table,Fn,TcpClientandQConnectionare 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:
Valuecarries no bookkeeping. It stays one pointer wide, with nothingadded 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.
!Sendcapture — anRc, aRefCellborrow — is refused too. Construct suchvalues inside the closure, or move them in.
thread_local!captures nothing, so it satisfies the bound. That is documented on
Runtime::scoperather than papered over.permits one heap at a time.
The bugs
A
Valueoutliving itsRuntimesegfaults. The case above, and the one thedebug leg actually reports. It surfaced at process exit, far from its cause.
The connection types close into an unmapped heap.
TcpClientandQConnectionhad no liveness tracking of any kind, and theirDrops callray_ipc_close/q_close, both of which reach into the runtime. Both are now!Send/!Synclike 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 runtimewas safe Rust calling straight into the engine with no check at all. Nothing
crashed, which is why it went unnoticed:
ray_alloclazily maps a heap when noneexists, so the value landed in an orphan one. The sharp case is symbols, which
are runtime-scoped —
Value::sym("hello")returned an empty symbol, droppingthe string with no error anywhere. Verified before the fix: it formatted as
'.The runtime leaks its event loop.
TcpClient::connectinstalls a poll onfirst use and
ray_runtime_destroynever touches it. Teardown now takes it downbefore 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 nowexecutes at all.
QConnectionwasSendandSync. A bare file descriptor with noPhantomData, so it inferred both, whileValue,RuntimeandTcpClientareneither.
executeinterns symbols and builds engine objects belonging to theruntime's thread, and
Syncadditionally allowed two threads to interleavewrites on one socket. Compile-time properties need compile-time tests, so all
four handle-carrying types now ship
compile_faildoctests — each paired with acontrol identical but for the bound, since a
compile_failblock passes wheneverthe snippet fails to build for any reason.
Building with
--no-default-features(nochrono) is warning-free again.The rule this settles on
One predicate, not two: a live
Runtimeguard is required for everythingexcept reading and dropping handles you already hold.
eval,set_global,get_global, the value constructors and the connection constructors all answerto the same
is_live(), which is true only inside a scope.Migration
Mechanical — delete
let _rt = Runtime::new()?;and wrap the body inRuntime::scope(|rt| { … Ok(()) })?. Every test, the example, the benchmarks and65 snippets across 34 docs pages moved that way.
Cost
Valuestays 8 bytes with no per-handle bookkeeping. No API changes shape apartfrom
Runtime::newbecomingRuntime::scope. The debug leg builds the engine at-O0, which is fast — the release-O3 -march=nativebuild is the slow one.Verification
Run locally against the vendored core this branch now sits on (v2.5.8,
f0d4bb4):release through the
sysescape hatch aborts with=== DFD: ray_release (stale release of freed block) on FREED block 0x... ===and a backtrace through
ray_release. The debug archive containsray_dfd_check_live; the release archive does not (nmreports 2 symbols vs0). A clean run therefore means something.
zero IPC skips,
fmtandclippy -D warningsclean,--no-default-featureswarning-free — locally, and on this PR's own CI run, where
test (release)andtest (debug)both pass and the four server-backed IPC tests reportokoneach leg.
tests/ipc.rsruns against a real spawned server, including a client thatis 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 thisrepository's HEAD, which is what an unset
RAY_VERSIONwould have stamped frominside
OUT_DIR.tests/no_runtime.rspins 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