Skip to content

fix(tasks): deadlock is a catchable runtime error at main's join site (#509)#521

Merged
InauguralPhysicist merged 1 commit into
mainfrom
fix/deadlock-catchable-509
Jul 9, 2026
Merged

fix(tasks): deadlock is a catchable runtime error at main's join site (#509)#521
InauguralPhysicist merged 1 commit into
mainfrom
fix/deadlock-catchable-509

Conversation

@InauguralPhysicist

Copy link
Copy Markdown
Collaborator

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. Yet deadlock is a documented closed error kind, and a killed task's interrupt on 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:

  • If any of main's saved frames is inside a try → clear main's block reason and re-enqueue 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.
  • Otherwise → terminal, exactly as before: loud message + non-zero exit.

The deadlock is delivered to the main task — 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 (a latent bug this surfaced)

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 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

  • Catchable-deadlock assertion at the end of test_tasks.eigsred without the fix (the uncaught deadlock aborts the whole file before it can print its summary). Asserts e.kind == "deadlock", the message, and that code after the try runs.
  • task_deadlock_worker_try.eigs in the exit-contract harness — a worker's try does not catch (rc 1, stays fatal).
  • Existing task_deadlock.eigs gate 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

…#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>
@InauguralPhysicist InauguralPhysicist merged commit 21c4831 into main Jul 9, 2026
17 checks passed
@InauguralPhysicist InauguralPhysicist deleted the fix/deadlock-catchable-509 branch July 9, 2026 11:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

task: deadlock error is not catchable by try/catch around task_join

1 participant