CoSwitch for xtensa and riscv32
What is missing
compiler/coroutine_emit.inc's EmitCoroutineRuntime has arms for
TARGET_X86_64, TARGET_I386, TARGET_AARCH64, TARGET_ARM32, and an
explicit Error for TARGET_WASM32. Its closing comment:
Other targets land in later phases (riscv32/xtensa) — they fall through silently ON PURPOSE and are left alone here; making THEM loud belongs to refactor-a-target-dispatch-chains-fail-open, not to a registration ticket that must leave every existing target byte-identical.
That was a correct call at the time. This ticket is the "later phase".
Why it is not currently visible, and what makes it visible
lib/rtl/scheduler.pas carries its own per-arch syscall block — SYS_gettid,
SYS_epoll_create1, SYS_epoll_ctl, SYS_epoll_wait/_pwait, SYS_fcntl,
SYS_timerfd_* — for x86-64, i386, aarch64, arm32 and nobody else. So on
xtensa and riscv32 every scheduler program dies at compile time with
undefined variable (SYS_gettid), long before anything could call CoSwitch.
The compile error is the only guard. Filling in the numbers is a two-line
change that anyone would read as obviously safe, and on its own it would
convert six honest compile failures into six programs that build and jump to an
address that was never emitted, having primed the coroutine stack with the
{$else} fallback — the x86-64 pop order (8 qwords: exc, r15, r14, r13,
r12, rbx, rbp, ret) — on a 32-bit target with different callee-saved registers.
This is the inverse of the usual reading of "a missing op hides every bug in the programs it stops from compiling". Normally the block is concealing a defect and removing it is pure gain. Here the block is the safety property, and removing it alone is a regression that looks like six more green rows.
The numbers are already measured and are recorded here so that whoever does the A work does not have to re-derive them, and so that nobody fills them in without doing the A work:
| xtensa | riscv32 (asm-generic) | |
|---|---|---|
gettid |
127 | 178 |
epoll_create1 |
275 | 20 |
epoll_ctl |
19 | 21 |
epoll_wait / _pwait |
epoll_wait 18, epoll_pwait 274 |
epoll_pwait 22 |
fcntl |
67 | 25 |
read / close |
12 / 9 | 63 / 57 |
timerfd_create / _settime |
312 / 313 | 85 / 86 |
(The xtensa column is measured — one syscall per process under
qemu-xtensa -strace, with all five of the repo's established anchors
reproduced exactly. The riscv32 column is asm-generic, i.e. aarch64's, and is
recall, not measurement; verify it before use.)
The three parts, all of which must land together
compiler/coroutine_emit.inc— aCoSwitchfor each target: push the callee-saved set plusBSS_EXC_TOP, store sp into[a0], load sp from[a1], restore, return. Must be reached bycall, never inlined. Track A. xtensa has the extra wrinkle that the two ABIs differ: Call0 has an ordinary moving sp and a normal callee-saved set, while windowed rotates the register file oncall8and needs the window spilled before the stack can be handed to another context — a windowedCoSwitchis a materially harder problem than a Call0 one, and the two should be sequenced, not bundled.lib/rtl/scheduler.pas— the syscall block above, anepoll_eventlayout (both targets are 32-bit and need the explicit pad word that aarch64/arm32 use, not x86's packed record), and aSpawnSizedpriming block whose slot count and return-address offset match part 1's pop order exactly. Track B.- The six rows wired into
test-xtensa/test-riscv32:test_asyncecho,test_channel,test_reactor,test_scheduler,test_scheduler_exc,test_timer.
Part 2 is worthless without part 1 and dangerous alone, which is the whole reason this is one ticket and not two.
Provenance
Found while adding xtensa's row to lib/rtl/platform/posix/platform_backend.pas
([[feature-s-the-xtensa-row-of-the-posix-syscall-table]]). That ticket unblocks
8 of the 14 compile failures and leaves these 6 deliberately red.
palthread.pas landmine — read this before you lift the __pxxclone guard
Found by frank-coordinator grepping for the sibling of the PalBackendMmapAnon
MAP_ANONYMOUS fix (97e96fc1b); scope corrected by frankS; the flag value
below is measured by frankA rather than cited.
Not a bug today. lib/rtl/palthread.pas defines MAP_ANON_PRIV = $22 at
:84, used at :161 to mmap every thread stack. That constant sits outside
the arch split, which starts at :87, while the syscall numbers sit inside
it — and both xtensa and riscv32 fall to the {$else} at :120, where
SYS_mmap = -1 and the __pxxclone compile-error fires first. So nothing is
silently wrong right now.
It becomes wrong the moment this ticket lands, because lifting the guard removes the thing that is currently saving it.
The two targets are NOT symmetric — this is the part to get right:
SYS_mmap |
MAP_PRIVATE|MAP_ANONYMOUS |
|
|---|---|---|
| riscv32 | 222 (generic ABI), placeholder is -1 |
$22 = 34 — already correct, same as x86-64/i386/aarch64/arm |
| xtensa | 80 (its own numbering; generic 222 is Unknown syscall 222) |
$802 = 2050 — the sole outlier |
So: when moving MAP_ANON_PRIV inside the arch split, xtensa takes $802 and
every other arch takes $22 (frankS's wording, and the reason for it is that
a note grouping the two targets invites someone to "fix" riscv32's already-correct
$22 to $802 and reproduce the EBADF that 97e96fc1b just removed).
riscv32 needs the syscall block only; xtensa needs the syscall block and the
flags constant.
$800 is MEASURED, not read off a table or taken from a comment. Under
qemu-xtensa -strace, mmap2 with flags $800 alone is decoded by qemu as
MAP_ANONYMOUS and returns EINVAL (no MAP_PRIVATE/MAP_SHARED); $802 is
decoded as MAP_PRIVATE|MAP_ANONYMOUS and maps; $22 is decoded as
MAP_PRIVATE|0x20 — 0x20 is not a named flag on this target — and returns
EBADF, mapping fd -1. That is qemu's own flag decoder naming the bit,
independent of builtinheap.pas:971's comment, which had been the only source.
Scope of that measurement (frankS): qemu's decoder is qemu's, not the
kernel's — but for this claim qemu is the right authority rather than a weaker
one, because hosted xtensa runs under qemu-user, so it is the execution target
for the profile where PalBackendMmapAnon and the thread-stack mmap actually
run. Read it as measured under qemu-xtensa 10.2.1, the execution target for
the hosted profile — not as a claim about silicon. The bare/ESP profile never
reaches mmap, so nothing there depends on it.
Re-measured 2026-09-23 (frank) — half the work is done and the trap is defused
Summary rewritten. Both load-bearing claims in the old one had gone stale, in the direction that OVERSTATES the work and misreads its urgency.
riscv32 has a coswitch. coroutine_emit.inc has arms for x86-64, i386,
aarch64, arm32 and riscv32, each setting CoSwitchAddr; the riscv32 one
landed in fc70d0cbe. Verified from the code and then from behaviour rather
than from either summary:
| target | --emit-obj test/lib_asyncnet6.pas |
|---|---|
esp32c3 |
ok (649 procs) |
riscv32 |
ok (649 procs) |
xtensa |
error: coroutines are not implemented for target xtensa |
Since esp32c3 is riscv32 and every ESP example in the tree is a -c3, coroutines and async work today on the target we actually exercise. The hole is esp32/esp32s2/esp32s3 — the xtensa parts.
And the silence is gone, which is what this ticket was really about. The body below is written around a trap: a silent fall-through that would turn into "six programs stop erroring and start jumping into code that was never emitted" the moment a syscall block appeared. There is now a loud refusal in the way, so acquiring a syscall block cannot spring anything. This is a feature gap, not an armed trap, and it should be ranked as one.
What would make it a trap again — stated as a mechanism so it does not
decay the way the old summary did: the refusal at coroutine_emit.inc's tail
being removed or routed around without an xtensa arm landing in the same
commit. Nothing about a syscall table matters any more.
Read the sections below as the ORIGINAL 2026-09 report, not as current state.
Landed 2026-09-24 (frank) — Call0, all three parts, one commit
Read the sections above as the original report. Two things in them are now
stale and are left in place: the syscall table's timerfd_settime 313 row is
right but incomplete (see below), and "The three parts, all of which must land
together" is satisfied rather than pending.
What the precondition check found first, because it nearly stopped this
Before writing any code I checked whether the six test rows could even RUN on
hosted xtensa, having just filed
[[bug-a-xtensa-emits-muluh-for-an-integer-multiply-and-no-stock-qemu-core-implements-it]]
— five of the six print integers, and integer formatting SIGILLs there. The
answer was that --xtensa-soft-mulhigh has shipped all along, which closed
that ticket and cleared this one's precondition in the same measurement. Had I
believed my own ticket I would have concluded this work was unverifiable and
parked it.
The scope, and why Call0 alone is not half a job
XtensaABI defaults to XTENSA_ABI_CALL0 (compiler.pas) and only an explicit
--xtensa-abi=windowed moves it. Measured, because a Makefile comment in this
tree claimed the opposite and was corrected in this commit: a flagless xtensa
build is byte-identical to --xtensa-abi=call0 and differs from
--xtensa-abi=windowed. So Call0 is what every xtensa and esp32s3 build gets
unless asked otherwise, and the practical hole is closed. Windowed is
[[feature-a-a-windowed-abi-coswitch-for-xtensa]], sequenced as this body asks.
Part 1 — the stub and the lowering, and why they are one commit
coroutine_emit.inc gains an xtensa Call0 arm; ir_codegen_xtensa.inc gains
IR_COSWITCH. Separately, either one is a live miscompile: a lowering with
no stub calls CoSwitchAddr = 0, which is offset 0 of .text — the program
entry — reached from inside user code, so it hangs or re-enters rather than
diagnosing. The 2026-08-31 note in coroutine_emit.inc predicted exactly this
("give riscv32 one without adding a stub arm here and the downstream guard
disappears"), and that note's else is now a fail-closed default for a target
that does not exist yet, with the prediction restated for the next backend.
Frame: 16 bytes — exc_top, a15, a0, pad. Three slots, and the authority is
not the ABI, it is exception_emit.inc: the xtensa Call0 jmpbuf this compiler
ships saves exactly (a15, sp, a0). Call0's nominal callee-saved set is
a12–a15, and this backend treats a12/a13 as expression scratch. A switch saving
more than the jmpbuf would claim a guarantee the exception runtime does not
make; saving less would break both.
Two things riscv32's arm does not need. The entry is nop-padded to 4 bytes
because a call0 target must be 4-aligned — Call0 encodes the low two bits as
zero, so an unaligned entry silently enters at a different address rather than
faulting. And the BSS_EXC_TOP address comes from the literal-pool idiom
(XtensaEmitLitHeader + l32r) because xtensa has no PC-relative add.
Part 2 — scheduler.pas, and every number measured by NAME
One probe program under qemu-xtensa -strace, which prints the kernel's own
identity for each number instead of my belief about it:
| xtensa | how it would have gone wrong | |
|---|---|---|
gettid |
127 | in an abandoned range (118 exit, 119 exit_group, 124 tkill) |
epoll_create1 / _ctl / _wait |
275 / 19 / 18 | it has a real epoll_wait, so it takes the x86 arm, not riscv32's pwait |
fcntl / read / close |
67 / 12 / 9 | — |
timerfd_create / _settime |
312 / 313 | NOT time64 — see below |
mmap / mprotect / munmap |
80 (mmap2) / 82 / 81 | asm-generic 222 is Unknown syscall, and 64 is utime, not mmap2 |
exit_group |
119 | confirmed twice, by name and by the process really leaving rc=7 |
The time64 row is the one that would have been silently wrong. Both xtensa
and riscv32 are 32-bit ESP targets, so copying riscv32's block is the obvious
move — and riscv32 is time64-only, needing timerfd_settime64(411) and a 64-bit
itimerspec. On xtensa timerfd_settime(313) answers EFAULT (it exists and
tried to read the pointer) while 410 is Unknown syscall. So xtensa
deliberately does not get SCHED_TIME64. This is precisely the failure the
file's own riscv32 note warns about: a number from the wrong table is not a
compile error, it is a different syscall at runtime.
And the palthread.pas landmine above has a SIBLING in this file, found by
looking for it rather than by anything failing: scheduler.pas had
MAP_ANON_PRIV = $22 outside the arch split, used to map every coroutine stack.
Now split, xtensa $802. The wrong value does not fail loudly — $22
decodes as MAP_PRIVATE|0x20, the kernel takes fd −1 literally and returns
EBADF, mapBase > 0 is False, and the code falls back to GetMem. The
coroutine then runs on an unguarded heap stack, silently, which is the one
outcome the guard-page machinery exists to prevent. Measured on this file's exact
call shape, with the landmine's warning against "tidying" riscv32 to $802
carried into the code comment.
The priming is byte-for-byte riscv32's, deliberately, so it is one shape with two register names — with a comment saying so, because two identical arms read as a copy-paste mistake.
Part 3 — the rows, and what makes them non-vacuous
Six rows in test-xtensa, asserted against the x86-64 build of the same
source rather than literal strings: the pair is then a RELATION carrying no
per-target constant, so a row cannot encode an xtensa-specific wrong answer as
its own expectation. Plus an esp32s3-vs-esp32c3 lib_asyncnet6 --emit-obj
parity row, which is the ESP-facing point of all this, and a negative control
asserting the windowed refusal by name.
All six MATCH the x86-64 oracle under qemu-xtensa. esp32s3 now builds
lib_asyncnet6 (685 procs) where before it stopped at uses scheduler.
Positive control, drawn from the population: perturbing the priming by one
slot — @CoStart to top+4 instead of top+8 — makes test_scheduler and
test_asyncecho segfault under qemu. So the rows exercise the CoSwitch
contract rather than merely importing the unit. riscv32's four comparable rows
were re-run unchanged and still match, so the shared-file edits did not move it.
One guard was born unable to pass and was caught by running it: the windowed
control first redirected 2> by analogy with other refusal rows in the Makefile,
and pascal26 writes diagnostics to STDOUT — the .err file came back 0 bytes
and the grep failed while the refusal worked perfectly. The other rows get away
with 2>/dev/null because they only assert a nonzero exit and never read the
text. Fixed to >, and the grep was then checked to discriminate (a
deliberately wrong pattern returns 1).
What is NOT claimed
Hosted xtensa under qemu-xtensa, Call0, with --xtensa-soft-mulhigh — so
arithmetic is not bit-identical to silicon and any numeric verdict from these
rows must name the flag. The esp32s3 row is a compile assertion; nothing
here ran on an xtensa ESP part. Windowed is untouched and refused.
Log
- 2026-09-24 — resolved; this names the commit that carried the resolve, which is not always the one that carried the change — commit 1dd37bbb4d.