From b96efe691846276e8e86757f8bd87f72e6ebb353 Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Thu, 9 Jul 2026 21:13:35 -0500 Subject: [PATCH] =?UTF-8?q?feat(tasks):=20task=5Fdetach=20=E2=80=94=20fire?= =?UTF-8?q?-and-forget=20tasks=20reap=20their=20handle=20slot=20(#530)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finished tasks were never removed from the 255-slot handle table, so task_spawn failed after 255 spawns per process — a LIFETIME cap even when every task was joined. task_detach of id (pthread-detach precedent) marks a task nobody will join: reaped at finish, at immediate-detach of an already-finished task, and at task_kill. An uncaught death still prints and still fails the process (#493 — the flag survives the reap in a scheduler-level counter). A task may detach itself via task_self. Also fixes ready-queue poisoning surfaced by the same workload: a task killed while READY left a stale entry; enough of them filled the fixed 256-slot queue, whose push then silently dropped real wakeups — a spurious deadlock. task_kill now removes the victim's pending entry. Surfaced by the #523 liferaft migration (deliverer task per in-flight message; crash teardown kills pending deliverers in bulk). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 16 +++++++ docs/BUILTINS.md | 1 + docs/SPEC.md | 9 ++++ src/builtins.c | 17 ++++++++ src/vm.c | 65 +++++++++++++++++++++++++++++ src/vm.h | 5 +++ tests/run_all_tests.sh | 1 + tests/task_exit_detached_death.eigs | 10 +++++ tests/test_tasks.eigs | 46 ++++++++++++++++++++ 9 files changed, 170 insertions(+) create mode 100644 tests/task_exit_detached_death.eigs diff --git a/CHANGELOG.md b/CHANGELOG.md index 54818ad..7d85a33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to EigenScript are documented here. ## [Unreleased] ### Added +- **`task_detach` — fire-and-forget tasks reap their handle slot (#530).** + Finished tasks were never removed from the 255-slot handle table before + process exit, so `task_spawn` failed after 255 spawns per process — a + LIFETIME cap, even when every task was joined. `task_detach of id` + (pthread-detach precedent) marks a task nobody will join: it is reaped the + moment it finishes (or immediately if already finished, or when + `task_kill`ed), returning its slot to the pool — the cap now bounds + *concurrent* tasks, as its message always claimed. A detached task's + uncaught death still prints and still fails the process (#493 — the flag + survives the reap in a scheduler-level counter). A task may detach itself + via `task_self`. Also fixes ready-queue poisoning found by the same + workload: a task killed while READY left a stale queue entry, and enough + of them filled the fixed 256-slot queue, whose push then SILENTLY dropped + real wakeups — a spurious "deadlock". `task_kill` now removes the victim's + pending entry. Surfaced by the #523 liferaft migration (a deliverer task + per in-flight message; crash-teardown kills pending deliverers in bulk). - **`task_self` — a task can learn its own id (#526).** `task_self of null` returns the running task's id in the same integer space `task_spawn` returns (the main task is 0, including before any task has been spawned). diff --git a/docs/BUILTINS.md b/docs/BUILTINS.md index ea3b637..55b5a08 100644 --- a/docs/BUILTINS.md +++ b/docs/BUILTINS.md @@ -578,6 +578,7 @@ Requires full build. Transformer model inference and training. | `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. | | `task_kill` | `task_kill of id` | Tear down task `id`: drop its mailbox, mark it dead, wake any joiner with an `interrupt` error. Returns 1 if killed, 0 for a finished/unknown/self target. | +| `task_detach` | `task_detach of id` | Mark task `id` **fire-and-forget** (the pthread-detach precedent, #530): it is reaped the moment it finishes — or immediately if already finished — releasing its handle slot for reuse, so task-per-message workloads are bounded by *concurrent* tasks, not lifetime spawns. A detached task's uncaught death still prints its trace and still fails the process at exit (#493). A reaped id reads as unknown afterwards (`task_join` null, `task_alive` 0). A task may detach itself: `task_detach of (task_self of null)`. Returns 1, or 0 for main/unknown. | | `task_sleep` | `task_sleep of ticks` | Suspend this task until the **virtual clock** advances by `ticks`. The clock is logical (discrete-event): it only jumps forward — to the earliest sleeper — when nothing else is runnable, so sleeping stays deterministic, not wall-clock. A negative sleep is treated as 0. A no-op when no task has been spawned. Forbidden inside an `arena_mark`…`arena_reset` scope. | | `task_now` | `task_now of null` | The current virtual-clock value (a number; 0 before any `task_sleep`). Deterministic — reads a logical counter, records no nondeterminism. | | `task_sched_seed` | `task_sched_seed of n` | Install a scheduling **seed**: the scheduler switches from FIFO round-robin to picking the next ready task from a seeded, platform-independent PRNG. Same seed ⇒ same interleaving (byte-identical run + replay, zero tape nondeterminism); a different seed explores a different ordering — the lever a deterministic simulation tester uses to search interleavings. No seed ⇒ unchanged FIFO. Typically called once at program start. Returns null. | diff --git a/docs/SPEC.md b/docs/SPEC.md index caad473..1f9d65b 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1197,6 +1197,15 @@ null` down as an argument (or a worker sends its own `task_self` up), and messages can then flow back to whoever asked — the link pattern message-based supervision needs. +A task nobody will ever join can be marked **fire-and-forget** with +`task_detach of id` (a task may detach itself via `task_self`). A +detached task releases all its resources the moment it finishes, so a +long-running program can spawn an unbounded stream of short-lived +tasks; an undetached task instead stays joinable until the program +ends. A detached task that dies of an uncaught error still prints its +trace and still makes the process exit non-zero — fire-and-forget +never silently swallows a failure. + ```eigenscript worker_id is 0 define worker() as: diff --git a/src/builtins.c b/src/builtins.c index d1cd1c9..100e2c4 100644 --- a/src/builtins.c +++ b/src/builtins.c @@ -4939,6 +4939,22 @@ Value* builtin_task_self(Value *arg) { return make_num((double)task_current_id()); } +/* task_detach of id -> 1 (0 for main/unknown). Marks the task fire-and-forget + * (#530, the pthread_detach precedent): it is reaped the moment it finishes — + * or immediately if already finished — releasing its handle slot for reuse, + * so task-per-message workloads are bounded by CONCURRENT tasks, not lifetime + * spawns. A detached task's uncaught death still prints its trace and still + * fails the process at exit (#493 — a scheduler counter survives the reap). + * Joining a reaped id returns null, like any unknown id. Deterministic; no + * tape participation. */ +Value* builtin_task_detach(Value *arg) { + if (!arg || arg->type != VAL_NUM) { + rt_error(EK_TYPE, 0, "task_detach requires a task id (a number)"); + return make_null(); + } + return make_num((double)task_do_detach((int)arg->data.num)); +} + /* task_sched_seed of n — install a scheduling seed. By default tasks run FIFO * round-robin; with a seed the scheduler picks the next ready task * pseudo-randomly from a deterministic PRNG. The interleaving stays @@ -6004,6 +6020,7 @@ void register_builtins(Env *env) { env_set_local_owned(env, "task_spawn", make_builtin(builtin_task_spawn)); env_set_local_owned(env, "task_alive", make_builtin(builtin_task_alive)); env_set_local_owned(env, "task_self", make_builtin(builtin_task_self)); + env_set_local_owned(env, "task_detach", make_builtin(builtin_task_detach)); env_set_local_owned(env, "task_yield", make_builtin(builtin_task_yield)); env_set_local_owned(env, "must_not_yield", make_builtin(builtin_must_not_yield)); env_set_local_owned(env, "task_join", make_builtin(builtin_task_join)); diff --git a/src/vm.c b/src/vm.c index 9fca437..030bc63 100644 --- a/src/vm.c +++ b/src/vm.c @@ -2084,6 +2084,7 @@ static const char *slot_type_name(EigsSlot s) { static void task_restore_slice(Task *t); static void task_save_slice(Task *t); static Task *task_current_running(void); +static void task_reap(Task *t); /* #530 */ static void task_apply_join_result(Task *t); /* fill a join placeholder on resume */ static void task_apply_recv_result(Task *t); /* fill a recv placeholder on resume */ @@ -5176,6 +5177,7 @@ typedef struct { int live; /* spawned tasks not yet DONE/DEAD */ int active; /* armed on first spawn */ int dead_letters; /* inc 2: sends to finished/unknown tasks */ + int detached_err_count; /* #530: reaped detached tasks that died unobserved (#493 gate) */ double now; /* inc 3: virtual clock (logical, starts 0) */ int seeded; /* inc 4: 1 once task_sched_seed installs a seed */ uint64_t rng_state; /* inc 4: splitmix64 state for the seeded pick */ @@ -5242,6 +5244,8 @@ static Task *sched_lookup(TaskScheduler *s, int id) { * non-zero instead of silently returning 0. */ int task_any_unobserved_error(void) { if (!g_task_sched) return 0; + /* #530: reaped detached tasks that died unobserved are counted, not held. */ + if (((TaskScheduler *)g_task_sched)->detached_err_count > 0) return 1; for (int i = 1; i < HANDLE_TABLE_SIZE; i++) { Task *t = (Task *)handle_lookup(i, HANDLE_TASK); if (t && t->err_unobserved) return 1; @@ -5260,6 +5264,24 @@ static void sched_ready_push(TaskScheduler *s, int id) { s->rcount++; } +/* #530: drop tid's pending ready-queue entry. A task killed while READY (or + * woken but not yet run) used to leave its entry behind; the trampoline + * skips stale ids, but enough of them FILL the fixed queue and + * sched_ready_push silently drops real wakeups — a spurious "deadlock". + * A task has at most one entry (recv-wake is idempotent and a task must be + * popped before it can re-enqueue), so one compacting pass suffices. */ +static void sched_ready_remove(TaskScheduler *s, int tid) { + int w = 0; + for (int k = 0; k < s->rcount; k++) { + int id = s->ready[(s->rhead + k) % TASK_READY_MAX]; + if (id != tid) { + s->ready[(s->rhead + w) % TASK_READY_MAX] = id; + w++; + } + } + s->rcount = w; +} + /* Inc 4: splitmix64 — a deterministic, platform-independent integer PRNG for * the seeded scheduling strategy. Pure integer arithmetic (no float, no OS * entropy), so the pick sequence is a reproducible function of the installed @@ -5500,6 +5522,9 @@ int task_do_kill(int tid) { if (!s || tid == 0 || tid == s->current) return 0; Task *t = sched_lookup(s, tid); if (!t || t->state == TASK_DONE || t->state == TASK_DEAD) return 0; + /* #530: a READY/woken victim holds a ready-queue entry — remove it so + * dead ids can never fill the queue and starve real wakeups. */ + sched_ready_remove(s, tid); /* Drain the mailbox. */ while (t->mbox_count > 0) { val_decref(t->mbox[t->mbox_head]); @@ -5541,6 +5566,9 @@ int task_do_kill(int tid) { } if (s->main_task.state == TASK_SUSPENDED && s->main_task.join_target == tid) sched_ready_push(s, 0); + /* #530: kill of a detached task is an explicit discard — reap now. (Kill + * is a deliberate teardown, never an uncaught error: no #493 counting.) */ + if (t->detached) task_reap(t); return 1; } @@ -5588,6 +5616,35 @@ static Value *task_start(Task *t) { /* Record a task that just finished (returned or errored) and wake any joiner * blocked on it. `r` is the value vm_run returned (NULL on suspend — not this * path). g_has_error distinguishes a normal end from an uncaught error. */ +/* #530: release a task's handle slot and free the struct. Only for tasks + * nobody will join (detached) — a reaped id reads as unknown afterwards + * (task_alive 0, task_join null) and the slot is immediately reusable. */ +static void task_reap(Task *t) { + int id = t->id; + task_free(t); + handle_release(id); +} + +/* #530: mark `tid` fire-and-forget. A detached task is reaped the moment it + * finishes — or immediately here if it already has — so its handle slot + * returns to the pool instead of holding the table until process exit. An + * already-dead unobserved error moves to the scheduler-level counter so the + * #493 exit gate survives the reap. The RUNNING task may detach itself. + * Returns 1 on success, 0 for main (task 0) or an unknown id. */ +int task_do_detach(int tid) { + TaskScheduler *s = sched_get(); + if (!s || tid == 0) return 0; + Task *t = sched_lookup(s, tid); + if (!t) return 0; + if (t->state == TASK_DONE || t->state == TASK_DEAD) { + if (t->err_unobserved) s->detached_err_count++; + task_reap(t); + return 1; + } + t->detached = 1; + return 1; +} + static void sched_finish(TaskScheduler *s, Task *t, Value *r) { /* A task that ended (returned OR died) while its per-thread arena is still * active — arena_mark with no matching arena_reset, e.g. the arena-suspend @@ -5625,6 +5682,14 @@ static void sched_finish(TaskScheduler *s, Task *t, Value *r) { } if (s->main_task.state == TASK_SUSPENDED && s->main_task.join_target == t->id) sched_ready_push(s, 0); + /* #530: a detached task's outcome is nobody's to consume — reap the slot + * now so task-per-message workloads aren't bounded by lifetime spawns. + * An uncaught death still fails the process: the #493 flag moves to the + * scheduler counter before the slot frees (the trace already printed). */ + if (t->id != 0 && t->detached) { + if (t->err_unobserved) s->detached_err_count++; + task_reap(t); + } } /* On resuming a task that was blocked in task_join, replace the placeholder diff --git a/src/vm.h b/src/vm.h index f55142e..8f10111 100644 --- a/src/vm.h +++ b/src/vm.h @@ -435,6 +435,10 @@ typedef struct Task { * with this at process exit makes the process exit non-zero — a * fire-and-forget worker's death must not silently green a run. */ int err_unobserved; + /* #530: fire-and-forget. Set by task_detach; the task is reaped (freed + + * handle slot released) the moment it finishes or is killed, instead of + * lingering joinable until process exit. */ + int detached; } Task; /* Free a Task's held refs and the struct. Safe on any state; called by @@ -458,6 +462,7 @@ int task_mbox_has(void); /* current task has a queued message? */ Value *task_mbox_pop(void); /* pop current task's front message (owned ref) */ void task_request_recv(void); /* current task blocks awaiting a message */ int task_do_kill(int tid); /* deterministic teardown of `tid`; 0 = bad target */ +int task_do_detach(int tid); /* #530: mark fire-and-forget (reap at finish); 0 = main/unknown */ /* Inc 3 virtual time (builtins.c task_sleep/task_now). */ void task_request_sleep(double ticks); /* current task sleeps until virtual now + ticks */ double task_virtual_now(void); /* current virtual-clock value (0 with no scheduler) */ diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index 0ad551d..5d93a26 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -2266,6 +2266,7 @@ 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_exit_detached_death.eigs 1 "MARK_END" # #530: a DETACHED death still fails the process 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 diff --git a/tests/task_exit_detached_death.eigs b/tests/task_exit_detached_death.eigs new file mode 100644 index 0000000..3cd2751 --- /dev/null +++ b/tests/task_exit_detached_death.eigs @@ -0,0 +1,10 @@ +# #530: a DETACHED worker that dies of an uncaught error is reaped at finish, +# but its death must STILL fail the process — #493's guarantee survives the +# reap via the scheduler-level counter. Expected: stderr trace + rc 1. +define die() as: + missing +id is task_spawn of die +task_detach of id +print of "main continues" +task_yield of null +print of "MARK_END" diff --git a/tests/test_tasks.eigs b/tests/test_tasks.eigs index 8b44b9f..7b34780 100644 --- a/tests/test_tasks.eigs +++ b/tests/test_tasks.eigs @@ -453,4 +453,50 @@ task_join of _self_r _self_msg is task_recv of null assert_eq of [_self_msg, _self_r, "worker replied its own id to the supervisor's mailbox"] +# --- task_detach (#530): fire-and-forget tasks reap their handle slot ------ + +# Lifetime-cap regression: 300 detached spawns is impossible pre-#530 (every +# finished task held its handle slot until process exit; spawn 256 raised +# EK_LIMIT even when each task was joined). +define _det_w() as: + return 1 +_det_n is 0 +loop while _det_n < 300: + _det_t is task_spawn of _det_w + task_detach of _det_t + task_yield of null + _det_n is _det_n + 1 +assert_eq of [_det_n, 300, "300 detached spawns run and reap (no lifetime cap)"] + +# Detaching an ALREADY-finished task reaps immediately; the id then reads +# unknown (join null, alive 0). +_det_t2 is task_spawn of _det_w +task_yield of null +assert_eq of [task_detach of _det_t2, 1, "detach of a finished task reaps it"] +assert_eq of [task_join of _det_t2, null, "joining a reaped id is null"] +assert_eq of [task_alive of _det_t2, 0, "a reaped id is not alive"] + +# task_kill of a detached live task reaps too: 300 spawn/detach/kill cycles. +define _det_block() as: + return task_recv of null +_det_n is 0 +loop while _det_n < 300: + _det_b is task_spawn of _det_block + task_detach of _det_b + task_kill of _det_b + _det_n is _det_n + 1 +assert_eq of [_det_n, 300, "300 detach+kill cycles reap their slots"] + +# A worker can detach ITSELF (the fire-and-forget idiom, via task_self). +define _det_selfw() as: + task_detach of (task_self of null) + return 7 +_det_t3 is task_spawn of _det_selfw +task_yield of null +assert_eq of [task_join of _det_t3, null, "self-detached task is reaped at finish"] + +# main (task 0) cannot be detached; unknown ids refuse. +assert_eq of [task_detach of 0, 0, "task_detach of main is refused"] +assert_eq of [task_detach of 99999, 0, "task_detach of an unknown id is refused"] + test_summary of null