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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,20 @@ All notable changes to EigenScript are documented here.
region issued no scheduler yield (needs VM support).

### Changed
- **`deadlock` is now catchable (#509).** A cooperative-task deadlock (all
tasks blocked, none runnable) was raised inside the scheduler trampoline and
surfaced as a fatal process error — a `try`/`catch` around `task_join` never
ran. It is now delivered as an ordinary catchable error at the **main task's**
blocked join/recv site: the handler binds `e.kind == "deadlock"` (message
`all tasks are blocked — deadlock`) and execution continues after the block,
matching how a killed task's `interrupt` is catchable. A deadlock with no
handler on main stays terminal — loud message, non-zero exit — so uncaught
behavior is unchanged; a handler inside a *worker* does not catch it (the
error goes to main). Implementation note: the fatal print no longer routes
through `rt_error`'s `g_try_depth` gate, since a suspended worker's still-open
try can leave that global non-zero between task switches (it is not part of a
task's saved slice) — the trampoline decides catchable-vs-terminal from main's
saved frames directly.
- **Cooperative task layer — exit-code and arena-guard fixes (#493, #510).**
Two silent-tolerance holes in the #408 task layer:
- **#493:** a worker that dies of an uncaught error while nothing
Expand Down
2 changes: 1 addition & 1 deletion docs/BUILTINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -571,7 +571,7 @@ Requires full build. Transformer model inference and training.
| `task_spawn` | `task_spawn of fn` or `task_spawn of [fn, arg1, ...]` | Create a cooperative task (#408) running `fn` on the single OS thread — deterministic by construction, unlike `spawn`'s OS thread. Args are deep-COPIED (share-nothing, like channel sends), not shared by reference. Returns a numeric task id. (Increment 1a: the task is recorded and reported by `task_alive`; the copying-stack scheduler that runs and interleaves tasks — `task_yield`/`task_join` — lands in a later increment.) |
| `task_alive` | `task_alive of id` | Returns 1 while the task is runnable or suspended, 0 once it has finished (or for an unknown id). |
| `task_yield` | `task_yield of null` | Cooperatively hand control to the next ready task; this task resumes round-robin. A no-op when no task has been spawned. Forbidden inside an `arena_mark`…`arena_reset` scope or a nested evaluation (raises `value`). |
| `task_join` | `task_join of id` | Block until task `id` finishes, then return its deep-copied result — or re-raise its uncaught error (as the same `{kind, message, line}` it died with). Joining an already-finished task returns immediately; an unknown id (or self) returns null. All tasks blocked with none runnable is a `deadlock` error, not a hang. |
| `task_join` | `task_join of id` | Block until task `id` finishes, then return its deep-copied result — or re-raise its uncaught error (as the same `{kind, message, line}` it died with). Joining an already-finished task returns immediately; an unknown id (or self) returns null. All tasks blocked with none runnable is a `deadlock` error, not a hang — catchable by a `try`/`catch` around the join on the main task (`e.kind == "deadlock"`); terminal only if unhandled. |
| `task_send` | `task_send of [id, value]` | Append a deep-copied message to task `id`'s unbounded FIFO mailbox, waking it if it waits in `task_recv`. Returns 1 if delivered, 0 if `id` is finished/unknown (a silent drop — send-to-dead is never an error). Never blocks. |
| `task_recv` | `task_recv of null` | Return the next message from this task's mailbox, or block cooperatively until one arrives. Forbidden inside an `arena_mark`…`arena_reset` scope or a nested evaluation (raises `value`). |
| `task_try_recv` | `task_try_recv of null` | Non-blocking receive: the next mailbox message, or `null` if empty. Never suspends. |
Expand Down
7 changes: 6 additions & 1 deletion docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -1169,7 +1169,12 @@ 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.
error, reported loudly rather than hanging. The `deadlock` is delivered
as an ordinary **catchable** error at the main task's blocked join/recv
site: a `try`/`catch` there binds `e.kind == "deadlock"` and execution
continues after the block. Only if the main task has no handler is the
deadlock terminal (loud message, non-zero exit) — a handler inside a
*worker* does not catch it, since the deadlock is delivered to main.

A task that **dies of an uncaught error** prints its stack trace, and if
nothing ever `task_join`s it the **process still exits non-zero** — a
Expand Down
36 changes: 35 additions & 1 deletion src/vm.c
Original file line number Diff line number Diff line change
Expand Up @@ -5677,7 +5677,41 @@ static Value *scheduler_trampoline(TaskScheduler *s) {
s->main_task.result = NULL;
return r ? r : make_null();
}
rt_error(EK_DEADLOCK, 0, "all tasks are blocked — deadlock");
/* #509: deadlock is a normal runtime error, not a hang — make it
* CATCHABLE. main is guaranteed SUSPENDED here (the DONE/DEAD case
* returned above), blocked at a task_join/recv. Build the structured
* error at main's blocked line (vm_take_error_value later lazily
* turns g_error_kind/raw/line into a {kind,message,line} dict, so
* e.kind == "deadlock"). We drive the print/handling ourselves and
* do NOT go through rt_error's g_try_depth-gated print: g_try_depth
* is a global, not part of a task's saved slice, so a suspended
* worker's still-open try can leave it non-zero here. */
Task *m = &s->main_task;
int catchable = 0;
for (int i = 0; i < m->saved_frame_count; i++)
if (m->saved_frames[i].try_count > 0) { catchable = 1; break; }
g_error_kind = (int)EK_DEADLOCK;
g_error_line = m->saved_current_line;
snprintf(g_error_raw, sizeof(g_error_raw),
"all tasks are blocked — deadlock");
snprintf(g_error_msg, sizeof(g_error_msg),
"Error line %d: all tasks are blocked — deadlock", g_error_line);
g_has_error = 1;
eigs_clear_error_value();
if (catchable) {
/* Deliver at main's blocked site: clear the block reason so the
* resume doesn't fill a normal join/recv result, then re-enqueue
* main. The loop resumes it with g_has_error set → CHECK_ERROR
* unwinds to the handler (which reads e.kind == "deadlock"). */
m->join_target = 0;
m->recv_blocked = 0;
m->sleeping = 0;
sched_ready_push(s, 0);
continue;
}
/* No handler in main → terminal: print loudly, exit non-zero. (No
* stack trace: between tasks g_vm has no live frames.) */
fprintf(stderr, "%s\n", g_error_msg);
return make_null();
}
Task *t = sched_lookup(s, id);
Expand Down
3 changes: 2 additions & 1 deletion tests/run_all_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2260,7 +2260,8 @@ check_task_exit() {
check_task_exit task_exit_unjoined_death.eigs 1 "MARK_END" # #493 strict: main completes, rc 1
check_task_exit task_exit_join_catch.eigs 0 "undefined_name" # #493 caught: rc 0
check_task_exit task_exit_killed.eigs 0 "MARK_END" # #493 kill: rc 0
check_task_exit task_deadlock.eigs 1 "deadlock" # #483 leak-clean (main's suspended slice) + #509 loud
check_task_exit task_deadlock.eigs 1 "deadlock" # #483 leak-clean (main's suspended slice) + #509 uncaught loud
check_task_exit task_deadlock_worker_try.eigs 1 "deadlock" # #509: deadlock goes to MAIN; a worker's try doesn't catch it

# [105] Builtin contract fixes (#312 negative indices, #316 predicate
# type-rejection, #317 min/max N-ary reduction) + #314: a directory as the
Expand Down
16 changes: 16 additions & 0 deletions tests/task_deadlock_worker_try.eigs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# #509: a deadlock is delivered to the MAIN task's blocked join site. A try
# inside a WORKER (not main) does NOT catch it — so with main unguarded this
# stays fatal (loud + rc 1), even though a worker wraps its own join in try.
gb is 0
define da as:
try:
return task_join of gb
catch e:
return "unreachable"
define db as:
return task_join of ga
ga is 0
ga is task_spawn of da
gb is task_spawn of db
task_join of ga
print of "MARK_UNREACHED"
24 changes: 24 additions & 0 deletions tests/test_tasks.eigs
Original file line number Diff line number Diff line change
Expand Up @@ -360,4 +360,28 @@ catch e:
assert_eq of [e.kind, "type_mismatch", "task_sched_seed rejects a non-number seed"]
assert_eq of [seed_threw, 1, "task_sched_seed of a non-number raises"]

# --- #509: a deadlock (mutual join, nothing runnable) is a CATCHABLE runtime
# error delivered at main's blocked join site — not a fatal-only hang. The
# handler binds e.kind == "deadlock" and code after the try runs. (An
# UNCAUGHT deadlock stays loud + non-zero — asserted by the task_deadlock
# suite gate.) Placed last: the two mutually-blocked workers stay suspended
# (never runnable, reaped at exit), so they can't perturb earlier tests. ---
dla is 0
dlb is 0
define dl_a as:
return task_join of dlb
define dl_b as:
return task_join of dla
dla is task_spawn of dl_a
dlb is task_spawn of dl_b
dl_caught is 0
try:
task_join of dla
assert of [0, "a deadlocked join must not return normally"]
catch e:
dl_caught is 1
assert_eq of [e.kind, "deadlock", "deadlock is catchable at the join site (#509)"]
assert_eq of [e.message, "all tasks are blocked — deadlock", "deadlock carries its message"]
assert_eq of [dl_caught, 1, "the deadlock handler ran and code after the try continues"]

test_summary of null
Loading