diff --git a/CHANGELOG.md b/CHANGELOG.md index a12b047b..c9a8d15d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,25 @@ All notable changes to EigenScript are documented here. ### Fixed +- **Memory corruption: values escaping an `arena_mark`…`arena_reset` + scope (#873).** `promote_if_arena` copied only numbers and strings to + the heap on store; a LIST escaping the scope became a dangling + reference into memory the next `arena_mark` handed back out — silent + wrong values, type confusion, and (via `append` into a heap list) a + `free(): invalid pointer` abort, all reachable from pure EigenScript + and invisible to ASan. Fixed at every store seam: `promote_if_arena` + now deep-promotes lists (recursively; lists are the only + arena-capable container), and the append / indexed-store / + `OP_SET_LOCAL` / `set_at` / `list_insert_at` / `copy_into` paths + promote arena values landing in heap containers (num fast paths + heap-force under an open window). The JIT is gated off while an arena + window is open — entry, OSR, and a deep-bail when a builtin opens one + mid-thunk — because emitted stores don't promote; arena scopes run + interpreted. Escaping a stored value is now *documented, safe + behavior*: the arena reclaims only unstored intermediates. + `tests/test_arena_escape.eigs` pins all seams under a stomp loop that + overwrites the reclaimed region, plus an OSR-threshold hot variant. + - **[62] audio capture no longer flakes on an opened-but-silent device (#876).** A real capture device that opens but delivers no samples in the bounded poll (suspended/held source) failed two checks — and the diff --git a/README.md b/README.md index b430f5dd..88c8b6a7 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,10 @@ arena_mark of null # save allocation point arena_reset of null # reclaim all transient allocations ``` -Bounded computation for constrained environments. +Bounded computation for constrained environments. Values that escape the +scope are safe: anything stored into a binding or a container that +outlives the window is promoted to the heap at the store (#873) — the +arena reclaims only the unstored intermediates. ## Standard Library diff --git a/src/builtins.c b/src/builtins.c index b9391b9f..aeb83bed 100644 --- a/src/builtins.c +++ b/src/builtins.c @@ -3089,9 +3089,19 @@ Value* builtin_copy_into(Value *arg) { if (!dest || dest->type != VAL_LIST || !src || src->type != VAL_LIST) return make_null(); if (offset < 0) return make_null(); for (int i = 0; i < src->data.list.count && offset + i < dest->data.list.count; i++) { - val_incref(src->data.list.items[i]); + Value *item = src->data.list.items[i]; + /* #873: promote arena items landing in a heap destination. */ + if (item && item->arena && !dest->arena) { + Value *promoted = promote_if_arena(item); + if (promoted != item) { + val_decref(dest->data.list.items[offset + i]); + dest->data.list.items[offset + i] = promoted; + continue; + } + } + val_incref(item); val_decref(dest->data.list.items[offset + i]); - dest->data.list.items[offset + i] = src->data.list.items[i]; + dest->data.list.items[offset + i] = item; } return dest; } @@ -3303,6 +3313,15 @@ Value* builtin_set_at(Value *arg) { int idx; if (!at_index(arg->data.list.items[1], list->data.list.count, "set_at", &idx)) return make_null(); + /* #873: promote an arena value stored into a heap list. */ + if (val && val->arena && !list->arena) { + Value *promoted = promote_if_arena(val); + if (promoted != val) { + val_decref(list->data.list.items[idx]); + list->data.list.items[idx] = promoted; + return list; + } + } val_incref(val); val_decref(list->data.list.items[idx]); list->data.list.items[idx] = val; @@ -3327,6 +3346,15 @@ Value* builtin_set_at(Value *arg) { int col; if (!at_index(arg->data.list.items[2], rowv->data.list.count, "set_at col", &col)) return make_null(); + /* #873: promote an arena value stored into a heap row. */ + if (val && val->arena && !rowv->arena) { + Value *promoted = promote_if_arena(val); + if (promoted != val) { + val_decref(rowv->data.list.items[col]); + rowv->data.list.items[col] = promoted; + return list; + } + } val_incref(val); val_decref(rowv->data.list.items[col]); rowv->data.list.items[col] = val; @@ -5452,6 +5480,15 @@ Value* builtin_list_insert_at(Value *arg) { list_append(list, old_last); memmove(&list->data.list.items[idx + 1], &list->data.list.items[idx], (count - idx) * sizeof(Value *)); + /* #873: promote an arena value inserted into a heap list. */ + if (val && val->arena && !list->arena) { + Value *promoted = promote_if_arena(val); + if (promoted != val) { + list->data.list.items[idx] = promoted; + val_decref(old_last); + return list; + } + } val_incref(val); list->data.list.items[idx] = val; val_decref(old_last); diff --git a/src/eigenscript.c b/src/eigenscript.c index 5c6e4181..419bd44e 100644 --- a/src/eigenscript.c +++ b/src/eigenscript.c @@ -1104,8 +1104,33 @@ Value* promote_if_arena(Value *v) { * and a heap VAL_NULL leaks via slot_bridge_wrap's pointer-drop. */ return v; } - /* Lists, dicts, functions: leave as-is (complex deep copy). - * Callers should avoid storing arena-allocated complex types. */ + if (v->type == VAL_LIST) { + /* #873: an arena list stored into a binding or heap container + * outlives arena_reset as a dangling reference — silent wrong + * values, type confusion, even free() aborts when a decref + * walks the stale pointer. Deep-promote instead: a fresh heap + * list; arena children promote recursively (fresh rc=1, + * adopted), heap children are shared (incref'd). Arena lists + * are acyclic at promotion time — building a cycle requires + * mutating through a binding, and binding stores promote — so + * the recursion terminates. Aliasing between two references to + * the same UNBOUND arena temporary is not preserved (each + * promotes to its own copy); observing that would require a + * binding, which promotes. Lists are the only arena-capable + * container: make_dict/make_fn/buffers/text builders are + * heap-only constructors and never carry v->arena. */ + Value *h = make_list_heap(v->data.list.count); + for (int i = 0; i < v->data.list.count; i++) { + Value *c = v->data.list.items[i]; + Value *pc = promote_if_arena(c); + if (pc == c) val_incref(pc); + h->data.list.items[i] = pc; + } + h->data.list.count = v->data.list.count; + return h; + } + /* Remaining types (dict/fn/builtin/buffer/text builder) are + * heap-only at construction; an arena flag on one is unreachable. */ return v; } @@ -1420,6 +1445,19 @@ void list_append(Value *list, Value *item) { } list->data.list.capacity = new_cap; } + /* #873: an arena item appended into a HEAP list dangles after + * arena_reset (the abort repro: decref of the stale pointer corrupts + * the allocator). Promote on the way in — same contract as the env + * and dict store paths. Arena-into-arena stays raw (both die at + * reset), heap-into-arena keeps the documented leak-side sharp edge + * (test_arena_ownership). */ + if (__builtin_expect(item && item->arena && !list->arena, 0)) { + Value *promoted = promote_if_arena(item); + if (promoted != item) { + list->data.list.items[list->data.list.count++] = promoted; + return; + } + } list->data.list.items[list->data.list.count++] = item; val_incref(item); } diff --git a/src/eigenscript.h b/src/eigenscript.h index a29b730f..ac04b5e2 100644 --- a/src/eigenscript.h +++ b/src/eigenscript.h @@ -913,6 +913,7 @@ void free_weight_val(Value *v); Value* make_num(double n); Value* promote_if_arena(Value *v); +Value* make_num_permanent(double n); /* heap-only make_num (#873 store paths) */ void recycle_intermediate(Value *v); Value* make_str(const char *s); Value* make_str_owned(char *s); diff --git a/src/vm.c b/src/vm.c index e927a8ab..93c3edd8 100644 --- a/src/vm.c +++ b/src/vm.c @@ -1462,7 +1462,11 @@ int jit_helper_iter_next(void) { idx_v->data.num = (double)(idx + 1); } else { val_decref(idx_v); - state->data.list.items[1] = make_num(idx + 1); + /* #873: heap-force when the state list must outlive an active + * arena window (mark left open across the loop back-edge). */ + state->data.list.items[1] = + (g_arena.active && !state->arena) ? make_num_permanent(idx + 1) + : make_num(idx + 1); } vm_push(elem); return 0; @@ -1811,7 +1815,13 @@ void jit_helper_index_set(void) { existing->data.num = val_s.d; } else { val_decref(existing); - target->data.list.items[i] = make_num(val_s.d); + /* #873: inside an arena window a plain make_num is + * arena-backed — stored into a HEAP list it dangles + * after arena_reset. Heap-force for heap targets. */ + target->data.list.items[i] = + (g_arena.active && !target->arena) + ? make_num_permanent(val_s.d) + : make_num(val_s.d); } } else { if (!_ok) rt_error(EK_VALUE, g_vm.current_line, "index must be an integer, got %g", idx_s.d); @@ -1831,9 +1841,20 @@ void jit_helper_index_set(void) { if (!vm_index_is_int(idx->data.num, &i)) { rt_error(EK_VALUE, g_vm.current_line, "index must be an integer, got %g", idx->data.num); } else if (vm_index_resolve(&i, target->data.list.count)) { + /* #873: promote an arena value stored into a heap list — + * same contract as list_append / the env store paths. */ + if (__builtin_expect(val->arena && !target->arena, 0)) { + Value *promoted = promote_if_arena(val); + if (promoted != val) { + val_decref(target->data.list.items[i]); + target->data.list.items[i] = promoted; + goto _idx_assign_stored; + } + } val_incref(val); val_decref(target->data.list.items[i]); target->data.list.items[i] = val; + _idx_assign_stored:; } else { rt_error(EK_INDEX, g_vm.current_line, "index %d out of range (list length %d)", i, target->data.list.count); } @@ -2119,6 +2140,7 @@ int jit_helper_call(EigsChunk *caller_chunk, int argc, int resume_off) { return 0; if (fn_chunk->jit_state != 2 || !fn_chunk->jit_code) return 1; if (g_task_sched) return 1; /* #533: task code runs interpreted */ + if (g_arena.active) return 1; /* #873: arena scopes run interpreted */ /* #728: a running thunk can flip the flag mid-flight (spawn is a * builtin, reachable via the VAL_BUILTIN path below) — after that, * don't enter NESTED thunks: their epilogues write the shared @@ -2147,7 +2169,7 @@ int jit_helper_call(EigsChunk *caller_chunk, int argc, int resume_off) { env_rebind_param_slot(call_env, 0, g_vm.stack[g_vm.sp - 1]); } else { - Value *arg_list = make_list(argc); + Value *arg_list = make_list_heap(argc); /* #873: bound as a param slot — heap so it cannot dangle (and no deep-promote cost) */ for (int i = 0; i < argc; i++) list_append(arg_list, STK_AS_VAL(g_vm.sp - argc + i)); env_rebind_param_slot(call_env, 0, @@ -2177,7 +2199,7 @@ int jit_helper_call(EigsChunk *caller_chunk, int argc, int resume_off) { fn_val->data.fn.params[0], ph, g_vm.stack[g_vm.sp - 1]); } else { - Value *arg_list = make_list(argc); + Value *arg_list = make_list_heap(argc); /* #873: bound as a param slot — heap so it cannot dangle (and no deep-promote cost) */ for (int i = 0; i < argc; i++) list_append(arg_list, STK_AS_VAL(g_vm.sp - argc + i)); vm_bind_fresh_param(call_env, 0, @@ -2276,6 +2298,15 @@ int jit_helper_call(EigsChunk *caller_chunk, int argc, int resume_off) { } if (!consumes_arg && result != arg) val_decref(arg); vm_push(result); + if (__builtin_expect(g_arena.active, 0)) { + /* #873: the builtin just opened an arena window (arena_mark). + * The caller thunk's inline stores don't arena-promote, so hand + * the rest of this chunk to the interpreter. The call completed + * and the top frame is the caller — fix its ip past the CALL + * and deep-bail, mirroring the g_has_error path above. */ + g_vm.frames[g_vm.frame_count - 1].ip = caller_chunk->code + resume_off; + return 2; + } return 0; } @@ -3124,9 +3155,26 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { } } } + /* #873: promote an arena value before it lands in an env + * slot — module-chunk slots are immortal and fn-local slots + * can outlive the arena window via parked envs/closures. + * Same incref/arena-promotion contract as + * env_bind_fresh_param_slot / env_rebind_param_slot. */ + if (__builtin_expect(slot_is_ptr(tos), 0)) { + Value *tv = slot_as_ptr(tos); + if (__builtin_expect(tv && tv->arena, 0)) { + Value *promoted = promote_if_arena(tv); + if (promoted && promoted != tv) { + slot_decref(e->values[slot]); + e->values[slot] = slot_from_value(promoted); + goto _set_local_arena_done; + } + } + } slot_incref(tos); slot_decref(e->values[slot]); e->values[slot] = tos; + _set_local_arena_done:; } else { /* #348: an out-of-range slot used to DROP the write silently * (the assigned variable simply never existed). Compiler-emitted @@ -3352,8 +3400,10 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { * check: a blocking task_recv's placeholder null flowed on as the * received message and the leaked suspend flag fired at a random * later call site — state corruption after ~OSR-threshold - * iterations (first seen as liferaft node tasks dying en masse). */ - if (!g_vm_multithreaded && !g_task_sched) { + * iterations (first seen as liferaft node tasks dying en masse). + * #873: nor inside an open arena window — thunk stores don't + * arena-promote (see the fresh-entry gate). */ + if (!g_vm_multithreaded && !g_task_sched && !g_arena.active) { chunk->back_edge_count++; /* OSR trigger. For "called once, loops a lot" chunks (the gauntlet @@ -3619,7 +3669,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { env_rebind_param_slot(call_env, 0, g_vm.stack[g_vm.sp - 1]); } else { - Value *arg_list = make_list(argc); + Value *arg_list = make_list_heap(argc); /* #873: bound as a param slot — heap so it cannot dangle (and no deep-promote cost) */ for (int i = 0; i < argc; i++) list_append(arg_list, STK_AS_VAL(g_vm.sp - argc + i)); env_rebind_param_slot(call_env, 0, @@ -3653,7 +3703,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { fn_val->data.fn.params[0], ph, g_vm.stack[g_vm.sp - 1]); } else { - Value *arg_list = make_list(argc); + Value *arg_list = make_list_heap(argc); /* #873: bound as a param slot — heap so it cannot dangle (and no deep-promote cost) */ for (int i = 0; i < argc; i++) list_append(arg_list, STK_AS_VAL(g_vm.sp - argc + i)); vm_bind_fresh_param(call_env, 0, @@ -3723,7 +3773,10 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { * write/write on both (TSan gate: test_spawn_jit_warm.eigs). * Workers interpret shared chunks instead, matching the OSR * gate at CASE(JUMP_BACK). */ - if (fn_chunk->jit_code && !g_vm_multithreaded && !g_task_sched) { + /* #873: nor inside an open arena window — emitted stores + * don't arena-promote; arena scopes run interpreted. */ + if (fn_chunk->jit_code && !g_vm_multithreaded && !g_task_sched && + !g_arena.active) { ((JitChunkFn)fn_chunk->jit_code)(); if (fn_chunk->jit_advance == -1) { /* jit_helper_return popped fn_chunk's frame but left @@ -4017,7 +4070,13 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { existing->data.num = val_s.d; } else { val_decref(existing); - target->data.list.items[i] = make_num(val_s.d); + /* #873: heap-force into heap targets while an + * arena window is open (mirror of + * jit_helper_index_set). */ + target->data.list.items[i] = + (g_arena.active && !target->arena) + ? make_num_permanent(val_s.d) + : make_num(val_s.d); } } else { if (!_ok) rt_error(EK_VALUE, current_line, "index must be an integer, got %g", idx_s.d); @@ -4037,9 +4096,20 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { if (!vm_index_is_int(idx->data.num, &i)) { rt_error(EK_VALUE, current_line, "index must be an integer, got %g", idx->data.num); } else if (vm_index_resolve(&i, target->data.list.count)) { + /* #873: promote an arena value stored into a heap list + * (mirror of jit_helper_index_set's general arm). */ + if (__builtin_expect(val->arena && !target->arena, 0)) { + Value *promoted = promote_if_arena(val); + if (promoted != val) { + val_decref(target->data.list.items[i]); + target->data.list.items[i] = promoted; + goto _interp_idx_stored; + } + } val_incref(val); val_decref(target->data.list.items[i]); target->data.list.items[i] = val; + _interp_idx_stored:; } else { rt_error(EK_INDEX, current_line, "index %d out of range (list length %d)", i, target->data.list.count); } @@ -4388,7 +4458,10 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { idx_v->data.num = (double)(idx + 1); } else { val_decref(idx_v); - state->data.list.items[1] = make_num(idx + 1); + state->data.list.items[1] = + (g_arena.active && !state->arena) + ? make_num_permanent(idx + 1) /* #873 */ + : make_num(idx + 1); } if (iterable->type == VAL_BUFFER) { /* Push number immediate directly — skip make_num + immediate- @@ -5617,7 +5690,8 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { * #728 MT gate — see the OP_CALL hook). */ if (fn_chunk->jit_state == 0 && !g_vm_multithreaded && !g_task_sched) jit_try_compile_chunk(fn_chunk); - if (fn_chunk->jit_code && !g_vm_multithreaded && !g_task_sched) { + if (fn_chunk->jit_code && !g_vm_multithreaded && !g_task_sched && + !g_arena.active) { /* #873: see the OP_CALL hook */ ((JitChunkFn)fn_chunk->jit_code)(); if (fn_chunk->jit_advance == -1) { /* Stage 4s: OP_RETURN sentinel — see OP_CALL hook. diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index e217cf3f..336c16b7 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -688,6 +688,13 @@ check "AO3 tensor save/load roundtrip" "$AO3_V" "21" AO4_C=$(echo "$AO_OUTPUT" | grep -A1 'AO4:' | tail -1) check "AO4 num_copy new local survives reset" "$AO4_C" "99.5" +# #873: values escaping an arena_mark…arena_reset scope must deep-promote +# at every store seam (binding, local slot, dict field, append, indexed +# store, set_at/insert_at/copy_into) — a stomp loop overwrites the +# reclaimed region so a dangling reference reads WRONG, not lucky. +check_eigs_suite "arena escape containment (#873 — list deep-promote at every store seam)" \ + "test_arena_escape.eigs" "All tests passed" 18 + check_eigs_suite "reduction builtins dot/sum/norm (vs explicit loop + edge cases)" \ "test_dot.eigs" "DOT_OK" 1 echo "" diff --git a/tests/test_arena_escape.eigs b/tests/test_arena_escape.eigs new file mode 100644 index 00000000..a98f107a --- /dev/null +++ b/tests/test_arena_escape.eigs @@ -0,0 +1,131 @@ +# ============================================================ +# #873 — values escaping an arena_mark…arena_reset scope +# ============================================================ +# promote_if_arena copied only numbers and strings to the heap on store; +# a LIST escaping the scope became a dangling reference into memory the +# next arena_mark handed back out — silent wrong values, type confusion, +# and (through the append seam) free() aborts. These tests pin every +# store seam: named binding, local slot, dict field, heap-list append, +# heap-list indexed store — plus the two repros from the issue, under a +# deliberate stomp loop that overwrites the reclaimed region. + +load_file of "lib/test.eigs" + +define _stomp() as: + arena_mark of null + local j1 is [8, 8, 8, 8, 8, 8, 8, 8] + local j2 is "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + local j3 is 111111.0 + arena_reset of null + return null + +# ---- Issue repro 1: the documented loop shape, one value escaping ---- +escaped is null +i is 0 +loop while i < 40: + arena_mark of null + tmp is [100 + i, 200 + i, 300 + i] + if i == 0: + escaped is tmp + scratch is [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16] + arena_reset of null + i is i + 1 +assert_eq of [escaped[0], 100, "#873 escaped list keeps iteration-0 values (was iteration 39's)"] +assert_eq of [escaped[1], 200, "#873 escaped list element 1 intact"] +assert_eq of [escaped[2], 300, "#873 escaped list element 2 intact"] + +# ---- Issue repro 2: type confusion (tag overwritten) ---- +escaped2 is null +i2 is 0 +loop while i2 < 80: + arena_mark of null + if i2 == 0: + escaped2 is [11, 22, 33, 44, 55, 66, 77, 88] + else: + s2 is "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + n2 is 1.5 + arena_reset of null + i2 is i2 + 1 +assert_eq of [type of escaped2, "list", "#873 escaped binding keeps its TYPE"] +assert_eq of [escaped2[7], 88, "#873 escaped list content intact after 79 stomp scopes"] + +# ---- Nested list escape (recursive promotion) ---- +nested is null +arena_mark of null +nested is [[1, 2], [3, [4, 5]]] +arena_reset of null +_stomp of null +assert_eq of [nested[1][1][1], 5, "#873 nested arena lists promote recursively"] + +# ---- Append seam: arena values into a HEAP list (was free() abort) ---- +ys is [] +arena_mark of null +append of [ys, 77] +append of [ys, [6, 7, 8]] +arena_reset of null +_stomp of null +assert_eq of [ys[0], 77, "#873 arena num appended into a heap list survives reset"] +assert_eq of [ys[1][2], 8, "#873 arena list appended into a heap list survives reset"] + +# ---- Indexed-store seam: xs[i] is ---- +xs is [1, 2, 3] +arena_mark of null +xs[0] is 42 +xs[1] is [9, 9] +arena_reset of null +_stomp of null +assert_eq of [xs[0], 42, "#873 indexed num store into a heap list survives reset"] +assert_eq of [xs[1][0], 9, "#873 indexed list store into a heap list survives reset"] + +# ---- Dict-field seam ---- +d is {} +arena_mark of null +d.k is [5, 6] +arena_reset of null +_stomp of null +assert_eq of [d.k[1], 6, "#873 arena list stored into a dict field survives reset"] + +# ---- Builtin store seams: set_at (1D + 2D), insert_at, copy_into ---- +sa is [0, 0, 0] +grid_l is [[0, 0], [0, 0]] +ia is [1, 3] +ci is [0, 0, 0, 0] +arena_mark of null +set_at of [sa, 1, [4, 5]] +set_at of [grid_l, 1, 0, [6, 7]] +list_insert_at of [ia, 1, [2, 2]] +copy_into of [ci, 1, [[3, 3], 4]] +arena_reset of null +_stomp of null +assert_eq of [sa[1][1], 5, "#873 set_at 1D promotes an arena value into a heap list"] +assert_eq of [grid_l[1][0][1], 7, "#873 set_at 2D promotes into a heap row"] +assert_eq of [ia[1][0], 2, "#873 insert_at promotes an arena value into a heap list"] +assert_eq of [ci[1][0], 3, "#873 copy_into promotes arena items into a heap destination"] +assert_eq of [ci[2], 4, "#873 copy_into promotes arena nums too"] + +# ---- Function-local slot seam (returned after two stomp scopes) ---- +define f_local(k) as: + arena_mark of null + local ll is [k, k + 1] + arena_reset of null + _stomp of null + return ll[0] + +assert_eq of [f_local of 3, 3, "#873 fn-local arena list promotes at the slot store"] + +# ---- Hot-loop variant: same shape run enough to cross JIT thresholds. +# The JIT's inline stores don't arena-promote, so JIT entry/OSR are gated +# off while an arena window is open (#873); this loop proves the gated +# path stays correct at OSR-triggering iteration counts. +hot_escaped is null +hi is 0 +loop while hi < 12000: + arena_mark of null + htmp is [1000 + hi, hi] + if hi == 0: + hot_escaped is htmp + arena_reset of null + hi is hi + 1 +assert_eq of [hot_escaped[0], 1000, "#873 escape stays correct across OSR-threshold iterations"] + +test_summary of null