Fix MT use-after-free: module-env observer-slot growth vs unlocked reads (#694)#701
Merged
Conversation
…ads (#694) observer_slot_update_e grew env->obs with a bare realloc + non-atomic pointer store while holding no lock, and the report of / when is / predicate opcodes read a chain-resolved env's obs array outside any lock. A worker reading a MODULE binding's observer slot concurrently with the main thread first-observing a NEW module binding therefore walked a freed array. This is exactly the use-after-free class #607 fixed for names/values/ assign_counts — obs was simply never brought into that scheme. It survived because module obs grows only on a binding's FIRST observation, and only on the main thread, so the window is narrow enough never to have bitten. obs now follows the same discipline: - growth on a shared module env copies into a fresh block under g_module_env_lock and RETIRES the old one (freed at park/destroy) rather than freeing it, so a reader can never touch freed memory; - the new block is published with a release store, and only THEN the wider cap, also release; - readers go through a new env_obs_slot() accessor that loads cap first and pointer second, both acquire. That load/store ordering is load-bearing and commented as such: reading pointer-before-cap would admit new-cap-with-old-block, i.e. an out-of-bounds read of the retired array. Single-threaded runs and thread-local frame/loop envs keep the plain realloc and plain loads behind one predicted-false g_vm_multithreaded branch, so nothing off the MT path pays for this. Scope: same-slot value races between two threads writing one binding stay out of scope, exactly as #607 declared — retirement keeps both blocks alive, so that is slot-value semantics, not array memory safety. All 17 obs access sites converted (an initial survey found only 9 because the grep filtered out lines containing obs_cap). Validation: tests/test_obs_mt_race.eigs 5 TSan data races -> 0 (rc 66 -> 0) tests/test_tsan.sh 11 passed, 0 failed; seeded race still detected, so the gate is not vacuous release suite 3183/3183 ASan + detect_leaks=1 3187/3187, leak tally 0, [87] leak-clean Closes #694 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes a multithreaded use-after-free in the EigenScript runtime where the module environment’s env->obs observer-slot array could be realloc’d (freeing the old block) while worker threads concurrently read from the old array during report of / when is / predicate operations. The fix aligns obs growth with the existing #607 “retire-and-publish” scheme used for module-env structural arrays under MT.
Changes:
- Introduces
env_obs_slot()to safely readobs_cap+obswith acquire ordering, and migrates VM/JIT report/predicate paths to use it. - Updates
observer_slot_update_eto growenv->obsvia a newobserver_obs_grow()that retires old blocks and publishes pointer/cap with release ordering underg_module_env_lockfor shared module envs. - Adds a new TSAN regression (
test_obs_mt_race.eigs) and includes it in the “race-free slice” intests/test_tsan.sh.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/test_tsan.sh | Adds the new TSAN regression to the race-free concurrency slice. |
| tests/test_obs_mt_race.eigs | New regression program that reproduces the module-env obs grow vs read race under TSAN. |
| src/vm.c | Converts obs slot reads to go through env_obs_slot() for MT safety. |
| src/eigenscript.h | Adds env_obs_slot() accessor with cap/pointer acquire ordering. |
| src/eigenscript.c | Implements MT-safe obs growth with retire-and-publish semantics; routes observer updates through the accessor. |
| CHANGELOG.md | Documents the fix and the new TSAN regression. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
544
to
+547
| static void observer_slot_update_e(Env *e, int idx, double new_entropy) { | ||
| if (!e || idx < 0) return; | ||
| if (idx >= e->obs_cap) { | ||
| int ncap = idx + 8; | ||
| ObserverSlot *no = realloc(e->obs, (size_t)ncap * sizeof(ObserverSlot)); | ||
| if (!no) return; | ||
| memset(no + e->obs_cap, 0, (size_t)(ncap - e->obs_cap) * sizeof(ObserverSlot)); | ||
| e->obs = no; | ||
| e->obs_cap = ncap; | ||
| } | ||
| ObserverSlot *s = &e->obs[idx]; | ||
| if (idx >= e->obs_cap && !observer_obs_grow(e, idx)) return; | ||
| /* Acquire-load the (possibly just-republished) block rather than reading |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
observer_slot_update_egrewenv->obswith a barerealloc+ non-atomic pointer store while holding no lock, and thereport of/when is/ predicate opcodes read a chain-resolved env'sobsarray outside any lock. A worker reading a module binding's observer slot concurrently with the main thread first-observing a new module binding therefore walked a freed array.This is precisely the use-after-free class #607 fixed for
names/values/assign_counts—obswas simply never brought into that scheme. It survived because moduleobsgrows only on a binding's first observation, and only on the main thread, so the window is narrow enough that it never bit in practice.The fix
obsnow follows the same #607 discipline:realloc, no lockg_module_env_lockidx < e->obs_cap && e->obs[idx], unlockedenv_obs_slot()— cap acquire, then pointer acquireThe ordering is load-bearing, and is commented as such at both ends. Reading pointer-before-cap would admit new-cap-with-old-block — an out-of-bounds read of the retired array — which would have made the "fix" a different bug. The writer therefore publishes pointer-then-cap (release) and the reader loads cap-then-pointer (acquire), so a reader either sees the old cap (valid against both blocks, since the new one is a superset copy and the old one is retired) or the new cap (which by the release/acquire chain guarantees the new block is already visible).
Nothing off the MT path pays: single-threaded runs and thread-local frame/loop envs keep the plain realloc and plain loads behind one predicted-false
g_vm_multithreadedbranch, the same gating #607 uses.Scope
Same-slot value races — two threads writing one binding's slot — stay out of scope, exactly as #607 declared them ("slot-value semantics, not memory safety of the arrays"). Retirement keeps both blocks alive, so this is not a safety hole; I did not silently widen the claim. This PR is about array memory safety only.
All 17
obsaccess sites are converted. Worth flagging: an initial survey found only 9, because the grep pattern excluded lines containingobs_cap. The remaining raw accesses are the writer itself and teardown.Validation
New regression
tests/test_obs_mt_race.eigs— three workers spinningreport ofa module binding while the main thread first-observes 600 new module bindings — added totest_tsan.sh's race-free slice:test_obs_mt_race.eigswithout the fixobserver_slot_update_eandvm_run_ex, exactly as the issue predictedtest_obs_mt_race.eigswith the fixtests/test_tsan.shdetect_leaks=1The red-then-green pair is the point: the test is only worth having because it fails without the fix.
Closes #694
🤖 Generated with Claude Code