fix(ipc): bind a VM for lifecycle hooks on VM-less threads - #581
Conversation
Closes #569. __VM is bound only by ray_runtime_create, so any thread an embedder drives IPC from -- the FFI teardown path in particular -- has none. PR 565 made the *no hook installed* case safe by letting ray_env_get fall through to globals, and deliberately left this open: with a hook actually bound, call_fn1 would run user code with __VM == NULL. A hook is user code and should behave the same wherever the event came from, so dispatch now binds a VM for the duration. The alternatives were worse: skipping the hook is silent and invisible at the call site, and failing the close would break the FFI teardown that runtime.c:50-54 explicitly supports. Binding also restores `.ipc.handle` inside the hook. ipc_ctx_set stores through __VM and returns early without one, so on a VM-less thread the hook previously saw a handle of -1 even when it ran. ray_vm_t is ~68 KB, too large for a teardown-path stack, so the temporary comes from the buddy heap -- process-wide and already live, since the heap belongs to the runtime rather than the thread. Teardown mirrors ray_runtime_destroy: release raise_val and trace, then free. Grown scope frames are not walked, matching that same teardown. Threads that already have a VM pay one predicted branch and allocate nothing. Tests: ipc/close_hook_vmless_thread stands up a real server, connects, then drives ray_poll_deregister from a thread that never called ray_runtime_create. It asserts the hook ran and that it observed a real `.ipc.handle` rather than -1. Mutation-verified, and the mutant reproduces the reported hazard exactly: src/lang/eval.c:124:16: runtime error: member access within null pointer of type 'struct ray_vm_t' make test: 3899 of 3899 passed. fuzz-smoke clean.
Rayforce targeted audit passedThe required Rayforce audit gate passed on the latest run. Workflow run: https://github.com/RayforceDB/rayforce/actions/runs/35382931194 |
The audit was right and it corrects a claim I made in the PR description. .ipc.on.close is one process-global binding and ipc_on_close fires it for both ends of a connection, so the server thread -- which has a VM -- also incremented the counter and also set a valid handle. Both assertions were satisfied by that firing alone, so the test did not distinguish fixed from unfixed behaviour. It additionally read those globals through ray_eval_str while the server poll thread could still be writing them, which is an unsynchronised access to g_env. My "mutation-verified" claim was true only in a weaker sense than stated: the mutant was caught by UBSan aborting on the NULL deref at eval.c:124 (fn_is_restricted reading __VM->restricted), not by the assertions. In a build without UBSan, or whenever the server processed EOF first, the test would have passed with the fix reverted. Fixed by stopping the server before installing the hook. With no hook bound during shutdown there is no server-side firing, so the single firing afterwards is unambiguously the client-side teardown driven from the VM-less thread -- and the poll loop that was racing the reads is gone by then. The assertions are now exact: fired == 1, and the handle equals this connection's h rather than merely being non-negative. Re-verified with a mutant that does not crash, so only the assertions can catch it -- hook_vm_bind refusing to bind, i.e. the "skip silently" alternative: test/test_ipc.c:731: fired->i64 != 1 (got 0, expected 1) Also took the three non-blocking items: the OOM path now logs before skipping the hook, matching how this file reports every other hook failure; hook_call_auth and the sync/async dispatch carry a note saying they are unbound because they only run under ray_poll_run, so a future embedder-driven path knows it needs the same treatment; and the test registry entry is aligned with its neighbours. make test: 3900 of 3900 passed. fuzz-smoke clean.
|
Both blockers were correct, and the first one corrects a claim I made in the PR description. Fixed in 1. The test did not gate the bugYour reading is exactly right: And my "mutation-verified" claim was weaker than I stated. The mutant was caught, but by UBSan aborting on the NULL dereference at Fixed by stopping the server before installing the hook. With no hook bound during shutdown there is no server-side firing, so the single firing afterwards is unambiguously the client-side teardown driven from the VM-less thread. The assertions are now exact rather than existential — I did not use "assert Re-verified with a mutant that does not crash, so only the assertions can catch it — 2. The g_env raceAlso correct, and the same change resolves it: the server thread is joined and its poll destroyed before anything reads those globals. Your observation that fixing the race alone would have worsened finding 1 — by guaranteeing the server-side increment — is what made it clear the two had to be fixed together rather than in sequence. Non-blocking — all three takenSilent OOM skip. Now logs before skipping, matching how this file reports every other hook failure. Unbound hook paths. Alignment. Fixed. Thanks in particular for the "verified sound" section — the re-entrancy analysis and the point that
|
Closes #569. Follow-up to #565, which fixed the no hook installed case and deliberately left this one open.
What was wrong
__VMis bound only byray_runtime_create, so any thread an embedder drives IPC from has none. #565 letray_env_getfall through to globals on such a thread — which means that with a hook actually bound,hook_lookupfinds the lambda andcall_fn1runs user code with__VM == NULL.The mutation check reproduces it exactly:
Why bind rather than skip or refuse
Both alternatives were worse:
runtime.c:50-54, where a thread-local error shadow was added precisely so FFI callers on VM-less threads keep working.A hook is user code; it should behave the same wherever the event came from.
A second bug fixed by the same change
ipc_ctx_setstores through__VMand returns early without one. So on a VM-less thread the hook — when it ran at all — saw.ipc.handleas-1. Binding restores it, and the test asserts on that specifically rather than only on "the hook fired".Cost
ray_vm_tis ~68 KB, too large for a teardown-path stack, so the temporary comes from the buddy heap — process-wide and already live, since the heap belongs to the runtime rather than the thread.Threads that already have a VM pay one predicted branch and allocate nothing; the allocation happens only on threads that would previously have crashed. Teardown mirrors
ray_runtime_destroy— releaseraise_valandtrace, then free — including in not walking grown scope frames, which that teardown does not either.Tests
ipc/close_hook_vmless_threadstands up a real server via the shared harness, connects, then drivesray_poll_deregisterfrom a thread that never calledray_runtime_create— the shape of an embedder's own teardown, which is how this was found.It asserts both that the hook ran and that it observed a real
.ipc.handle. Mutation-verified against the pre-fix behaviour, which produces the UBSan error quoted above rather than a clean failure.make test: 3899 of 3899 passed (0 skipped, 0 failed).make fuzz-smokeclean.