fix(tasks): unjoined death exit code, arena guard w/o scheduler, suspended-main leak (#493, #510, #483)#520
Merged
Conversation
…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>
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.
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-forgettask_spawnhid 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 atask_joinobserves it (caught or not), folded into the process exit code.task_killis a deliberate teardown and never trips it.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_sleepchecked the no-scheduler short-circuit before the arena guard, soarena_mark … task_yield … arena_resetwas a silent no-op (not the documentedvalueraise) until sometask_spawnhad 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_freefreed 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, mirroringtask_free's worker path. Workers were already clean (handle_table_drain→task_free); only main's slice was missed.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