Skip to content

fix(tasks): unjoined death exit code, arena guard w/o scheduler, suspended-main leak (#493, #510, #483)#520

Merged
InauguralPhysicist merged 1 commit into
mainfrom
fix/task-exit-paths-493-510-483
Jul 9, 2026
Merged

fix(tasks): unjoined death exit code, arena guard w/o scheduler, suspended-main leak (#493, #510, #483)#520
InauguralPhysicist merged 1 commit into
mainfrom
fix/task-exit-paths-493-510-483

Conversation

@InauguralPhysicist

Copy link
Copy Markdown
Collaborator

Three silent-tolerance holes in the #408 cooperative task layer, surfaced by an adversarial language review and closed together — they share the task-builtin entry paths and the scheduler-teardown seam.

#493 — unjoined task death is silently swallowed

A worker that dies of an uncaught error while nothing task_joins it printed its stack trace to stderr but the process still exited 0. Fire-and-forget task_spawn hid failures — easy to green CI while a worker already died.

Fix: per-task err_unobserved, set when a worker dies of an uncaught error, cleared once a task_join observes it (caught or not), folded into the process exit code. task_kill is a deliberate teardown and never trips it.

define die() as:
    missing
id is task_spawn of die
print of "x"          # main runs to completion...
task_yield of null
# before: rc=0   after: rc=1

Joining without a catch still fails (rc 1); joining with a catch recovers (rc 0).

#510 — arena guard skipped when no scheduler

task_yield / task_recv / task_sleep checked the no-scheduler short-circuit before the arena guard, so arena_mark … task_yield … arena_reset was a silent no-op (not the documented value raise) until some task_spawn had armed a scheduler. The "no-op with no tasks" rule hid the "forbidden inside an arena" rule. The arena guard now runs first, independent of the scheduler.

#483 — leak of main's suspended slice on a fatal exit

A fatal exit while main (task 0) is still suspended — a deadlock, or main blocked on a join/recv that never resolves — leaked ~10 KB under ASan. task_sched_thread_free freed main's saved-slice arrays on the "main always ran to completion, slice is empty" assumption, orphaning the base module frame's chunk ref (plus the nested fn chunks in its constant pool) and the operand stack's value refs. The slice is now torn down ref-correctly, mirroring task_free's worker path. Workers were already clean (handle_table_draintask_free); only main's slice was missed.

Note: the one-shot LSan repro read clean (a stale stack slot kept the block reachable — a false negative). Only the in-suite deadlock test made it reproduce reliably; that test is the regression lock.

Tests

  • test_tasks.eigs: task_yield/recv/sleep: arena guard skipped when no scheduler (no tasks spawned) #510 arena-guard-without-scheduler assertions at the top (before any spawn) — red without the fix.
  • run_all_tests.sh: a dedicated exit-contract harness over four fixtures — unjoined-death (rc 1), join+catch (rc 0), killed-unjoined (rc 0), deadlock (rc 1 and leak-clean under ASan, stricter than the tolerated global tally).

Docs

SPEC.md (task exit-status rule), DIAGNOSTICS.md (exit-code table), CHANGELOG [Unreleased].

Validation

Release suite 2687/2687; ASan suite 2687/2687, leak tally 0. All four exit-contract checks pass; every fix confirmed red-without-fix.

Closes #493
Closes #510
Closes #483

🤖 Generated with Claude Code

…ended-main leak (#493, #510, #483)

Three silent-tolerance holes in the #408 cooperative task layer, found by an
adversarial language review and closed together (they share the task-builtin
and scheduler-teardown seams).

#493 — a worker that dies of an uncaught error while nothing task_joins it
printed its trace but the process still exited 0: a fire-and-forget worker's
death silently greened the run. Now tracked per-task (err_unobserved, set on
uncaught death, cleared when a join observes it caught-or-not) and folded into
the process exit code — task_kill is a deliberate teardown and never trips it.
  define die() as:
      missing
  id is task_spawn of die
  print of "x"          # main runs to completion...
  task_yield of null
  # before: rc=0   after: rc=1

#510 — task_yield/task_recv/task_sleep checked the no-scheduler short-circuit
BEFORE the arena guard, so `arena_mark … task_yield … arena_reset` was a no-op
(not the documented `value` raise) until some task_spawn had armed a scheduler.
The arena guard now runs first, independent of the scheduler.

#483 — a fatal exit while main (task 0) is still SUSPENDED (a deadlock, or main
blocked on a join/recv that never resolves) leaked ~10 KB: task_sched_thread_free
freed main's saved-slice arrays on the "main always ran to completion, slice is
empty" assumption, orphaning the module frame's chunk ref (+ nested fn chunks in
its constant pool) and the operand stack's value refs. The slice is now torn
down ref-correctly, mirroring task_free's worker path. (Workers were already
clean via handle_table_drain → task_free.) The one-shot LSan repro read clean —
a stale stack slot kept the block reachable, a false negative — and only the
in-suite deadlock test made it reproduce; that test is the regression lock.

Tests: #510 arena-guard-without-scheduler assertions at the top of
test_tasks.eigs (red without the fix); a dedicated exit-contract harness in
run_all_tests.sh over four fixtures (unjoined-death rc1, join+catch rc0,
killed-unjoined rc0, deadlock rc1 + leak-clean under ASan).
Docs: SPEC.md task exit-status rule, DIAGNOSTICS.md exit-code table, CHANGELOG.
Release + ASan suites: 2687/2687, leak tally 0.

Closes #493
Closes #510
Closes #483

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@InauguralPhysicist InauguralPhysicist merged commit e3be296 into main Jul 9, 2026
17 checks passed
@InauguralPhysicist InauguralPhysicist deleted the fix/task-exit-paths-493-510-483 branch July 9, 2026 10:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant