diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c09443..6b9a83d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/BUILTINS.md b/docs/BUILTINS.md index e333fcc..0ecf204 100644 --- a/docs/BUILTINS.md +++ b/docs/BUILTINS.md @@ -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. | diff --git a/docs/SPEC.md b/docs/SPEC.md index 5b89f63..ae38889 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -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 diff --git a/src/vm.c b/src/vm.c index 5519d7a..05fcdff 100644 --- a/src/vm.c +++ b/src/vm.c @@ -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); diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index f9fb5e5..3e10361 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -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 diff --git a/tests/task_deadlock_worker_try.eigs b/tests/task_deadlock_worker_try.eigs new file mode 100644 index 0000000..6118edc --- /dev/null +++ b/tests/task_deadlock_worker_try.eigs @@ -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" diff --git a/tests/test_tasks.eigs b/tests/test_tasks.eigs index 3d15662..8873f42 100644 --- a/tests/test_tasks.eigs +++ b/tests/test_tasks.eigs @@ -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