Skip to content

Fix MT use-after-free: module-env observer-slot growth vs unlocked reads (#694)#701

Merged
InauguralPhysicist merged 1 commit into
mainfrom
fix-obs-mt-race-694
Jul 25, 2026
Merged

Fix MT use-after-free: module-env observer-slot growth vs unlocked reads (#694)#701
InauguralPhysicist merged 1 commit into
mainfrom
fix-obs-mt-race-694

Conversation

@InauguralPhysicist

Copy link
Copy Markdown
Collaborator

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 precisely the use-after-free class #607 fixed for names/values/assign_countsobs 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 that it never bit in practice.

The fix

obs now follows the same #607 discipline:

before after
grow (shared module env) bare realloc, no lock copy into a fresh block under g_module_env_lock
old block freed immediately retired on the env, freed at park/destroy
publish plain store pointer release-store, then cap release-store
read idx < e->obs_cap && e->obs[idx], unlocked env_obs_slot() — cap acquire, then pointer acquire
single-threaded / frame envs plain realloc unchanged, behind one predicted-false branch

The 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_multithreaded branch, 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 obs access sites are converted. Worth flagging: an initial survey found only 9, because the grep pattern excluded lines containing obs_cap. The remaining raw accesses are the writer itself and teardown.

Validation

New regression tests/test_obs_mt_race.eigs — three workers spinning report of a module binding while the main thread first-observes 600 new module bindings — added to test_tsan.sh's race-free slice:

gate result
test_obs_mt_race.eigs without the fix 5 TSan data races, rc 66 — at observer_slot_update_e and vm_run_ex, exactly as the issue predicted
test_obs_mt_race.eigs with the fix 0 races, rc 0
tests/test_tsan.sh 11 passed, 0 failed — and the seeded race is still detected, so the gate has not rotted into a vacuous pass
release suite 3183/3183
ASan + detect_leaks=1 3187/3187, leak tally 0, section [87] leak-clean

The 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

…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>
Copilot AI review requested due to automatic review settings July 25, 2026 02:24

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 read obs_cap + obs with acquire ordering, and migrates VM/JIT report/predicate paths to use it.
  • Updates observer_slot_update_e to grow env->obs via a new observer_obs_grow() that retires old blocks and publishes pointer/cap with release ordering under g_module_env_lock for shared module envs.
  • Adds a new TSAN regression (test_obs_mt_race.eigs) and includes it in the “race-free slice” in tests/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 thread src/eigenscript.c
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
@InauguralPhysicist
InauguralPhysicist merged commit 5d832a9 into main Jul 25, 2026
19 checks passed
@InauguralPhysicist
InauguralPhysicist deleted the fix-obs-mt-race-694 branch July 25, 2026 02:35
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.

MT race: observer_slot_update_e grows env->obs with an unlocked realloc while readers walk it

2 participants