diff --git a/CHANGELOG.md b/CHANGELOG.md index e914d94..8e70c5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,13 @@ All notable changes to EigenScript are documented here. inside an `arena_mark`…`arena_reset` scope or a nested evaluation is a `value` error. Not yet: mailboxes/`task_send` (increment 2), virtual time, the pluggable seeded-strategy hook for the DST consumer. + Increment 1c hardening: adversarial valgrind/ASan planted-fault + verification proved the arena guard and the save-buffer teardown are + load-bearing, and surfaced+fixed a real bug — a task ending inside an + active arena scope arena-allocated its error dict (which crosses to the + joiner) and left the arena active for the next task; `sched_finish` now + forces the arena inactive. Determinism is a suite gate (two-run + byte-identical), and SPEC gains an executable Tasks section. - **E003 is scope-precise (#404, increment two)** — the undefined-name pass models the runtime's real scope rules instead of a whole-file binding set: function-locals (fresh-name `is` and `local`) are diff --git a/docs/SPEC.md b/docs/SPEC.md index f83b8a8..cde8de1 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1126,6 +1126,47 @@ print of (thread_join of h) 9 ``` +## Cooperative tasks + +`task_spawn` creates a **cooperative task** on the single interpreter +thread (unlike `spawn`, which uses an OS thread). Tasks are scheduled +round-robin: a task runs until it calls `task_yield of null`, which +hands control to the next ready task; it resumes where it left off. +Because there is one thread and a fixed policy, the interleaving is +**deterministic by construction** — the same program prints identically +on every run, with no threads and no clock. Args and results cross the +task boundary deep-copied (share-nothing, like channel sends). + +`task_join of id` blocks until task `id` finishes and returns its +result (or re-raises its uncaught error as the same `{kind, message, +line}` dict — see [Error handling](#error-handling)). `task_alive of +id` is 1 until the task finishes. + +```eigenscript +order is [] +define step(tag) as: + order is append of [order, f"{tag}-1"] + task_yield of null + order is append of [order, f"{tag}-2"] + return tag + +a is task_spawn of [step, "a"] +b is task_spawn of [step, "b"] +ra is task_join of a +rb is task_join of b +print of order +print of f"{ra}{rb}" +``` +```output +["a-1", "b-1", "a-2", "b-2"] +ab +``` + +When the main program returns, any tasks still running are torn down +(the program ends). If every task is blocked with none runnable — for +example two tasks each `task_join`-ing the other — that is a `deadlock` +error, reported loudly rather than hanging. + ## Buffers `buffer of count` allocates a flat array of `count` nums (all 0). diff --git a/src/vm.c b/src/vm.c index 7f279b1..6af3196 100644 --- a/src/vm.c +++ b/src/vm.c @@ -5261,6 +5261,15 @@ static Value *task_start(Task *t) { * blocked on it. `r` is the value vm_run returned (NULL on suspend — not this * path). g_has_error distinguishes a normal end from an uncaught error. */ static void sched_finish(TaskScheduler *s, Task *t, Value *r) { + /* A task that ended (returned OR died) while its per-thread arena is still + * active — arena_mark with no matching arena_reset, e.g. the arena-suspend + * guard raised inside the scope — must not leave the arena active: (1) its + * error dict / result below outlive the task and cross to the joiner, so + * they must be heap, not arena (a later arena_reset would dangle them); and + * (2) the next task must start from a clean arena baseline. The suspend + * guard guarantees a task never *yields* with the arena active, so the only + * way it's active here is an ending task that leaked the scope. */ + g_arena.active = 0; if (g_has_error) { t->has_error = 1; t->error_value = vm_take_error_value(); /* the {kind,message,line} dict */ diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index 4c76549..a026484 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -2140,9 +2140,23 @@ check_eigs_suite "recv-blocked worker doesn't hang exit" test_spawn_channel_exit echo "[104] Worker Arena Return (no cross-thread UAF, #302)" check_eigs_suite "worker arena return deep-copied before detach" test_spawn_arena_return.eigs "All tests passed" 1 -# #408 cooperative task layer — increment 1a: spawn + alive + leak-clean -# teardown of unrun tasks (the interleaving scheduler lands in 1b). -check_eigs_suite "cooperative tasks: spawn + alive + teardown (#408 inc 1a)" test_tasks.eigs "All tests passed" 1 +# #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 + +# #408 determinism-by-construction: a task program with cooperative yields must +# print byte-identically on two fresh processes (the signature property — the +# interleaving is a pure function of program order, no tape). +TOTAL=$((TOTAL + 1)) +DET1=$(./eigenscript ../examples/task_pipeline.eigs &1) +DET2=$(./eigenscript ../examples/task_pipeline.eigs &1) +if [ "$DET1" = "$DET2" ] && echo "$DET1" | grep -q "= 120"; then + echo " PASS: cooperative tasks replay byte-identically (#408 determinism)" + PASS=$((PASS + 1)) +else + echo " FAIL: task interleaving diverged between runs (#408 determinism)" + FAIL=$((FAIL + 1)) +fi # [105] Builtin contract fixes (#312 negative indices, #316 predicate # type-rejection, #317 min/max N-ary reduction) + #314: a directory as the diff --git a/tests/test_tasks.eigs b/tests/test_tasks.eigs index b122993..333125c 100644 --- a/tests/test_tasks.eigs +++ b/tests/test_tasks.eigs @@ -110,4 +110,33 @@ catch e: assert_eq of [e.kind, "value", "task_yield in an arena scope is a value-error"] assert_eq of [threw2, 1, "arena-scoped yield raises"] +# --- teardown: a task KILLED while suspended (main ends first) with a heap +# value live on its saved value stack must be reclaimed clean. The list +# literal evaluates left-to-right, so the string is on the stack when the +# inner task_yield suspends. Covered by the ASan leak gate (tally 0). --- +define holder() as: + local keep is ["heap-string-on-the-value-stack", task_yield of null] + return len of keep +hk is task_spawn of holder +# NOT joined — reaped by kill-outstanding while suspended holding the string. + +# --- a task that dies inside an arena scope, UNJOINED, must not leave the +# per-thread arena active (would arena-allocate its error dict and +# contaminate the next task). Covered by the valgrind arena-poison gate. --- +define arena_dier() as: + arena_mark of null + task_yield of null # guard raises here; the task dies inside the scope + return 0 +ad is task_spawn of arena_dier +# NOT joined — sched_finish must force the arena inactive on this dead task. + +# A clean task spawned AFTER the arena-dier proves the arena baseline is clean: +# its string allocations must be heap, not arena (no cross-task contamination). +define after_arena() as: + task_yield of null + local s is "fresh-heap-string" + return len of s +aa is task_spawn of after_arena +assert_eq of [task_join of aa, 17, "task after an arena-dier gets a clean arena"] + test_summary of null