fix(tasks): deadlock is a catchable runtime error at main's join site (#509)#521
Merged
Merged
Conversation
…#509) A cooperative-task deadlock (all tasks blocked, none runnable) was raised inside scheduler_trampoline via rt_error and surfaced as a fatal process error — a try/catch around task_join never ran the handler, even though `deadlock` is a documented closed error kind and a killed task's `interrupt` on join IS catchable. The trampoline detects deadlock only when main is still live and SUSPENDED (the DONE/DEAD case returns above). Now, at that point, it builds the structured deadlock error at main's blocked line and — if any of main's saved frames is inside a try — clears main's block reason and re-enqueues main; the scheduler loop resumes it with g_has_error set, so CHECK_ERROR unwinds to the handler (e.kind == "deadlock", e.message == "all tasks are blocked — deadlock") and execution continues after the block. With no handler on main, the deadlock stays terminal: loud message + non-zero exit, unchanged. The error is delivered to MAIN only — a try inside a worker does not catch it. define da as: return task_join of gb define db as: return task_join of ga ga is task_spawn of da gb is task_spawn of db try: task_join of ga # before: fatal, handler skipped catch e: print of e.kind # after: "deadlock" print of "after" # after: runs; rc 0 Implementation note: the fatal print no longer routes through rt_error's g_try_depth gate. g_try_depth is a process global, not part of a task's saved slice, so a suspended worker's still-open try can leave it non-zero between task switches — which silently suppressed the fatal print when a worker held a try. The trampoline now decides catchable-vs-terminal from main's saved frames directly and prints the terminal case itself. Tests: catchable-deadlock assertion at the end of test_tasks.eigs (red without the fix — the uncaught deadlock aborts the file); task_deadlock_worker_try.eigs in the exit-contract harness asserts a worker's try does NOT catch (rc 1). Docs: SPEC.md, BUILTINS.md (task_join), CHANGELOG. Release + ASan suites: 2688/2688, leak tally 0. Closes #509 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A cooperative-task deadlock (all tasks blocked, none runnable) was raised inside
scheduler_trampolineviart_errorand surfaced as a fatal process error — atry/catcharoundtask_joinnever ran the handler. Yetdeadlockis a documented closed error kind, and a killed task'sinterrupton join is catchable. This closes that inconsistency.Fix
The trampoline detects deadlock only when the main task is still live and suspended (the DONE/DEAD case returns earlier). At that point it now builds the structured deadlock error at main's blocked line and:
try→ clear main's block reason and re-enqueue main; the scheduler loop resumes it withg_has_errorset, soCHECK_ERRORunwinds to the handler.e.kind == "deadlock",e.message == "all tasks are blocked — deadlock", and execution continues after the block.The deadlock is delivered to the main task — a
tryinside a worker does not catch it.Implementation note (a latent bug this surfaced)
The fatal print no longer routes through
rt_error'sg_try_depthgate.g_try_depthis a process global, not part of a task's saved slice, so a suspended worker's still-opentrycan leave it non-zero between task switches — which silently suppressed the fatal deadlock print when a worker held a try. The trampoline now decides catchable-vs-terminal from main's saved frames directly and prints the terminal case itself.Tests
test_tasks.eigs— red without the fix (the uncaught deadlock aborts the whole file before it can print its summary). Assertse.kind == "deadlock", the message, and that code after thetryruns.task_deadlock_worker_try.eigsin the exit-contract harness — a worker'strydoes not catch (rc 1, stays fatal).task_deadlock.eigsgate still asserts uncaught deadlock is loud + rc 1 and leak-clean.Docs
SPEC.md (task layer), BUILTINS.md (
task_join), CHANGELOG[Unreleased].Validation
Release + ASan suites 2688/2688, leak tally 0, 0 heap errors. Every new assertion confirmed red-without-fix.
Closes #509
🤖 Generated with Claude Code