fix(env): guard env_lookup_flat against a thread with no VM - #565
Conversation
ray_env_get() segfaults when called from any thread that did not call ray_runtime_create(). env_lookup_flat() dereferences the thread-local __VM unconditionally, while every other function on the same path already guards it — including ray_env_get_local() immediately below, which does the identical scope-stack walk behind `if (!__VM)`. __VM is assigned in exactly one place in the tree (runtime.c, inside ray_runtime_create), and no public API binds a VM to any other thread, so every other thread has __VM == NULL by construction. This is reachable from the public IPC API: ray_ipc_close -> ray_poll_deregister -> ipc_on_close -> hook_call_lifecycle -> hook_lookup -> ray_env_get -> env_lookup_flat. With no hook installed hook_call_lifecycle is meant to be a no-op — its next statement is `if (!fn) return;` — and the unguarded dereference is the only thing preventing that. Skip the local scopes when there is no VM; global bindings must still resolve. No behavioural change on a thread that has a VM: the loop body is unchanged, only wrapped in `if (__VM)`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
singaraiona
left a comment
There was a problem hiding this comment.
Verified end to end — reproduced, patched, and ran the suite. This is correct and I'm merging it.
Reproduction, your vmprobe.c verbatim against a build of this branch and a build of its parent:
| build | exit |
|---|---|
559b37cf~1 |
139 (SIGSEGV) |
559b37cf |
0 — worker: returned (nil) (no crash) |
Suite: make lib clean, make test → === 3887 of 3887 passed (0 skipped, 0 failed) ===. So the concern in your checklist is settled; you weren't able to run it, and it's green.
Your reading of the intent is right. ray_env_get_local at env.c:309 does the byte-identical scope walk with if (!__VM) return NULL; immediately below the unguarded copy at :296 — that asymmetry is an oversight, not a policy. And ray_env_get is public (rayforce.h:677) with a documented synchronization caveat but no threading precondition, so a VM-less caller is within the contract as written.
The known limitation you flagged is the right call, and thank you for stating it rather than guessing. With a hook globally bound, call_fn1 would now run on a thread with no VM — whether lifecycle hooks should run there at all, or whether dispatch should bind a VM first, is a real design question and not one to settle inside a NULL guard. I'll open a separate issue for it.
Two follow-ups, both on us:
-
Your
make testblocker is a real bug and a one-liner.test/test_link.c:34has#define _POSIX_C_SOURCE 200809Lsitting afterrayforce.h,test.hand the rest have already pulled infeatures.h— so it has no effect where it is, and on glibc 2.44 it's a redefinition that-Werrorrejects. Deleting the line is the whole fix. Happy to take it, or it's yours if you'd rather unblock your own checkout. -
A regression test would be welcome but isn't a merge condition.
test/test_heap_parallel.cis the only existingpthread_createuser in the suite, so there's a pattern to copy if you want to wire the probe in. Not blocking this.
What & why
Summary
ray_env_get()segfaults when called from any thread that did not callray_runtime_create().env_lookup_flat()dereferences the thread-local__VMunconditionally, while every other function on the same path —including its immediate neighbour doing the identical walk — already
guards it.
This is reachable from the public IPC API: closing a connection fires the
.ipc.on.closelifecycle lookup, which callsray_env_get().Reproduction
Reproduced against the packaged build
2.6.2.r147.g1388b334; theunguarded dereference is still present on
devatc27fdc34. Noembedder, ~25 lines:
Cause
__VMis_Thread_local(src/core/runtime.h:127) and is assigned inexactly one place in the tree —
src/core/runtime.c:261, insideray_runtime_create. No public API binds a VM to any other thread, soevery other thread has
__VM == NULLby construction.src/lang/env.c:296:Why this looks like an oversight rather than an unsupported use
Every other function on this path is already written for the VM-less
case:
env.c:309ray_env_get_localif (!__VM) return NULL;— identical scope walk, guardedipc.c:264ipc_ctx_handlereturn __VM ? __VM->ipc_handle : -1;ipc.c:268ipc_ctx_pollreturn __VM ? (ray_poll_t*)__VM->ipc_poll : NULL;ipc.c:272ipc_ctx_setif (!__VM) return;runtime.c:55ray_last_err_msgenv.c:296env_lookup_flatruntime.c:50-54states the intent directly:The IPC Client API block in
rayforce.halso documents no threadingrestriction, while the header is explicit wherever one exists
("main-thread only" for the progress/span APIs, "call from the thread
that owns poll" for listener servicing, "one writer handle per directory"
for AOF).
The teardown path is the practical trigger:
hook_call_lifecycle's very next statement isif (!fn) return;, so withno hook installed this is meant to be a no-op — the unguarded deref is the
only thing preventing that.
Fix
Skip the local scopes when there is no VM; globals must still resolve.
Matches
ray_env_get_local's existing style.This also covers the dotted walk, whose head segment resolves through the
same function (
env.c:340,:371).Known limitation, stated deliberately
This makes the no hook installed case safe, which is what the IPC
teardown path needs. If a hook is globally bound and the close happens
on a VM-less thread,
hook_lookupwill now find the lambda andcall_fn1will run without a VM. That is a separate question — whetherlifecycle hooks should run at all on such a thread, or whether dispatch
should bind a VM first — and I have not addressed it here rather than
guess at the intended semantics. Happy to follow up if you have a
preference.
Testing
Applied to a clean checkout of
devatc27fdc34:make lib -j8builds clean.vmprobelinked against it goes from exit 139 (SIGSEGV) to exit 0:worker: returned (nil) (no crash).byte-identical, only wrapped in
if (__VM).I could not run
make testto completion: it fails to compiletest/test_link.oon glibc 2.44 (Arch), wheretest/test_link.c:34redefines
_POSIX_C_SOURCEas200809Lover the202405Lthatfeatures.halready set, and-Werrorrejects it. That is apre-existing issue in a file this change does not touch, and it
reproduces independently of the patch — but it does mean I have not
verified the suite, and you may want to run it yourself before merging.
Checklist
dev(notmaster)makebuilds cleanly (no new warnings) —make libverified atc27fdc34make testpasses — could not run locally, see Testing above: thesuite fails to compile
test/test_link.oon glibc 2.44 for apre-existing reason unrelated to this change (verified on a pristine
unpatched checkout). No test added: this is a NULL guard on a path with
no existing thread-related coverage, and the repro above needs a second
thread. Happy to add one if you'd like it wired into the suite.