feat: true cancellation for the fiber Io layer (PLAN-IO-UNIFY Phase 3)
A cancelled async worker now abandons its body at its next suspend instead
of running to completion.
- Cancel-flag back-ref (D4): SpawnOpts.cancel_flag (core.sx) + Fiber.cancel_flag
(sched.sx), set from opts.cancel_flag in Scheduler.spawn_raw; async passes
xx @f.canceled (the Future.canceled Atomic(bool) erased to *void).
- Delivery: Scheduler.suspend_raw consults fiber_canceled(self.current) PRE-park
(raise without parking — no deadlock if cancel landed before the worker ran)
and POST-resume (cancel landed while parked), raising error.Canceled.
cancel(f) flips the sticky flag, marks .canceled, and wakes the worker.
- async worker is failable Closure() -> ($R, !); the completion closure
f.value = worker() catch {…} marks .canceled/.failed and wakes the awaiter,
so post-suspend side effects never run. New failable io.sleep(ms) is the
cancellation point.
- Compiler: a -> ! fn whose only error source is try-ing a protocol method
(io.suspend_raw) was wrongly flagged 'declared ! but never errors';
collectErrorSites now marks a try of a non-identifier callee as a dynamic
(opaque) error source, suppressing the warning.
- Two UAFs found by adversarial review and fixed: (1) cancel-before-park
orphaned io.sleep's armed timer — suspend_raw's pre-park raise now evicts the
current fiber's timer/waiter first; (2) cancel(f) could wake a reaped worker —
now only wakes when was_pending.
Migrated 1805/1806/1824 to failable workers. Lock: example 1825 (seq: 1 -99,
post-suspend line never runs); byte-identical on aarch64-macOS + aarch64-linux.
.ir churn is the SpawnOpts layout change (type-table string renumbering).
This commit is contained in:
@@ -35,6 +35,66 @@ the existing fiber primitives in sched.sx (`spawn`/`suspend_self`/`wake`/`sleep`
|
|||||||
installed via `push Context { io = xx scheduler } { … s.run(); }` — exactly the existing sched examples,
|
installed via `push Context { io = xx scheduler } { … s.run(); }` — exactly the existing sched examples,
|
||||||
just with the scheduler now reachable as `context.io`.
|
just with the scheduler now reachable as `context.io`.
|
||||||
|
|
||||||
|
## Status (2026-06-28)
|
||||||
|
- **Phase 3 — TRUE cancellation via `suspend_raw -> !`. DONE.** A cancelled async
|
||||||
|
worker now abandons its body at its next suspend instead of running to
|
||||||
|
completion. Pieces:
|
||||||
|
- **Cancel-flag back-ref (D4 — back-ref pointer, chosen):** `SpawnOpts.cancel_flag:
|
||||||
|
*void` (core.sx) + `Fiber.cancel_flag: *void` (sched.sx), set from
|
||||||
|
`opts.cancel_flag` in `Scheduler.spawn_raw`. `async` passes `xx @f.canceled`
|
||||||
|
(the `Future.canceled` `Atomic(bool)` erased to `*void`).
|
||||||
|
- **Delivery:** `Scheduler.suspend_raw` checks `fiber_canceled(self.current)` (a
|
||||||
|
`*Atomic(bool)` load) PRE-park (raise without parking — no deadlock if cancel
|
||||||
|
landed before the worker ran) and POST-resume (cancel landed while parked),
|
||||||
|
raising `error.Canceled` (a bare `-> !`; set inferred). `cancel(f)` flips the
|
||||||
|
sticky flag, marks `.canceled`, and `ready(.{handle=f.task})`s the worker.
|
||||||
|
- **Worker is failable** `Closure() -> ($R, !)`: the `async` completion closure
|
||||||
|
`f.value = worker() catch { … }` (the captured-failable-closure-call the
|
||||||
|
Phase-3-prereq fix enabled) marks `.canceled`/`.failed` and wakes the awaiter;
|
||||||
|
the worker's post-suspend side effects never run. New failable `io.sleep(ms)`
|
||||||
|
(arm_timer + `try suspend_raw`) is the cancellation point.
|
||||||
|
- **Compiler gap fixed:** a `-> !` fn whose only error source is `try`-ing a
|
||||||
|
protocol method (`io.suspend_raw`) was wrongly flagged "declared `!` but never
|
||||||
|
errors". `collectErrorSites` (error_analysis.zig) now sets a `dyn` flag for a
|
||||||
|
`try` of a non-identifier callee (opaque error channel), suppressing the
|
||||||
|
warning.
|
||||||
|
- **Two UAFs found by adversarial review and FIXED:** (1) cancel-before-park
|
||||||
|
orphaned `io.sleep`'s armed timer → `suspend_raw`'s pre-park raise now evicts
|
||||||
|
the current fiber's timer/waiter first. (2) `cancel(f)` woke a possibly-reaped
|
||||||
|
worker → now only wakes when `was_pending` (`.pending` before the store).
|
||||||
|
- Migrated 1805/1806/1824 to failable workers. Lock:
|
||||||
|
`examples/concurrency/1825-concurrency-fiber-cancel-suspend.sx` (`seq: 1 -99`
|
||||||
|
— post-suspend line never runs). **Validated byte-identical on aarch64-macOS
|
||||||
|
host AND aarch64-linux container** (1824 + 1825). Suite 853/0. Expected `.ir`
|
||||||
|
churn (SpawnOpts layout) regenerated; no non-`.ir` snapshot changed.
|
||||||
|
|
||||||
|
|
||||||
|
- **Phase 3 PREREQUISITE — captured-failable-closure call typing. DONE.** The
|
||||||
|
async completion closure (`b.run = () => { f.value = worker() catch {…} }`)
|
||||||
|
captures a failable `worker` and consumes its error channel; the free-variable
|
||||||
|
capture analysis (`collectCaptures` in `src/ir/lower/closure.zig`) did not
|
||||||
|
descend into the error-handling / context / asm / multi-assign nodes, so
|
||||||
|
`worker` was never captured — inside the lambda it resolved against an empty
|
||||||
|
scope and typed as `.unresolved` (`catch`/`try` then rejected it). Fixed: added
|
||||||
|
`try_expr`, `catch_expr`, `onfail_stmt`, `raise_stmt`, `multi_assign`,
|
||||||
|
`push_stmt`, `comptime_expr`, `insert_expr`, `spread_expr`, `asm_expr` arms to
|
||||||
|
`collectCaptures`. Adversarially reviewed (captures resolve, locals correctly
|
||||||
|
excluded, no false-positive captures, 851/0). Lock: example
|
||||||
|
`examples/closures/0314-closures-capture-failable-call.sx` (catch + try over a
|
||||||
|
captured failable closure; pure language feature, host-only). The `push_stmt`
|
||||||
|
arm also fixes the previously-noted "free-var analysis doesn't descend into a
|
||||||
|
nested `push Context {…}`" gap. **Phase 3 is now unblocked.**
|
||||||
|
- Two PRE-EXISTING, orthogonal bugs surfaced during review (neither blocked
|
||||||
|
Phase 3): (1) calling a closure stored in a **struct data field** typed as
|
||||||
|
`unresolved` (value → garbage; failable → can't `catch`) — **RESOLVED**
|
||||||
|
(`issues/0201`): `CallResolver.plan` gained a closure/fn-pointer field arm and
|
||||||
|
the lowering closure-field arm now also handles bare `.function` fields;
|
||||||
|
regression `examples/closures/0315-closures-struct-field-call.sx`. (2) asm
|
||||||
|
write-through place through a deref (`asm { … "+r" -> @(p.*) }`) fails LLVM
|
||||||
|
verification — repros with NO closure (independent of capture analysis);
|
||||||
|
possibly an unsupported deref-place form rather than a confirmed bug, not
|
||||||
|
filed.
|
||||||
|
|
||||||
## Status (2026-06-27)
|
## Status (2026-06-27)
|
||||||
- **Phase 0 — fibers inherit the spawn-time context. DONE** (`2f2d7f1d`). Discovered during Phase 1: a
|
- **Phase 0 — fibers inherit the spawn-time context. DONE** (`2f2d7f1d`). Discovered during Phase 1: a
|
||||||
fiber body ran under `__sx_default_context` (the `abi(.c)` `fib_dispatch` dropped the implicit
|
fiber body ran under `__sx_default_context` (the `abi(.c)` `fib_dispatch` dropped the implicit
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -12,16 +12,18 @@
|
|||||||
#import "modules/std.sx";
|
#import "modules/std.sx";
|
||||||
|
|
||||||
main :: () {
|
main :: () {
|
||||||
// Inputs captured at the call site.
|
// Inputs captured at the call site. The worker is FAILABLE
|
||||||
s := context.io.async(() -> i64 => 40 + 2);
|
// (`Closure() -> ($R, !)`) — the unified Phase 3 shape; a body that never
|
||||||
|
// raises is a degenerate failable that always succeeds.
|
||||||
|
s := context.io.async(() -> (i64, !) => 40 + 2);
|
||||||
print("sum: {}\n", s.await() or { -1 });
|
print("sum: {}\n", s.await() or { -1 });
|
||||||
|
|
||||||
d := context.io.async(() -> i64 => 21 * 2);
|
d := context.io.async(() -> (i64, !) => 21 * 2);
|
||||||
print("double: {}\n", d.await() or { -1 });
|
print("double: {}\n", d.await() or { -1 });
|
||||||
|
|
||||||
// A worker that closes over a local.
|
// A worker that closes over a local.
|
||||||
base := 42;
|
base := 42;
|
||||||
n := context.io.async(() -> i64 => base);
|
n := context.io.async(() -> (i64, !) => base);
|
||||||
print("nullary: {}\n", n.await() or { -1 });
|
print("nullary: {}\n", n.await() or { -1 });
|
||||||
|
|
||||||
// The Io capability also carries a clock.
|
// The Io capability also carries a clock.
|
||||||
|
|||||||
@@ -6,12 +6,13 @@
|
|||||||
#import "modules/std.sx";
|
#import "modules/std.sx";
|
||||||
|
|
||||||
main :: () {
|
main :: () {
|
||||||
// Not canceled → await yields the value.
|
// Not canceled → await yields the value. The worker is FAILABLE
|
||||||
ok := context.io.async(() -> i64 => 7);
|
// (`Closure() -> ($R, !)`) — the unified Phase 3 shape.
|
||||||
|
ok := context.io.async(() -> (i64, !) => 7);
|
||||||
print("ok: {}\n", ok.await() or { -1 });
|
print("ok: {}\n", ok.await() or { -1 });
|
||||||
|
|
||||||
// Canceled → await raises .Canceled → the `or` default is taken.
|
// Canceled → await raises .Canceled → the `or` default is taken.
|
||||||
c := context.io.async(() -> i64 => 7);
|
c := context.io.async(() -> (i64, !) => 7);
|
||||||
c.cancel();
|
c.cancel();
|
||||||
print("canceled: {}\n", c.await() or { -99 });
|
print("canceled: {}\n", c.await() or { -99 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ main :: () -> i64 {
|
|||||||
push .{ io = xx s } {
|
push .{ io = xx s } {
|
||||||
ps.spawn(() => {
|
ps.spawn(() => {
|
||||||
rec(pl, 1); // coordinator starts
|
rec(pl, 1); // coordinator starts
|
||||||
a := context.io.async(() -> i64 => { rec(pl, 10); 100 }); // worker A — deferred
|
a := context.io.async(() -> (i64, !) => { rec(pl, 10); 100 }); // worker A — deferred
|
||||||
b := context.io.async(() -> i64 => { rec(pl, 20); 23 }); // worker B — deferred
|
b := context.io.async(() -> (i64, !) => { rec(pl, 20); 23 }); // worker B — deferred
|
||||||
rec(pl, 2); // both spawned, neither has run
|
rec(pl, 2); // both spawned, neither has run
|
||||||
va := a.await() or { -1 }; // park; A runs, wakes us
|
va := a.await() or { -1 }; // park; A runs, wakes us
|
||||||
vb := b.await() or { -1 };
|
vb := b.await() or { -1 };
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Stream B2 — TRUE cancellation (PLAN-IO-UNIFY Phase 3). A `cancel` delivered to
|
||||||
|
// a worker that is PARKED at a suspend point makes the worker ABANDON its body:
|
||||||
|
// the worker's next `suspend_raw` raises `IoErr.Canceled`, which unwinds out
|
||||||
|
// through `try context.io.sleep(..)` and the failable worker, so every line AFTER
|
||||||
|
// the suspend never runs. This is "true cancellation, model (a)" — cancel rides
|
||||||
|
// the `!` channel and stops in-flight work at the next suspend, not merely flags
|
||||||
|
// a result.
|
||||||
|
//
|
||||||
|
// Flow (deterministic, virtual clock): the worker records 1 and parks in
|
||||||
|
// `sleep`; the coordinator (a fiber, so it can `yield`) lets the worker reach its
|
||||||
|
// park, then `cancel`s it. The worker's parked `suspend_raw` is woken and raises
|
||||||
|
// `Canceled` → the post-sleep `rec(pl, 2)` and the `42` return NEVER execute. The
|
||||||
|
// coordinator's `await` raises `Canceled` (sticky flag) → `or` default -99.
|
||||||
|
// Sequence: `1 -99` — the absence of `2` is the proof that the post-suspend work
|
||||||
|
// was truly cancelled.
|
||||||
|
//
|
||||||
|
// aarch64-pinned (the scheduler's per-arch asm): runs end-to-end on a matching
|
||||||
|
// host (macOS + linux, byte-identical under the deterministic virtual clock).
|
||||||
|
#import "modules/std.sx";
|
||||||
|
sched :: #import "modules/std/sched.sx";
|
||||||
|
|
||||||
|
Log :: struct { seq: [8]i64; n: i64; }
|
||||||
|
rec :: (l: *Log, v: i64) { l.seq[l.n] = v; l.n = l.n + 1; }
|
||||||
|
|
||||||
|
main :: () -> i64 {
|
||||||
|
lg : Log = .{ n = 0 };
|
||||||
|
s := sched.Scheduler.init();
|
||||||
|
ps := @s; pl := @lg;
|
||||||
|
push .{ io = xx s } {
|
||||||
|
ps.spawn(() => {
|
||||||
|
w := context.io.async(() -> (i64, !) => {
|
||||||
|
rec(pl, 1); // worker started
|
||||||
|
try context.io.sleep(10); // park; cancel delivers Canceled HERE
|
||||||
|
rec(pl, 2); // POST-SUSPEND — must NEVER run
|
||||||
|
42
|
||||||
|
});
|
||||||
|
ps.yield_now(); // let the worker run & park in sleep
|
||||||
|
w.cancel(); // cancel while parked → wakes + raises
|
||||||
|
r := w.await() or { -99 }; // await raises Canceled → -99
|
||||||
|
rec(pl, r);
|
||||||
|
});
|
||||||
|
ps.run();
|
||||||
|
}
|
||||||
|
print("seq:");
|
||||||
|
i := 0;
|
||||||
|
while i < lg.n { print(" {}", lg.seq[i]); i = i + 1; }
|
||||||
|
print("\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
{ "target": "macos" }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
seq: 1 -99
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -102,6 +102,14 @@ PinTarget :: enum { any; main; on_thread; }
|
|||||||
|
|
||||||
SpawnOpts :: struct {
|
SpawnOpts :: struct {
|
||||||
pin: PinTarget = .any;
|
pin: PinTarget = .any;
|
||||||
|
// Cancellation back-ref (Phase 3 — true cancellation). A pointer to the
|
||||||
|
// spawned task's cancel flag (the `Future.canceled` `Atomic(bool)`, erased to
|
||||||
|
// `*void` so this foundation type stays free of the atomic dependency). A
|
||||||
|
// suspending impl stashes it on the spawned execution context so its
|
||||||
|
// `suspend_raw` can consult it and raise `IoErr.Canceled` on resume. `null`
|
||||||
|
// (the default) means "no cancellation channel" — the blocking impl and any
|
||||||
|
// uncancellable spawn leave it unset.
|
||||||
|
cancel_flag: *void = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
ParkToken :: struct {
|
ParkToken :: struct {
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ sx_run_boxed_closure :: (arg: *void) {
|
|||||||
// long-lived allocator) is safe. A deeper fix — `async` capturing the scheduler's
|
// long-lived allocator) is safe. A deeper fix — `async` capturing the scheduler's
|
||||||
// own long-lived allocator the way `sched.go` does — needs a protocol affordance
|
// own long-lived allocator the way `sched.go` does — needs a protocol affordance
|
||||||
// to reach it and is deferred to the convergence phase.
|
// to reach it and is deferred to the convergence phase.
|
||||||
async :: ufcs (io: Io, worker: Closure() -> $R) -> *Future($R) {
|
async :: ufcs (io: Io, worker: Closure() -> ($R, !)) -> *Future($R) {
|
||||||
raw := context.allocator.alloc_bytes(size_of(Future($R)));
|
raw := context.allocator.alloc_bytes(size_of(Future($R)));
|
||||||
f : *Future($R) = xx raw;
|
f : *Future($R) = xx raw;
|
||||||
f.state = .pending;
|
f.state = .pending;
|
||||||
@@ -154,14 +154,31 @@ async :: ufcs (io: Io, worker: Closure() -> $R) -> *Future($R) {
|
|||||||
// The completion closure: run the worker, publish the result, wake any parked
|
// The completion closure: run the worker, publish the result, wake any parked
|
||||||
// awaiter. Heap-boxed so it survives until the worker actually runs (deferred
|
// awaiter. Heap-boxed so it survives until the worker actually runs (deferred
|
||||||
// under the fiber impl). It captures `f` + `worker`; nothing variadic crosses.
|
// under the fiber impl). It captures `f` + `worker`; nothing variadic crosses.
|
||||||
|
//
|
||||||
|
// Phase 3 (true cancellation): the worker is FAILABLE (`Closure() -> ($R, !)`).
|
||||||
|
// A suspend that delivers cancellation (`suspend_raw` raising `Canceled` on a
|
||||||
|
// cancelled worker), or any genuine `raise`, unwinds the worker's body right
|
||||||
|
// here — so its post-suspend side effects never run. On success publish the
|
||||||
|
// value and mark `.ready`; on error mark `.canceled` when `cancel` set the
|
||||||
|
// flag, else `.failed`. Either way wake any parked awaiter. Under `CBlockingIo`
|
||||||
|
// `suspend_raw` is a no-op, so the worker never raises Canceled inline — it
|
||||||
|
// runs to completion (a post-hoc `cancel` still makes `await` raise via the
|
||||||
|
// sticky `f.canceled`, the 1806 contract).
|
||||||
braw := context.allocator.alloc_bytes(size_of(ThunkBox));
|
braw := context.allocator.alloc_bytes(size_of(ThunkBox));
|
||||||
b : *ThunkBox = xx braw;
|
b : *ThunkBox = xx braw;
|
||||||
b.run = () => {
|
b.run = () => {
|
||||||
f.value = worker();
|
f.value = worker() catch {
|
||||||
|
if f.canceled.load(.acquire) { f.state = .canceled; }
|
||||||
|
else { f.state = .failed; }
|
||||||
|
context.io.ready(f.park);
|
||||||
|
return;
|
||||||
|
};
|
||||||
f.state = .ready;
|
f.state = .ready;
|
||||||
context.io.ready(f.park); // no-op if no awaiter parked yet
|
context.io.ready(f.park); // no-op if no awaiter parked yet
|
||||||
};
|
};
|
||||||
f.task = io.spawn_raw(xx sx_run_boxed_closure, xx b, .{});
|
// Pass the cancel-flag back-ref so the worker fiber's `suspend_raw` can consult
|
||||||
|
// it (Phase 3). `xx @f.canceled` erases the `*Atomic(bool)` to `*void`.
|
||||||
|
f.task = io.spawn_raw(xx sx_run_boxed_closure, xx b, .{ cancel_flag = xx @f.canceled });
|
||||||
return f;
|
return f;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,15 +211,45 @@ await :: ufcs (f: *Future($R)) -> ($R, !IoErr) {
|
|||||||
return f.value;
|
return f.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `cancel(f)` — request cancellation. Sets the per-future cancel flag + marks the
|
// `cancel(f)` — request cancellation (model (a) — cancel rides the `!` channel).
|
||||||
// state so a subsequent `await` raises `.Canceled` (model (a) — cancel rides the
|
// Sets the sticky per-future cancel flag + marks `.canceled` (so a subsequent
|
||||||
// `!` channel). DOES NOT STOP AN ALREADY-SPAWNED WORKER: under the fiber impl the
|
// `await` raises `.Canceled`), then WAKES the worker fiber so it delivers the
|
||||||
// worker fiber is already queued, so `run()` still executes it to completion (its
|
// cancellation at its current/next suspend.
|
||||||
// side effects happen; it flips `.canceled -> .ready`). The sticky `canceled`
|
//
|
||||||
// atomic is the source of truth — subsequent awaits keep raising regardless of
|
// Phase 3 (TRUE cancellation): `ready(.{ handle = f.task })` re-readies the worker
|
||||||
// the state field. True work-cancellation (the worker's next suspend raising
|
// fiber parked under the fiber impl. On resume its `suspend_raw` sees the flag and
|
||||||
// `Canceled` so it abandons its body) is Phase 3.
|
// raises `Canceled`, so the worker ABANDONS its body — post-suspend side effects
|
||||||
|
// never run. The sticky `canceled` atomic is the source of truth (`await` keeps
|
||||||
|
// raising regardless of the state field). `wake` is guarded on `.suspended`, so a
|
||||||
|
// `ready` of a not-yet-parked worker is a safe no-op (its first `suspend_raw`'s
|
||||||
|
// pre-park check then delivers the cancel without parking). Under `CBlockingIo`
|
||||||
|
// `f.task` is null and `ready` is a no-op — the worker already ran inline, and the
|
||||||
|
// sticky flag still makes `await` raise (the 1806 contract, unchanged).
|
||||||
cancel :: ufcs (f: *Future($R)) {
|
cancel :: ufcs (f: *Future($R)) {
|
||||||
|
// Wake the worker fiber ONLY while the task is still in flight (`.pending`).
|
||||||
|
// Once it has completed (`.ready`/`.failed`) or was already cancelled, its
|
||||||
|
// fiber may have been REAPED (the run loop `munmap`s + frees a `.done`
|
||||||
|
// fiber), so `f.task` would dangle — `ready` on it is a use-after-free. The
|
||||||
|
// sticky `canceled` flag still makes a subsequent `await` raise in those
|
||||||
|
// cases (the 1806 model-(a) contract), so no wake is needed there. A
|
||||||
|
// not-yet-run worker is `.pending` with a live (queued) fiber; `ready` is a
|
||||||
|
// safe no-op on it (its first `suspend_raw` pre-park check then delivers).
|
||||||
|
was_pending := f.state == .pending;
|
||||||
f.canceled.store(true, .release);
|
f.canceled.store(true, .release);
|
||||||
f.state = .canceled;
|
f.state = .canceled;
|
||||||
|
if was_pending { context.io.ready(.{ handle = f.task }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// `sleep(io, ms)` — a FAILABLE suspend for `ms` virtual milliseconds. Arms a
|
||||||
|
// timer at `now_ms() + ms` and parks via `suspend_raw`; the fired timer
|
||||||
|
// re-readies the fiber, and on resume `suspend_raw` raises `Canceled` if the task
|
||||||
|
// was cancelled while sleeping (Phase 3). So `try io.sleep(..)` inside an `async`
|
||||||
|
// worker is a cancellation point: a `cancel` lands the worker's body unwinding
|
||||||
|
// here instead of running past the sleep. No-op under `CBlockingIo` (its
|
||||||
|
// `arm_timer`/`suspend_raw` are stubs — the blocking model has no scheduler to
|
||||||
|
// advance a virtual clock).
|
||||||
|
sleep :: ufcs (io: Io, ms: i64) -> ! {
|
||||||
|
pk : ParkToken = .{ handle = null };
|
||||||
|
io.arm_timer(io.now_ms() + ms, pk);
|
||||||
|
try io.suspend_raw(@pk);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,11 @@
|
|||||||
// epoll on linux). Runs end-to-end on a matching aarch64 host, ir-only on an
|
// epoll on linux). Runs end-to-end on a matching aarch64 host, ir-only on an
|
||||||
// arch mismatch.
|
// arch mismatch.
|
||||||
#import "modules/std.sx";
|
#import "modules/std.sx";
|
||||||
|
// Phase 3 (true cancellation): `suspend_raw` reads the spawned fiber's cancel
|
||||||
|
// flag — a `*Atomic(bool)` back-ref to the `Future.canceled` — to decide whether
|
||||||
|
// to raise `error.Canceled` on resume. atomic.sx has no naked asm, so it is safe
|
||||||
|
// to pull in here (no fib_tramp re-emit).
|
||||||
|
#import "modules/std/atomic.sx";
|
||||||
// `race` synthesizes its result type (a tagged-union mirroring the input tuple's
|
// `race` synthesizes its result type (a tagged-union mirroring the input tuple's
|
||||||
// labels) and constructs the winner variant by runtime index — both need the
|
// labels) and constructs the winner variant by runtime index — both need the
|
||||||
// metatype WRITE side (`make_enum`/`make_variant`/`EnumVariant`/`field_type`).
|
// metatype WRITE side (`make_enum`/`make_variant`/`EnumVariant`/`field_type`).
|
||||||
@@ -98,6 +103,13 @@ Fiber :: struct {
|
|||||||
// not the blocking default. Behavior-preserving for fibers spawned under the
|
// not the blocking default. Behavior-preserving for fibers spawned under the
|
||||||
// default context (the capture just re-pushes that same default).
|
// default context (the capture just re-pushes that same default).
|
||||||
dctx: Context;
|
dctx: Context;
|
||||||
|
// Phase 3 (true cancellation): back-ref to this fiber's task cancel flag
|
||||||
|
// (the `Future.canceled` `Atomic(bool)`, erased to `*void`), set from
|
||||||
|
// `SpawnOpts.cancel_flag` at `spawn_raw`. `suspend_raw` consults it and
|
||||||
|
// raises `IoErr.Canceled` when set, so a cancelled worker abandons its body
|
||||||
|
// at its next suspend. `null` for a fiber spawned with no cancel channel
|
||||||
|
// (a bare `spawn`, or `spawn_raw` with `cancel_flag = null`).
|
||||||
|
cancel_flag: *void = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A pending virtual-time timer: wake `fiber` once the virtual clock reaches
|
// A pending virtual-time timer: wake `fiber` once the virtual clock reaches
|
||||||
@@ -630,6 +642,9 @@ impl Io for Scheduler {
|
|||||||
entry_fn : (*void) -> void = xx entry;
|
entry_fn : (*void) -> void = xx entry;
|
||||||
entry_fn(arg);
|
entry_fn(arg);
|
||||||
});
|
});
|
||||||
|
// Stash the cancel-flag back-ref (Phase 3): `suspend_raw` consults it on
|
||||||
|
// this fiber to raise `error.Canceled`. `null` for an uncancellable spawn.
|
||||||
|
f.cancel_flag = opts.cancel_flag;
|
||||||
return xx f;
|
return xx f;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -638,10 +653,42 @@ impl Io for Scheduler {
|
|||||||
// out here when the parked task was cancelled (wired in Phase 3). The M:1
|
// out here when the parked task was cancelled (wired in Phase 3). The M:1
|
||||||
// impl does not raise yet — it just parks the current fiber.
|
// impl does not raise yet — it just parks the current fiber.
|
||||||
suspend_raw :: (self: *Scheduler, park: *ParkToken) -> ! {
|
suspend_raw :: (self: *Scheduler, park: *ParkToken) -> ! {
|
||||||
|
// Phase 3 (true cancellation), PRE-PARK check: if this fiber's task was
|
||||||
|
// already cancelled before it reached this suspend, deliver immediately —
|
||||||
|
// do NOT park (a park with no pending waker would deadlock; the cancel's
|
||||||
|
// `ready` already fired as a no-op against a not-yet-suspended fiber).
|
||||||
|
if self.fiber_canceled(self.current) {
|
||||||
|
// A timer / fd-waiter armed by a higher-level suspend (e.g.
|
||||||
|
// `io.sleep`'s `arm_timer`) just before this call would be ORPHANED:
|
||||||
|
// raising without parking means this fiber never `wake`s (the path
|
||||||
|
// that normally evicts its timer), yet it runs to its end and is
|
||||||
|
// reaped — a later timer fire would then `wake` freed memory (UAF).
|
||||||
|
// Evict any pending timer / waiter for this fiber before unwinding.
|
||||||
|
cancel_timer_for(self, self.current);
|
||||||
|
cancel_io_waiter_for(self, self.current);
|
||||||
|
raise error.Canceled;
|
||||||
|
}
|
||||||
// Record the parking fiber so a cross-fiber `ready(park)` (the worker that
|
// Record the parking fiber so a cross-fiber `ready(park)` (the worker that
|
||||||
// completes the awaited task) can find and wake it.
|
// completes the awaited task, or a `cancel` that wakes this worker) can
|
||||||
|
// find and wake it.
|
||||||
park.handle = xx self.current;
|
park.handle = xx self.current;
|
||||||
self.suspend_self();
|
self.suspend_self();
|
||||||
|
// POST-RESUME check: a `cancel` that landed while we were parked woke us
|
||||||
|
// here (evicting any armed timer). Deliver `Canceled` so the worker
|
||||||
|
// abandons its body — its post-suspend lines never run.
|
||||||
|
if self.fiber_canceled(self.current) { raise error.Canceled; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// True iff fiber `f`'s task carries a cancel flag that is set. The flag is a
|
||||||
|
// `*Atomic(bool)` back-ref (`Fiber.cancel_flag`, erased to `*void`) to the
|
||||||
|
// owning `Future.canceled`. A null flag (uncancellable spawn / bare fiber)
|
||||||
|
// is never cancelled. A null `f` (called outside a fiber) likewise can't be
|
||||||
|
// cancelled — the caller's own loud `suspend_self` guard handles that path.
|
||||||
|
fiber_canceled :: (self: *Scheduler, f: *Fiber) -> bool {
|
||||||
|
if f == null { return false; }
|
||||||
|
if f.cancel_flag == null { return false; }
|
||||||
|
flag : *Atomic(bool) = xx f.cancel_flag;
|
||||||
|
return flag.load(.acquire);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-ready a fiber parked under `park` (its `handle` is the `*Fiber`, recorded
|
// Re-ready a fiber parked under `park` (its `handle` is the `*Fiber`, recorded
|
||||||
|
|||||||
@@ -38,79 +38,90 @@ pub const ErrorAnalysis = struct {
|
|||||||
|
|
||||||
/// Collect the error TAGS raised + the `try`-call EDGES of a function body,
|
/// Collect the error TAGS raised + the `try`-call EDGES of a function body,
|
||||||
/// for the inferred-set fix-point. Stops at nested function boundaries.
|
/// for the inferred-set fix-point. Stops at nested function boundaries.
|
||||||
pub fn collectErrorSites(self: ErrorAnalysis, node: *const Node, tags: *std.ArrayList(u32), edges: *std.ArrayList([]const u8)) void {
|
pub fn collectErrorSites(self: ErrorAnalysis, node: *const Node, tags: *std.ArrayList(u32), edges: *std.ArrayList([]const u8), dyn: *bool) void {
|
||||||
switch (node.data) {
|
switch (node.data) {
|
||||||
.raise_stmt => |rs| {
|
.raise_stmt => |rs| {
|
||||||
if (Lowering.isErrorTagLiteralNode(rs.tag)) {
|
if (Lowering.isErrorTagLiteralNode(rs.tag)) {
|
||||||
tags.append(self.l.alloc, self.l.module.types.internTag(rs.tag.data.field_access.field)) catch {};
|
tags.append(self.l.alloc, self.l.module.types.internTag(rs.tag.data.field_access.field)) catch {};
|
||||||
}
|
}
|
||||||
self.collectErrorSites(rs.tag, tags, edges);
|
self.collectErrorSites(rs.tag, tags, edges, dyn);
|
||||||
},
|
},
|
||||||
.try_expr => |te| {
|
.try_expr => |te| {
|
||||||
if (Lowering.callTargetName(te.operand)) |nm| edges.append(self.l.alloc, nm) catch {};
|
if (Lowering.callTargetName(te.operand)) |nm| {
|
||||||
self.collectErrorSites(te.operand, tags, edges);
|
edges.append(self.l.alloc, nm) catch {};
|
||||||
|
} else if (te.operand.data == .call) {
|
||||||
|
// A `try` whose callee is NOT a plain identifier — a protocol
|
||||||
|
// method (`io.suspend_raw`), a UFCS / instance method, a
|
||||||
|
// closure / fn-pointer value. Its error channel is OPAQUE to
|
||||||
|
// this static convergence (no free-fn name to resolve a set
|
||||||
|
// from), so the function genuinely propagates a dynamic error.
|
||||||
|
// Mark it so the "declared `!` but never errors" warning is
|
||||||
|
// suppressed — the `!` is load-bearing, not droppable.
|
||||||
|
dyn.* = true;
|
||||||
|
}
|
||||||
|
self.collectErrorSites(te.operand, tags, edges, dyn);
|
||||||
},
|
},
|
||||||
.block => |b| for (b.stmts) |s| self.collectErrorSites(s, tags, edges),
|
.block => |b| for (b.stmts) |s| self.collectErrorSites(s, tags, edges, dyn),
|
||||||
.if_expr => |ie| {
|
.if_expr => |ie| {
|
||||||
self.collectErrorSites(ie.condition, tags, edges);
|
self.collectErrorSites(ie.condition, tags, edges, dyn);
|
||||||
self.collectErrorSites(ie.then_branch, tags, edges);
|
self.collectErrorSites(ie.then_branch, tags, edges, dyn);
|
||||||
if (ie.else_branch) |eb| self.collectErrorSites(eb, tags, edges);
|
if (ie.else_branch) |eb| self.collectErrorSites(eb, tags, edges, dyn);
|
||||||
},
|
},
|
||||||
.while_expr => |w| {
|
.while_expr => |w| {
|
||||||
self.collectErrorSites(w.condition, tags, edges);
|
self.collectErrorSites(w.condition, tags, edges, dyn);
|
||||||
self.collectErrorSites(w.body, tags, edges);
|
self.collectErrorSites(w.body, tags, edges, dyn);
|
||||||
},
|
},
|
||||||
.for_expr => |f| {
|
.for_expr => |f| {
|
||||||
for (f.iterables) |it| {
|
for (f.iterables) |it| {
|
||||||
self.collectErrorSites(it.expr, tags, edges);
|
self.collectErrorSites(it.expr, tags, edges, dyn);
|
||||||
if (it.range_end) |re| self.collectErrorSites(re, tags, edges);
|
if (it.range_end) |re| self.collectErrorSites(re, tags, edges, dyn);
|
||||||
}
|
}
|
||||||
self.collectErrorSites(f.body, tags, edges);
|
self.collectErrorSites(f.body, tags, edges, dyn);
|
||||||
},
|
},
|
||||||
.return_stmt => |r| if (r.value) |v| self.collectErrorSites(v, tags, edges),
|
.return_stmt => |r| if (r.value) |v| self.collectErrorSites(v, tags, edges, dyn),
|
||||||
.var_decl => |v| if (v.value) |val| self.collectErrorSites(val, tags, edges),
|
.var_decl => |v| if (v.value) |val| self.collectErrorSites(val, tags, edges, dyn),
|
||||||
.const_decl => |c| self.collectErrorSites(c.value, tags, edges),
|
.const_decl => |c| self.collectErrorSites(c.value, tags, edges, dyn),
|
||||||
.destructure_decl => |d| self.collectErrorSites(d.value, tags, edges),
|
.destructure_decl => |d| self.collectErrorSites(d.value, tags, edges, dyn),
|
||||||
.assignment => |a| {
|
.assignment => |a| {
|
||||||
self.collectErrorSites(a.target, tags, edges);
|
self.collectErrorSites(a.target, tags, edges, dyn);
|
||||||
self.collectErrorSites(a.value, tags, edges);
|
self.collectErrorSites(a.value, tags, edges, dyn);
|
||||||
},
|
},
|
||||||
.multi_assign => |m| {
|
.multi_assign => |m| {
|
||||||
for (m.targets) |t| self.collectErrorSites(t, tags, edges);
|
for (m.targets) |t| self.collectErrorSites(t, tags, edges, dyn);
|
||||||
for (m.values) |v| self.collectErrorSites(v, tags, edges);
|
for (m.values) |v| self.collectErrorSites(v, tags, edges, dyn);
|
||||||
},
|
},
|
||||||
.call => |c| {
|
.call => |c| {
|
||||||
self.collectErrorSites(c.callee, tags, edges);
|
self.collectErrorSites(c.callee, tags, edges, dyn);
|
||||||
for (c.args) |a| self.collectErrorSites(a, tags, edges);
|
for (c.args) |a| self.collectErrorSites(a, tags, edges, dyn);
|
||||||
},
|
},
|
||||||
.binary_op => |b| {
|
.binary_op => |b| {
|
||||||
self.collectErrorSites(b.lhs, tags, edges);
|
self.collectErrorSites(b.lhs, tags, edges, dyn);
|
||||||
self.collectErrorSites(b.rhs, tags, edges);
|
self.collectErrorSites(b.rhs, tags, edges, dyn);
|
||||||
},
|
},
|
||||||
.unary_op => |u| self.collectErrorSites(u.operand, tags, edges),
|
.unary_op => |u| self.collectErrorSites(u.operand, tags, edges, dyn),
|
||||||
.deref_expr => |d| self.collectErrorSites(d.operand, tags, edges),
|
.deref_expr => |d| self.collectErrorSites(d.operand, tags, edges, dyn),
|
||||||
.force_unwrap => |fu| self.collectErrorSites(fu.operand, tags, edges),
|
.force_unwrap => |fu| self.collectErrorSites(fu.operand, tags, edges, dyn),
|
||||||
.null_coalesce => |nc| {
|
.null_coalesce => |nc| {
|
||||||
self.collectErrorSites(nc.lhs, tags, edges);
|
self.collectErrorSites(nc.lhs, tags, edges, dyn);
|
||||||
self.collectErrorSites(nc.rhs, tags, edges);
|
self.collectErrorSites(nc.rhs, tags, edges, dyn);
|
||||||
},
|
},
|
||||||
.field_access => |fa| self.collectErrorSites(fa.object, tags, edges),
|
.field_access => |fa| self.collectErrorSites(fa.object, tags, edges, dyn),
|
||||||
.index_expr => |ix| {
|
.index_expr => |ix| {
|
||||||
self.collectErrorSites(ix.object, tags, edges);
|
self.collectErrorSites(ix.object, tags, edges, dyn);
|
||||||
self.collectErrorSites(ix.index, tags, edges);
|
self.collectErrorSites(ix.index, tags, edges, dyn);
|
||||||
},
|
},
|
||||||
.spread_expr => |s| self.collectErrorSites(s.operand, tags, edges),
|
.spread_expr => |s| self.collectErrorSites(s.operand, tags, edges, dyn),
|
||||||
.catch_expr => |ce| {
|
.catch_expr => |ce| {
|
||||||
self.collectErrorSites(ce.operand, tags, edges);
|
self.collectErrorSites(ce.operand, tags, edges, dyn);
|
||||||
self.collectErrorSites(ce.body, tags, edges);
|
self.collectErrorSites(ce.body, tags, edges, dyn);
|
||||||
},
|
},
|
||||||
.defer_stmt => |d| self.collectErrorSites(d.expr, tags, edges),
|
.defer_stmt => |d| self.collectErrorSites(d.expr, tags, edges, dyn),
|
||||||
.push_stmt => |p| {
|
.push_stmt => |p| {
|
||||||
self.collectErrorSites(p.context_expr, tags, edges);
|
self.collectErrorSites(p.context_expr, tags, edges, dyn);
|
||||||
self.collectErrorSites(p.body, tags, edges);
|
self.collectErrorSites(p.body, tags, edges, dyn);
|
||||||
},
|
},
|
||||||
.array_literal => |al| for (al.elements) |el| self.collectErrorSites(el, tags, edges),
|
.array_literal => |al| for (al.elements) |el| self.collectErrorSites(el, tags, edges, dyn),
|
||||||
.tuple_literal => |tl| for (tl.elements) |el| self.collectErrorSites(el.value, tags, edges),
|
.tuple_literal => |tl| for (tl.elements) |el| self.collectErrorSites(el.value, tags, edges, dyn),
|
||||||
// Stop at nested function boundaries; leaves contribute nothing.
|
// Stop at nested function boundaries; leaves contribute nothing.
|
||||||
else => {},
|
else => {},
|
||||||
}
|
}
|
||||||
@@ -127,6 +138,11 @@ pub const ErrorAnalysis = struct {
|
|||||||
tags: std.ArrayList(u32),
|
tags: std.ArrayList(u32),
|
||||||
edges: std.ArrayList([]const u8),
|
edges: std.ArrayList([]const u8),
|
||||||
rt: ?*const Node,
|
rt: ?*const Node,
|
||||||
|
// The body `try`s a callee with an OPAQUE error channel (a protocol
|
||||||
|
// method / UFCS-method / closure call) — so it genuinely propagates a
|
||||||
|
// dynamic error even when no concrete tag converges. Suppresses the
|
||||||
|
// empty-set "drop the `!`" warning.
|
||||||
|
dyn: bool,
|
||||||
};
|
};
|
||||||
var work = std.StringHashMap(Node_).init(self.l.alloc);
|
var work = std.StringHashMap(Node_).init(self.l.alloc);
|
||||||
defer work.deinit();
|
defer work.deinit();
|
||||||
@@ -138,8 +154,9 @@ pub const ErrorAnalysis = struct {
|
|||||||
if (!Lowering.astIsPureBareInferred(fd.return_type)) continue;
|
if (!Lowering.astIsPureBareInferred(fd.return_type)) continue;
|
||||||
var tags = std.ArrayList(u32).empty;
|
var tags = std.ArrayList(u32).empty;
|
||||||
var edges = std.ArrayList([]const u8).empty;
|
var edges = std.ArrayList([]const u8).empty;
|
||||||
self.collectErrorSites(fd.body, &tags, &edges);
|
var dyn = false;
|
||||||
work.put(e.key_ptr.*, .{ .tags = tags, .edges = edges, .rt = fd.return_type }) catch {};
|
self.collectErrorSites(fd.body, &tags, &edges, &dyn);
|
||||||
|
work.put(e.key_ptr.*, .{ .tags = tags, .edges = edges, .rt = fd.return_type, .dyn = dyn }) catch {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Union edge contributions until no set grows (monotone → terminates).
|
// Union edge contributions until no set grows (monotone → terminates).
|
||||||
@@ -178,7 +195,7 @@ pub const ErrorAnalysis = struct {
|
|||||||
// protocol-impl method (its `!` is dictated by the protocol
|
// protocol-impl method (its `!` is dictated by the protocol
|
||||||
// contract — e.g. `Io.suspend_raw` — so a non-raising impl body
|
// contract — e.g. `Io.suspend_raw` — so a non-raising impl body
|
||||||
// is not a "drop the `!`" case; see `impl_method_names`).
|
// is not a "drop the `!`" case; see `impl_method_names`).
|
||||||
if (sorted.len == 0 and !std.mem.eql(u8, se.key_ptr.*, "main") and !self.l.impl_method_names.contains(se.key_ptr.*)) {
|
if (sorted.len == 0 and !se.value_ptr.dyn and !std.mem.eql(u8, se.key_ptr.*, "main") and !self.l.impl_method_names.contains(se.key_ptr.*)) {
|
||||||
if (self.l.diagnostics) |diags| {
|
if (self.l.diagnostics) |diags| {
|
||||||
if (se.value_ptr.rt) |rt| {
|
if (se.value_ptr.rt) |rt| {
|
||||||
diags.addFmt(.warn, rt.span, "function '{s}' is declared `!` but never errors — drop the `!`", .{se.key_ptr.*});
|
diags.addFmt(.warn, rt.span, "function '{s}' is declared `!` but never errors — drop the `!`", .{se.key_ptr.*});
|
||||||
|
|||||||
@@ -1049,7 +1049,10 @@ pub fn recordClosureShape(self: *Lowering, lam: *const ast.Lambda) void {
|
|||||||
defer tags.deinit(self.alloc);
|
defer tags.deinit(self.alloc);
|
||||||
var edges = std.ArrayList([]const u8).empty;
|
var edges = std.ArrayList([]const u8).empty;
|
||||||
defer edges.deinit(self.alloc);
|
defer edges.deinit(self.alloc);
|
||||||
self.errorAnalysis().collectErrorSites(lam.body, &tags, &edges);
|
// `dyn` (opaque-error-channel `try`) is irrelevant to closure-shape set
|
||||||
|
// widening — that signal only gates the top-level "drop the `!`" warning.
|
||||||
|
var dyn_unused = false;
|
||||||
|
self.errorAnalysis().collectErrorSites(lam.body, &tags, &edges, &dyn_unused);
|
||||||
for (edges.items) |callee| {
|
for (edges.items) |callee| {
|
||||||
for (self.calleeEscapeTags(callee)) |t| {
|
for (self.calleeEscapeTags(callee)) |t| {
|
||||||
if (!containsTag(tags.items, t)) tags.append(self.alloc, t) catch {};
|
if (!containsTag(tags.items, t)) tags.append(self.alloc, t) catch {};
|
||||||
|
|||||||
Reference in New Issue
Block a user