Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
9 changes: 9 additions & 0 deletions src/vm.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
20 changes: 17 additions & 3 deletions tests/run_all_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 </dev/null 2>&1)
DET2=$(./eigenscript ../examples/task_pipeline.eigs </dev/null 2>&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
Expand Down
29 changes: 29 additions & 0 deletions tests/test_tasks.eigs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading