diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d85a33..afc6844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -207,6 +207,20 @@ All notable changes to EigenScript are documented here. (the one legitimate empty-expression position) still yields `null` (#494). ### Fixed +- **JIT: task code now stays interpreted at EVERY entry point (#533).** The + #408 ruling ("task code runs interpreted") was enforced only at the + fresh-entry gate: the OSR back-edge counter/handoff and the OP_CALL / + OP_DISPATCH thunk entries weren't task-gated, so a task's hot loop was + JIT-compiled MID-TASK after ~OSR-threshold iterations — and + `jit_helper_call` has no suspend check, so a blocking `task_recv`'s + placeholder null flowed onward as the received message and the leaked + suspend flag suspended the task mid-expression at a later call site. + Symptom: mass task death / `null` from `task_recv` / corrupted locals + after ~5000 loop iterations (first seen as liferaft's node tasks dying en + masse under chaos — reachable at all only once #530 lifted the lifetime + spawn cap). All four entry points now check `g_task_sched`; + regression-pinned with an OSR-threshold-crossing recv loop + (`tests/test_task_osr.eigs`, red pre-fix / green post-fix). - **`dict_remove` no longer inflates the dict's hash table exponentially.** The re-index rebuild after a removal reused the grow path's blind capacity-doubling, so N removes on one dict grew its table by 2^N — diff --git a/src/vm.c b/src/vm.c index 030bc63..94ba669 100644 --- a/src/vm.c +++ b/src/vm.c @@ -1702,6 +1702,7 @@ int jit_helper_call(EigsChunk *caller_chunk, int argc, int resume_off) { !g_trace_hist && vm_leaf_accessor_exec(fn_chunk, argc)) 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_vm.frame_count >= VM_FRAMES_MAX) return 1; if (g_native_call_depth >= JIT_NATIVE_CALL_DEPTH_MAX) return 1; @@ -2913,8 +2914,16 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { * per-chunk JIT state. Compilation is already gated off under MT * (#296); skip the counter and the OSR slot scan/execute too while * multithreaded so parallel workers running the same chunk don't race - * on back_edge_count / jit_osr (they interpret hot loops instead). */ - if (!g_vm_multithreaded) { + * on back_edge_count / jit_osr (they interpret hot loops instead). + * #533: ALSO skip while a cooperative task scheduler is active — the + * #408 ruling is that task code runs interpreted, but only the + * fresh-entry gate enforced it. A task loop crossing the OSR + * threshold was JIT'd MID-TASK, and jit_helper_call has no suspend + * 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) { chunk->back_edge_count++; /* OSR trigger. For "called once, loops a lot" chunks (the gauntlet @@ -3295,8 +3304,12 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { * the caller's stack. Resync to the (now-current) caller * frame or, if we've fallen below base_frame, hand the * result back to C. */ - if (fn_chunk->jit_state == 0) jit_try_compile_chunk(fn_chunk); - if (fn_chunk->jit_code) { + if (fn_chunk->jit_state == 0 && !g_task_sched) jit_try_compile_chunk(fn_chunk); + /* #533: never enter a thunk while the task scheduler is active, + * even one compiled before the first spawn — task code runs + * interpreted (suspension points exist only in the interpreter + * loop's builtin-call check). */ + if (fn_chunk->jit_code && !g_task_sched) { ((JitChunkFn)fn_chunk->jit_code)(); if (fn_chunk->jit_advance == -1) { /* jit_helper_return popped fn_chunk's frame but left @@ -5043,9 +5056,9 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { frame->call_argc = 1; fn_chunk->exec_count++; - /* JIT hook (mirror of OP_CALL bytecode-fn path). */ - if (fn_chunk->jit_state == 0) jit_try_compile_chunk(fn_chunk); - if (fn_chunk->jit_code) { + /* JIT hook (mirror of OP_CALL bytecode-fn path; #533 task gate). */ + if (fn_chunk->jit_state == 0 && !g_task_sched) jit_try_compile_chunk(fn_chunk); + if (fn_chunk->jit_code && !g_task_sched) { ((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 5d93a26..a62315a 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -2173,6 +2173,10 @@ check_eigs_suite "worker arena return deep-copied before detach" test_spawn_aren # #408 cooperative task layer: spawn/alive/yield/join/deadlock + leak-clean # teardown of suspended/killed tasks (incl. heap-on-saved-stack + arena-dier). check_eigs_suite "cooperative tasks: yield/join/deadlock/teardown (#408)" test_tasks.eigs "All tests passed" 1 +# #533: task loops must stay interpreted past the OSR threshold (lowered here +# so the recv loop crosses it fast) — a mid-task OSR compile made task_recv +# return its placeholder null and corrupted the task at the next call site. +EIGS_JIT_OSR_THRESHOLD=20 check_eigs_suite "task loops stay interpreted past the OSR threshold (#533)" test_task_osr.eigs "task-osr: all passed" 1 # lib/sync — cooperative-task lock gives mutual exclusion across yield points # (#488): unlocked non-atomic RMW loses updates, the lock closes the race, diff --git a/tests/test_task_osr.eigs b/tests/test_task_osr.eigs new file mode 100644 index 0000000..43a4662 --- /dev/null +++ b/tests/test_task_osr.eigs @@ -0,0 +1,30 @@ +# #533: a task's hot loop must NOT be OSR-compiled mid-task — task code runs +# interpreted (the #408 ruling). Pre-fix, once a recv-loop crossed the OSR +# threshold it was JIT'd mid-task and the next blocking task_recv went through +# jit_helper_call (which has no suspend check): the placeholder null flowed +# onward as the "received" message and the leaked suspend flag corrupted the +# task at a later call site. The suite runs this with EIGS_JIT_OSR_THRESHOLD=20 +# so the loops cross the threshold quickly: every received message must still +# be a real string, all 200 of them. +define worker() as: + local n is 0 + loop while 1: + local m is task_recv of null + if m == "stop": + return n + if (type of m) != "str": + return 0 - 1 + n is n + 1 + +w is task_spawn of worker +i is 0 +loop while i < 200: + task_send of [w, f"msg{i}"] + task_yield of null + i is i + 1 +task_send of [w, "stop"] +got is task_join of w +if got == 200: + print of "task-osr: all passed" +else: + print of f"task-osr: FAILED got={got}"