← board

An exception in a stackful generator body never reaches the for-in's handler

The three-way control

Same Once in all three — try ... except on E: Exception do Inc(caught) end, run 3 times, gmsg a global AnsiString:

what raises result
a plain procedure Plain called inside the try caught=3
function Gen(n): Integer; generator; stackless; driven by for x in Gen(1) caught=3
function Gen(n): Integer; generator; (stackful, coroutine) — same source otherwise Unhandled exception: Exception: helloA, process dies on the first iteration

The try is the same try, the raise is the same raise, and only the generator form changes. This is not the handler failing to match.

Why it is plausible on this path and not the other

A stackful generator body runs on its OWN coroutine stack and is entered by CoSwitch reting into it rather than by a call. The exception chain head is per-thread (EXC_TOP), so a raise on the coroutine stack walks a chain whose frames belong to the consumer's stack — there is no handler on the generator's own stack, and whatever it finds is not reachable by the unwind it then performs. A stackless step function is an ordinary call on the ordinary stack and has none of this.

What this blocks

The guard this needs

The three-way control above, as a test, once it is fixed — the stackful row is the guard and the other two are the controls that say the harness works. Note that the failure is a process death, so a value assertion catches it without any census.

Resolved — link the chain to the resumer's at body entry

CoAlloc seeds the coroutine context's saved exc_top with 0 — its own comment says so, "fresh chain on this stack" — and CoSwitch restores it on the way in, so a raise on the coroutine stack walked an empty chain, found no handler and killed the process. This was a design choice, not an oversight, which is why the fix had to say what the right semantics are rather than just patch.

Not a Track U fork. Two independent sources already answer it: pxx's own STACKLESS generators propagate to the consumer (caught=3 on the identical source), and so does the language. What was open was the implementation, which is mine to pick.

The consumer's chain head needs no new intrinsic. CoNext calls CoSwitch with pfrom = &callerctx, and CoSwitch pushes exc_top LAST (lowest address) before mov [pfrom], rsp — so [[self + CO_OFF_CALLERCTX]] is exactly the chain the consumer was running with. Three instructions in the generator body prologue, where the instance is already in rbx. No nil guard: callerctx is written on the way in and cannot be 0 at body entry, and a guard would have to branch over EmitStatusSlotX64, whose length is not fixed (the threadsafe build takes a gs: prefix) — a hand-counted distance over a variable-length emitter is a worse hazard than the one it guards.

The five rows, and what each is for

row before after
a plain procedure raising inside the try caught 5 caught 5 — control
generator; stackless; raising caught 5 caught 5 — control
generator; (stackful) raising process dies, rc=217 caught 5 — guard
a generator catching its OWN raise, then yielding again ok ok — did not swing too far
a raise past the generator's own non-matching handler died reaches the consumer

Rows 1 and 2 were green before the fix. They are in the test because without them a reader cannot tell "the guard fires" from "the harness is broken".

The row that CANNOT be written, and why that is load-bearing

There is no row for "the consumer raises while the generator is suspended inside its own try" — the case where an inherited chain would be stale in the dangerous direction. yield inside try/except/finally is rejected by the compiler ("yield inside try/except/finally is not allowed (v1)"), so a generator's handler frame can never be live across a suspension. That refusal, which exists for unrelated reasons, is what makes a once-at-entry link safe rather than merely convenient.

The assumption, written where it can be violated

The link is made ONCE, at first entry, so it holds only while the resumer's frame outlives the coroutine. True today because CoAlloc and CoNext have exactly one caller — the for-in desugar — which creates and resumes in the same frame and resumes at the loop HEAD, outside any try the loop BODY pushes. Nothing enforces that uniqueness, so the constraint is stated at both call sites (lib/rtl/coroutine.pas's CoAlloc and the desugar's paCoAlloc lookup), not only at the prologue that depends on it — a second driver would have to refresh the link per resume, and the failure mode if it does not is a longjmp into a returned frame, which would not point back here.

What this fix COSTS, measured

It turns a process death into a working program that leaks. An exception escaping the loop skips the teardown that calls CoFree, so each escaping raise loses the coroutine's 64 KB stack plus the instance — RSS 9324 kB at N=2000 and 36844 kB at N=8000, a 4.59 kB/raise slope of touched pages against a 64 KB reservation. Recorded on bug-a-a-generator-instance-is-not-freed-when-an-exception-escapes-the-for-in, which this measurement moved from UNCHECKED-for-stackful to its largest case, and it is why the new test runs N=5.

Log