Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
1 change: 1 addition & 0 deletions docs/BUILTINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
9 changes: 9 additions & 0 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions src/builtins.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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));
Expand Down
65 changes: 65 additions & 0 deletions src/vm.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 */

Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/vm.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) */
Expand Down
1 change: 1 addition & 0 deletions tests/run_all_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions tests/task_exit_detached_death.eigs
Original file line number Diff line number Diff line change
@@ -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"
46 changes: 46 additions & 0 deletions tests/test_tasks.eigs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading