From 21ed947773d439050ed43bb5a80841a9c3796f7c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 07:00:23 +0000 Subject: [PATCH 1/3] mmaptrim-probe: 0c answers 5, at 64KB, confirming the ceiling Section 0c calls _SEGATTACH in a loop rather than going through mmap and gets 5 -- the same five 0b got at 2MB and at 16MB, now at 64KB. A 256-fold range of sizes for a total of 320KB, which leaves no reading in which memory or address space is the limit, and it says the ceiling did not move when SEG_THRESHOLD did, which is the point of asking the kernel rather than the library. 0b is 64/64/64 on the same run: mmap spends no slot at any size a program is likely to ask for. Section 0's addresses are the allocator's now (0x436000, 0x837000, ... at offset 0x37000, 4MB apart) and still misaligned, so zend still trims, and section 1's head trim is 0x1c8000, so the real path exercises both trims. Notes and the invariant updated to cite 0c, since 0b can no longer produce the number. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WGAwvvTwDg2yknFkmZ3qzs --- CLAUDE.md | 29 +++++++++++++++++++++-------- docs/notes/libap.md | 17 +++++++++++++++++ sys/lib/tests/mmaptrim-probe.c | 6 ++++++ 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d93449a90..9c566826f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -485,8 +485,16 @@ and 3 say both trims survive. **But three of the eight `mmap` calls FAILED**, and 10MB is not a memory limit, so **section 0b** asks how many mappings a process may HOLD, at three sizes, *with the prediction written down first*: slots give the same count at every size, address -space gives one falling as 1/size. It is **5 at 2MB and 5 at 16MB**, -and 64 at a size below `SEG_THRESHOLD` — the control. **Two of its +space gives one falling as 1/size. It was **5 at 2MB and 5 at 16MB**, +and 64 at a size below `SEG_THRESHOLD` — the control. ***AND THE FIX +DISARMED IT***: with `SEG_THRESHOLD` at 64MB all three of 0b's sizes +take the malloc arm, so it answers 64/64/64 — right, worth keeping as +the regression check that `mmap` spends no slot, and *unable to say +how many slots exist*. **Section 0c** asks the platform instead, +`_SEGATTACH` in a loop at 64KB: **5**, the same number over a 256-fold +range of sizes for a total of 320KB, which excludes memory outright. +*A section whose cases have collapsed onto one path is a check that +can only pass.* **Two of its sections can kill the process, so it takes an ARGUMENT**: no argument runs the tail trim, `3` runs the head trim, never sharing a run, because a section that can kill the process hides every section after @@ -1690,12 +1698,17 @@ the index, so that nothing here is a surprise. reason it scrubs `FD_ISTTY` and `FD_ISREG`. `FD_BUFFEREDX` is poison: `read()` and `select()` both answer EIO for it without touching the fd. -- **A process gets FIVE spare segment slots, measured**, so `mmap` - cannot be backed by `segattach` in general: `mmaptrim-probe` section - 0b maps until refused at three sizes and answers 5 at 2MB and 5 at - 16MB -- *the same count for eight times the memory*. `SEG_THRESHOLD` - is 64MB for that reason and anything below it is `malloc`-backed, - page-aligned by hand since `malloc` is not. +- **A process gets FIVE spare segment slots, measured twice and the + second time at 64KB**, so `mmap` cannot be backed by `segattach` in + general. `mmaptrim-probe` section 0b got 5 at 2MB and 5 at 16MB + through `mmap`; **section 0c calls `_SEGATTACH` directly and gets 5 + at 64KB**. *Five, over a 256-fold range of sizes and a total of + 320KB* -- so memory and address space are excluded outright, and the + number is the kernel's rather than the library's. `SEG_THRESHOLD` is + 64MB for that reason and anything below it is `malloc`-backed, + page-aligned by hand since `malloc` is not. **0c is the one that + still asks**: every size 0b uses is now under the threshold, so it + answers 64/64/64 and can only confirm that `mmap` spends no slot. - `/dev/snarf` is the clipboard and has no concept of ownership. - Plan 9 has no loopback unless `ip/ipconfig loopback /dev/null 127.1` has been run -- and that belongs in the machine's startup, not in diff --git a/docs/notes/libap.md b/docs/notes/libap.md index 9f8a0668b..a06443f2d 100644 --- a/docs/notes/libap.md +++ b/docs/notes/libap.md @@ -3900,6 +3900,23 @@ anything a program is at all likely to allocate. *The limit was known and the constant was chosen as though it were not.* At 1MB, php's Zend allocator -- 2MB chunks -- would have stopped at about 10MB of heap. +***AND THE NUMBER IS CONFIRMED INDEPENDENTLY, AT 64KB.*** Section 0c +calls `_SEGATTACH` in a loop rather than going through `mmap`, and +gets **5** -- the same five, now over a 256-fold range of sizes and a +total of 320KB, which leaves no reading in which memory or address +space is the limit. It also says the ceiling did not move when +`SEG_THRESHOLD` did, which is the point of asking the kernel rather +than the library. + +*That section exists because the fix disarmed the one that found the +problem.* With the threshold at 64MB all three of 0b's sizes take the +malloc arm, so it answers 64/64/64 -- correct, and worth keeping as +the regression check that `mmap` spends no slot, but **its smallest +size has stopped being a control and the run says nothing about how +many slots exist**. Read on its own it is indistinguishable from the +Linux control. A section whose cases have collapsed onto one path is +a check that can only pass. + 64MB now: a program needs five simultaneous 64MB mappings to reach the ceiling, and the detachable path is kept for the only case worth a slot, a mapping big enough that leaving it in the data segment for the diff --git a/sys/lib/tests/mmaptrim-probe.c b/sys/lib/tests/mmaptrim-probe.c index 23a05c096..7966339b1 100644 --- a/sys/lib/tests/mmaptrim-probe.c +++ b/sys/lib/tests/mmaptrim-probe.c @@ -265,6 +265,12 @@ main(int argc, char **argv) * reaches past the POSIX surface on purpose: the number it reports * is a property of the kernel, and going through mmap to get it * would measure the library's policy instead. + * + * **MEASURED: 5.** The same five 0b got at 2MB and at 16MB, now at + * 64KB -- *a 256-fold range of sizes, and 320KB in total* -- which + * leaves no reading in which memory or address space is the limit. + * It also says the number did not move when SEG_THRESHOLD did, + * which is the point of asking the kernel rather than the library. */ #ifndef __linux__ { From 3a0e75726ef0d8ad58afd0bf83129f21972e70cb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 07:19:54 +0000 Subject: [PATCH 2/3] libap: ucontext for amd64 php's Zend/zend_fibers.c is unconditional -- Fiber is a language class -- and wants either boost.context's GAS assembly or . APExp had neither. This is the second, chosen because it is libap's own code and reusable; the other ten architectures can follow the same shape, which is why the machine part is ucontext_arch.h and the register work is in arch/amd64. It is setjmp.s with the return-value convention changed, and deliberately so: the two problems are the same one. Both of that file's findings carry over. Eight registers and not two, because 6c has REGEXT = D_R15 and R13/R12 can follow R15/R14 as caches for global variables, so a switch restoring only SP and PC leaves the resumed code reading another context's globals. And the restore must not write to the stack: ADDQ $8,SP then JMP rather than storing the PC at 0(SP) and RETing, since a context resumed at the top of a fresh fiber stack is exactly the case that faults. AX is zeroed before the jump, for the reason longjmp does it: without that line a getcontext resumed by setcontext returns whatever was in AX and its caller believes the number. The first design kept the ucontext pointer in a callee-saved register across the context's own function. There are none: a host 6c -S on a function that computes three values, calls through a function pointer and then uses them spills all three to the stack and touches BX and R12-R15 not at all. The pointer goes on the new stack instead. That leaves one number spanning two languages -- makecontext writes the pointer at base[UC_ARGMAX+1], _ctxfinish reads it at sp[UC_ARGMAX] -- and off by one the pointer read back is an argument, so uc_link is garbage and the usual consequence is exit(0), indistinguishable from a clean finish at a prompt. So _ctxreturn passes its stack pointer to C and does nothing else; no offset into ucontext_t appears in assembly at all. UC_ARGMAX is a cap rather than a convention because only argument zero is in a register here, so the bookkeeping cell has to follow a fixed number of slots. mc_regs[3..7] are left as they are, which is why POSIX makes getcontext a precondition of makecontext: those registers may hold 6c's caches for global variables, so the right value for a new context is the caller's. stack_t is defined in and not in . POSIX puts it beside sigaltstack and APExp has no sigaltstack, so a probe compiling `stack_t ss;' against would answer yes and then call the function. The external/ grep found 39 files, every C use a consumer behind HAVE_SIGALTSTACK and the two config.h hits gnulib's replacement slot. Two instruments. ucontext-test.c is the switch and has a real reference implementation, which is rare here: 0 failures against glibc before the VM sees it. makectx-xcheck.{c,sh} is the frame arithmetic on the host, and it compiles the real ucontext.c rather than a copy -- no extraction, no markers to rot -- because everything structural in that file is C. It has no external oracle, so its control is a deliberate break: the ucp cell off by one gives 3 failures, SP not one slot below gives 1. And its first run reported its own stub. Section 3 failed on "did not exit" because the real _setmcontext never returns while the stub recorded and returned, falling through to _ctxfinish's exit(0). The stub longjmps now. The assertion was right and the fake was wrong. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WGAwvvTwDg2yknFkmZ3qzs --- .gitignore | 4 + amd64/include/ape/ucontext_arch.h | 32 +++ docs/notes/libap.md | 119 +++++++++++ sys/include/ape/ucontext.h | 62 ++++++ sys/lib/tests/makectx-xcheck.c | 202 ++++++++++++++++++ sys/lib/tests/makectx-xcheck.sh | 60 ++++++ sys/lib/tests/ucontext-test.c | 250 +++++++++++++++++++++++ sys/src/ape/lib/ap/arch/amd64/mcontext.s | 128 ++++++++++++ sys/src/ape/lib/ap/arch/amd64/mkfile | 2 + sys/src/ape/lib/ap/arch/amd64/ucontext.c | 220 ++++++++++++++++++++ 10 files changed, 1079 insertions(+) create mode 100644 amd64/include/ape/ucontext_arch.h create mode 100644 sys/include/ape/ucontext.h create mode 100644 sys/lib/tests/makectx-xcheck.c create mode 100644 sys/lib/tests/makectx-xcheck.sh create mode 100644 sys/lib/tests/ucontext-test.c create mode 100644 sys/src/ape/lib/ap/arch/amd64/mcontext.s create mode 100644 sys/src/ape/lib/ap/arch/amd64/ucontext.c diff --git a/.gitignore b/.gitignore index bb99cb074..4e2eb4e99 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,7 @@ sys/src/ape/cmd/clisp/build-*/ # munmaptrim-xcheck's generated extract and its host binary sys/lib/tests/munmaptrim-extract.h sys/lib/tests/munmaptrim-xcheck + +# makectx-xcheck's host binary and its scratch include directory +sys/lib/tests/makectx-xcheck +sys/lib/tests/.makectx-xcheck.d/ diff --git a/amd64/include/ape/ucontext_arch.h b/amd64/include/ape/ucontext_arch.h new file mode 100644 index 000000000..ba28c10cc --- /dev/null +++ b/amd64/include/ape/ucontext_arch.h @@ -0,0 +1,32 @@ +#ifndef __UCONTEXT_ARCH_H +#define __UCONTEXT_ARCH_H + +/* + * amd64 machine context for . + * + * ***THE EIGHT SLOTS ARE setjmp's EIGHT, AND THAT IS DELIBERATE.*** + * `arch/amd64/setjmp.s' saves SP, PC, BP, BX, R12, R13, R14, R15 and its + * header says why the last six are there: APExp's 6c has + * REGEXT = D_R15, so R15 and R14 are reserved program-wide + * (`6c/reg.c:55-57') and R13/R12 can follow them as external register + * caches for global variables. A context switch that restored only + * SP and PC would leave the resumed code reading another context's + * cached globals. *ucontext has exactly setjmp's problem, so it gets + * exactly setjmp's answer* -- and `arch/amd64/ucontext.s' is that file's + * save/restore sequence with the return-value convention changed. + * + * The order is fixed because the assembly indexes it by number. It is + * the ONLY thing the assembly knows about any of these structures: + * everything else -- `uc_link', `uc_stack', `uc_sigmask' -- is handled + * in C, so no struct offset is ever written down twice. + */ +typedef struct mcontext_t { + unsigned long long mc_regs[8]; +} mcontext_t; + +/* names for the three slots `makecontext' has to fill */ +#define _MC_SP 0 +#define _MC_PC 1 +#define _MC_ARG 2 /* BP, which is 6c's RARG: the first argument */ + +#endif /* __UCONTEXT_ARCH_H */ diff --git a/docs/notes/libap.md b/docs/notes/libap.md index a06443f2d..444b59060 100644 --- a/docs/notes/libap.md +++ b/docs/notes/libap.md @@ -3945,3 +3945,122 @@ already wrapped `free`, and the same break gives 3 failures. *A check that cannot fail is not a check*, and the way it got written is worth naming: the assertion was phrased against the thing under test instead of against something independent of it. + +## ucontext for amd64 + +php's `Zend/zend_fibers.c` is unconditional -- `Fiber` is a language +class -- and wants either boost.context's GAS assembly or +``. APExp had neither. This is the second, chosen because +it is libap's own code and reusable beyond php; the other ten +architectures can follow the same shape. + +**Three files.** `arch/amd64/mcontext.s` holds `_getmcontext`, +`_setmcontext`, `_swapmcontext` and the `_ctxreturn` stub; +`arch/amd64/ucontext.c` holds the four POSIX entry points and +`_ctxfinish`; `sys/include/ape/ucontext.h` and +`amd64/include/ape/ucontext_arch.h` are the interface. + +**It is `setjmp.s` with the return-value convention changed**, and +deliberately so: the two problems are the same one. Both of that file's +findings carry over unchanged -- + +* **eight registers, not two.** 6c has `REGEXT = D_R15`, so R15 and R14 + are reserved program-wide (`6c/reg.c:55-57`) and R13/R12 can follow + them as caches for global variables. A switch restoring only SP and + PC leaves the resumed code reading another context's globals. +* **the restore must not WRITE to the stack.** `ADDQ $8,SP` then `JMP` + instead of storing the PC at `0(SP)` and `RET`ing. A context resumed + at the top of a fresh fiber stack is exactly the case that faults. + +**And AX is zeroed before the jump.** Without it a `getcontext` resumed +by `setcontext` returns whatever was in AX and its caller believes the +number -- the same reason `longjmp` sets it, found by writing the +section that checks it rather than by reasoning. + +### No register survives a call here, and that is measured + +The first design kept the ucontext pointer in a callee-saved register +across the context's own function. **There are no callee-saved +registers.** `hostkencc/out/bin/6c -S` on a function that computes three +values, calls through a function pointer and then uses them spills all +three to the stack and touches BX and R12-R15 not at all. So the +pointer goes on the new stack instead. + +*`main9.s:7` calls R12-R15 "callee-saved REGEXT registers", and both +statements can be true* -- a register the allocator never hands out is +trivially preserved -- but nothing in this implementation now depends on +either reading, which is the point. + +### The one number that spans two languages + +`makecontext` writes the ucontext pointer at `base[UC_ARGMAX+1]`; +`_ctxfinish` reads it at `sp[UC_ARGMAX]`, where `sp` is what +`_ctxreturn` hands over after the RET has popped one slot. **Off by one +and the pointer read back is an argument**, so `uc_link` is garbage and +the usual consequence is `exit(0)` -- indistinguishable from a clean +finish at a shell prompt. + +`_ctxreturn` therefore passes its stack pointer to C and does nothing +else: *the frame layout belongs to `makecontext`, which is C, so the +stub carries no magic number.* No offset into `ucontext_t` appears in +assembly at all. + +**`UC_ARGMAX` is a cap and not a convention.** On an ABI that passes the +first six arguments in registers the stack image does not change shape +with `argc`, so the bookkeeping cell can sit at a fixed offset whatever +the count -- how glibc does it. Here only argument zero is in a register +(6c's `RARG` = BP) and the rest are contiguous from `8(FP)`, so the cell +must follow a FIXED number of argument slots. Eight, chosen rather than +derived; php passes none. + +**`mc_regs[3..7]` are left exactly as they are, and that is why POSIX +makes `getcontext` a precondition of `makecontext`**: those registers +may hold 6c's caches for global variables, so the right value for a new +context is the caller's, which the required `getcontext` already put +there. Zeroing them would hand the new context a null view of every +register-cached global. + +**`stack_t` is defined in `` and not in ``.** +POSIX puts it beside `sigaltstack`, and APExp has no `sigaltstack`: a +probe compiling `stack_t ss;` against `` would answer yes and +the next thing such a package does is call the function. *This tree's +commonest bug shape with the sign flipped* -- not a stub answering +success but a TYPE answering for a capability that is not there. The +`external/` grep found 39 files; every C use is a consumer behind +`HAVE_SIGALTSTACK`, and the two `config.h` hits are gnulib's +replacement slot, which a plain typedef does not disturb. + +### Two instruments, and what each can and cannot say + +`sys/lib/tests/ucontext-test.c` is the switch, and **it has a real +reference implementation**, which is rare here: glibc has ucontext, so +all six sections were checked against a working one before the VM saw +them. 0 failures on gcc. A failure on Plan 9 that also fails on glibc is +the test's bug. + +`sys/lib/tests/makectx-xcheck.{c,sh}` is the frame arithmetic, on the +host. **It compiles the real `ucontext.c` rather than a copy** -- no +extraction, no markers to rot -- with stubs for the three assembly +primitives, which is possible only because everything structural in that +file is C. It exists because the off-by-one above fails *silently* on +the VM and costs a rebuild to find. No external oracle, so its control +is a deliberate break, run and reverted: the ucp cell off by one gives +**3** failures, `SP` not one slot below gives **1**. + +***AND ITS FIRST RUN REPORTED ITS OWN STUB.*** Section 3 failed on "did +not exit", because the real `_setmcontext` jumps away and never returns +while the stub recorded and returned, falling through to `_ctxfinish`'s +`exit(0)`. The stub `longjmp`s now, which is what "does not return" +means in a host program. *The instrument reporting itself is the shape +to recognise rather than argue with* -- the assertion was right and the +fake was wrong. + +**What neither covers**: `uc_sigmask` is saved and restored through +`sigprocmask`, which on APE is a read and a write of one global +(`signal/sigprocmask.c`), and nothing here exercises a context switch +with signals actually blocked. And the header could not be +syntax-checked against the APE headers on the host -- they reach each +other through absolute `/sys/include/ape/...` paths that resolve only +inside `apexp-sh` -- so the gcc check symlinks the two ucontext headers +into a scratch directory instead and supplies `sigset_t` from glibc's +own ``. diff --git a/sys/include/ape/ucontext.h b/sys/include/ape/ucontext.h new file mode 100644 index 000000000..9b6629c59 --- /dev/null +++ b/sys/include/ape/ucontext.h @@ -0,0 +1,62 @@ +#ifndef __UCONTEXT_H +#define __UCONTEXT_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * POSIX user contexts. + * + * WHERE IT CAME FROM. php's `Zend/zend_fibers.c' is unconditional -- + * `Fiber' is a language class -- and needs one of two context switches: + * boost.context's GAS assembly, or this. APExp had neither. See + * `docs/notes/php.md'. + * + * ***`stack_t' IS DEFINED HERE AND NOT IN , ON PURPOSE.*** + * POSIX puts it in , beside `sigaltstack' -- and **APExp has + * no `sigaltstack'**. Putting the type where POSIX puts it would make + * an autoconf probe that compiles `stack_t ss;' against + * answer YES, and the next thing such a package does is call + * `sigaltstack'. *That is this tree's commonest bug shape with the + * sign flipped*: not a stub answering success, but a TYPE answering for + * a capability that is not there. Here the type is reachable only by + * including , which is a deliberate act. + * The `external/' grep before adding the name found 39 files; every + * C use is a CONSUMER behind `HAVE_SIGALTSTACK', and the two `config.h' + * hits are gnulib's `/`*` #undef stack_t *`/' replacement slot, which a + * plain typedef does not disturb. + */ +typedef struct { + void *ss_sp; + long ss_size; + int ss_flags; +} stack_t; + +/* + * `uc_mcontext' IS FIRST and must stay first: `_getmcontext' and + * friends are handed `&ucp->uc_mcontext' by the C wrappers, so nothing + * in the assembly depends on this layout -- but keeping the machine + * state at offset 0 means a debugger looking at a `ucontext_t' finds + * the registers where it expects them. + */ +typedef struct __ucontext { + mcontext_t uc_mcontext; + struct __ucontext *uc_link; + stack_t uc_stack; + sigset_t uc_sigmask; +} ucontext_t; + +extern int getcontext(ucontext_t *); +extern int setcontext(const ucontext_t *); +extern int swapcontext(ucontext_t *, const ucontext_t *); +extern void makecontext(ucontext_t *, void (*)(void), int, ...); + +#ifdef __cplusplus +} +#endif + +#endif /* __UCONTEXT_H */ diff --git a/sys/lib/tests/makectx-xcheck.c b/sys/lib/tests/makectx-xcheck.c new file mode 100644 index 000000000..b35bf5e0f --- /dev/null +++ b/sys/lib/tests/makectx-xcheck.c @@ -0,0 +1,202 @@ +/* makectx-xcheck.c -- does `makecontext' build the frame that + * `_ctxreturn' and `_ctxfinish' expect? Checked on the build host. + * + * sh makectx-xcheck.sh + * + * ***A CROSS-CHECK, AND IT COMPILES THE REAL FILE RATHER THAN A COPY + * OF IT.*** `lib/ap/arch/amd64/ucontext.c' is compiled straight into + * this program -- no extraction, no transcription, no markers to rot -- + * with stubs standing in for the three assembly primitives and for + * `sigprocmask'. That is possible because everything structural in + * that file is C; the assembly knows only "eight slots". + * + * WHY IT IS WORTH A FILE. The one number that has to agree across the + * two languages is where the ucontext pointer sits in the new frame: + * `makecontext' writes it at `base[UC_ARGMAX+1]' and `_ctxfinish' reads + * it at `sp[UC_ARGMAX]', where `sp' is what `_ctxreturn' hands over + * after the RET has popped one slot. **Off by one and the pointer read + * back is an argument**, so `uc_link' is garbage -- and the usual + * consequence is `exit(0)', which from a shell prompt is + * indistinguishable from a clean finish. *A silent wrong answer is + * exactly what a host check is for*, and on the VM the same mistake + * costs a full rebuild to find. + * + * It does NOT test the context switch: that is machine code for a + * machine this is not. `ucontext-test.c' is the instrument for that, + * and it has glibc as a reference implementation. + * + * THE CONTROL IS A DELIBERATE BREAK, run and reverted, because there is + * no external oracle for a layout this file and `ucontext.c' define + * between them: + * + * `base[UC_ARGMAX+1]' -> `base[UC_ARGMAX]' **3 failures** + * `(...)base - 8' -> `(...)base' **1 failure** + * + * Exit status is the failure count. + */ + +#include +#include +#include +#include +#include + +#include /* the tree's, through the script's -I */ + +/* ---- stubs for what lives in mcontext.s ---------------------- */ + +void _ctxreturn(void) { } + +/* + * ***THE STUB MUST NOT RETURN, AND THE FIRST VERSION DID.*** The real + * `_setmcontext' jumps away and never comes back, so `_ctxfinish's + * `exit(0)' is unreachable once a `uc_link' has been followed. A stub + * that simply recorded and returned fell through to that `exit', and + * section 3's "did not exit" check failed -- *the instrument reporting + * its own stub, which is the shape to recognise rather than to argue + * with*. It longjmps instead, which is what "does not return" means in + * a host program. + */ +static const mcontext_t *last_set; +static jmp_buf away; + +void +_setmcontext(const mcontext_t *mc) +{ + last_set = mc; + longjmp(away, 1); +} + +void _swapmcontext(mcontext_t *o, const mcontext_t *n) { (void)o; last_set = n; } +int _getmcontext(mcontext_t *mc) { (void)mc; return 0; } + +int +sigprocmask(int how, sigset_t *set, sigset_t *oset) +{ + (void)how; (void)set; (void)oset; + return 0; +} + +/* `_ctxfinish' ends in exit(0) when uc_link is null; catch that. */ +static int exited; +#define exit xexit +static void xexit(int n) { (void)n; exited = 1; } + +/* ---- the real file ------------------------------------------- */ +#include "ucontext.c" + +#undef exit + +/* ---- the harness --------------------------------------------- */ + +static int fails; + +static void +eq(const char *what, long long got, long long want) +{ + if(got == want) + printf(" PASS %-44s %lld\n", what, got); + else { + printf(" FAIL %-44s got %lld want %lld\n", what, got, want); + fails++; + } +} + +#define STKSZ 65536 +static char stack[STKSZ]; + +static void dummy(void) { } + +int +main(void) +{ + ucontext_t uc, link; + unsigned long long *base, top; + int i; + + printf("makectx-xcheck: makecontext's frame, host build\n\n"); + + printf("Section 0: the three registers makecontext sets\n"); + memset(&uc, 0, sizeof uc); + uc.uc_stack.ss_sp = stack; + uc.uc_stack.ss_size = STKSZ; + uc.uc_link = &link; + for(i = 3; i < 8; i++) + uc.uc_mcontext.mc_regs[i] = 0xAAAA0000ULL + i; + + makecontext(&uc, dummy, 1, 0x1234); + + top = ((unsigned long long)(uintptr_t)stack + STKSZ) & ~15ULL; + base = (unsigned long long *)(uintptr_t)(top - (UC_ARGMAX+2)*8); + + eq("SP is one slot below the frame", + (long long)(uc.uc_mcontext.mc_regs[_MC_SP] + - (unsigned long long)(uintptr_t)base), -8); + eq("PC is the function", + uc.uc_mcontext.mc_regs[_MC_PC] == (unsigned long long)(uintptr_t)dummy, 1); + eq("the first argument is in the register slot", + (long long)uc.uc_mcontext.mc_regs[_MC_ARG], 0x1234); + + printf("\nSection 1: mc_regs[3..7] are LEFT ALONE\n"); + /* + * Not housekeeping: they may hold 6c's caches for global + * variables, so the right value for a new context is the one the + * required `getcontext' already put there. Zeroing them is the + * plausible wrong thing, which is why it is asserted. + */ + for(i = 3; i < 8; i++){ + char nm[48]; + sprintf(nm, "mc_regs[%d] untouched", i); + eq(nm, (long long)uc.uc_mcontext.mc_regs[i], 0xAAAA0000LL + i); + } + + printf("\nSection 2: the frame as _ctxreturn will find it\n"); + eq("base[0] is the return stub", + base[0] == (unsigned long long)(uintptr_t)_ctxreturn, 1); + eq("base[UC_ARGMAX+1] is the ucontext", + base[UC_ARGMAX+1] == (unsigned long long)(uintptr_t)&uc, 1); + + printf("\nSection 3: _ctxfinish finds it from the SP it is given\n"); + /* + * ***THE ONE NUMBER THAT SPANS THE TWO LANGUAGES.*** After the + * RET the stack pointer is &base[1], which is what `_ctxreturn' + * hands over. If this disagrees with where makecontext wrote the + * pointer, uc_link is garbage and the usual result is a silent + * exit. + */ + last_set = (const mcontext_t *)0; + exited = 0; + if(setjmp(away) == 0) + _ctxfinish((void *)(base + 1)); + eq("it followed uc_link", last_set == &link.uc_mcontext, 1); + eq("and did not fall through to exit", exited, 0); + + printf("\nSection 4: a null uc_link exits instead\n"); + uc.uc_link = (ucontext_t *)0; + base[UC_ARGMAX+1] = (unsigned long long)(uintptr_t)&uc; + last_set = (const mcontext_t *)0; + exited = 0; + if(setjmp(away) == 0) + _ctxfinish((void *)(base + 1)); + eq("it exited", exited, 1); + eq("and followed no link", last_set == (const mcontext_t *)0, 1); + + printf("\nSection 5: every argument slot, at the cap\n"); + memset(&uc, 0, sizeof uc); + uc.uc_stack.ss_sp = stack; + uc.uc_stack.ss_size = STKSZ; + uc.uc_link = &link; + makecontext(&uc, dummy, UC_ARGMAX, 101, 102, 103, 104, 105, 106, 107, 108); + eq("argument 0 in the register slot", + (long long)uc.uc_mcontext.mc_regs[_MC_ARG], 101); + for(i = 1; i < UC_ARGMAX; i++){ + char nm[48]; + sprintf(nm, "argument %d at base[%d]", i, i+1); + eq(nm, (long long)base[i+1], 101 + i); + } + eq("the ucontext cell SURVIVED the last argument", + base[UC_ARGMAX+1] == (unsigned long long)(uintptr_t)&uc, 1); + + printf("\nmakectx-xcheck: %d failures\n", fails); + return fails; +} diff --git a/sys/lib/tests/makectx-xcheck.sh b/sys/lib/tests/makectx-xcheck.sh new file mode 100644 index 000000000..7b946d9dd --- /dev/null +++ b/sys/lib/tests/makectx-xcheck.sh @@ -0,0 +1,60 @@ +#!/bin/sh +# makectx-xcheck.sh -- compile libap's real `ucontext.c' on the build +# host, beside stubs for the three assembly primitives, and check that +# the frame `makecontext' builds is the one `_ctxfinish' reads back. +# +# sh makectx-xcheck.sh +# +# ***NOTHING IS EXTRACTED OR TRANSCRIBED HERE.*** The harness +# `#include's the library file itself, which is possible because +# everything structural in it is C -- the assembly knows only "eight +# slots". So there are no markers to rot, and the thing under test is +# the thing that ships. +# +# The three headers it needs are the tree's own: `' from +# `sys/include/ape' and `' from `amd64/include/ape'. +# **They cannot simply be put on the include path**, because the APE +# headers reach each other through ABSOLUTE `/sys/include/ape/...' +# paths that resolve only inside `apexp-sh's namespace -- a first +# attempt died on `stddef_arch.h:16'. So the two are SYMLINKED into a +# scratch directory that is searched first, beside a `' that +# supplies `sigset_t' from glibc's own `'. +# *That detail is not cosmetic*: defining `sigset_t' independently made +# `' conflict with it two includes later. +# +# It does NOT test the context switch -- that is machine code for a +# machine this is not. `ucontext-test.c' does, with glibc as a +# reference implementation. + +set -e + +# This file is three levels below the repository root. +ROOT=$(cd "$(dirname "$0")/../../.." && pwd) +SRC=$ROOT/sys/src/ape/lib/ap/arch/amd64/ucontext.c +HDR=$ROOT/sys/include/ape/ucontext.h +ARCH=$ROOT/amd64/include/ape/ucontext_arch.h +HERE=$(cd "$(dirname "$0")" && pwd) +TMP=$HERE/.makectx-xcheck.d + +for f in "$SRC" "$HDR" "$ARCH"; do + [ -f "$f" ] || { echo "makectx-xcheck: no $f -- is ROOT wrong?" >&2; exit 2; } +done + +rm -rf "$TMP"; mkdir -p "$TMP" +ln -s "$HDR" "$TMP/ucontext.h" +ln -s "$ARCH" "$TMP/ucontext_arch.h" +ln -s "$SRC" "$TMP/ucontext.c" +: > "$TMP/lib.h" +cat > "$TMP/signal.h" <<'EOF' +#ifndef _MAKECTX_STUB_SIGNAL_H +#define _MAKECTX_STUB_SIGNAL_H +#include /* glibc's own, so agrees */ +#define SIG_SETMASK 2 +extern int sigprocmask(int, sigset_t *, sigset_t *); +#endif +EOF + +cc=${CC:-gcc} +$cc -Wall -Wextra -Wno-unused-parameter -I"$TMP" \ + -o "$HERE/makectx-xcheck" "$HERE/makectx-xcheck.c" +"$HERE/makectx-xcheck" diff --git a/sys/lib/tests/ucontext-test.c b/sys/lib/tests/ucontext-test.c new file mode 100644 index 000000000..5ab9ea6de --- /dev/null +++ b/sys/lib/tests/ucontext-test.c @@ -0,0 +1,250 @@ +/* ucontext-test.c -- getcontext/makecontext/swapcontext/setcontext + * + * pcc -o ucontext-test ucontext-test.c && ./ucontext-test + * gcc -o ucontext-test ucontext-test.c && ./ucontext-test # the control + * + * ***THIS ONE HAS A REAL REFERENCE IMPLEMENTATION, WHICH IS RARE HERE.*** + * glibc has ucontext, so every assertion below can be checked against a + * working implementation before the VM ever sees it -- the tree's own + * rule about checking a new test on gcc first, for once with the unit + * under test present on both sides rather than only the harness. **A + * failure on Plan 9 that also fails on glibc is this file's bug.** + * + * WHERE IT CAME FROM. php's `Zend/zend_fibers.c' is unconditional -- + * `Fiber' is a language class -- and needs either boost.context's GAS + * assembly or `'. APExp had neither, so the second was + * written: `lib/ap/arch/amd64/mcontext.s' (three register-only + * primitives) and `ucontext.c' (everything structural). + * `docs/notes/php.md' has the survey it came out of. + * + * WHAT EACH SECTION IS FOR, because several of them look alike and are + * not: + * + * 0 a context resumed by `setcontext' RETURNS 0 from its + * `getcontext'. The assembly zeroes AX before jumping for this + * reason alone, exactly as longjmp does -- *without it a resumed + * getcontext returns whatever was in AX and its caller believes + * the number*. + * 1 a function runs on the new stack AND the stack is really the one + * that was handed over -- checked by taking the address of a local + * and asking whether it lies inside the allocation. A context + * switch that quietly ran on the caller's stack would pass every + * other section here. + * 2 `uc_link' is honoured when the function RETURNS. + * 3 ***THE ARGUMENTS, AND THIS IS THE ONE THAT CHECKS THE FRAME + * LAYOUT.*** It passes the maximum this port accepts, because the + * cell holding the ucontext sits immediately above the argument + * slots: an off-by-one puts the last argument on top of it, and + * then `_ctxfinish' reads a bad pointer. **The dangerous outcome + * is silence** -- a null-ish `uc_link' means `exit(0)', which from + * the shell is indistinguishable from a clean finish -- so the + * section prints a marker before the call and the count after it. + * 4 ping-pong, which is what a fiber actually does: many switches + * through one pair of contexts, each resuming where it left off. + * 5 a context's own locals survive being switched away from and back. + * + * Exit status is the failure count. + */ + +#include +#include +#include +#include + +#ifndef __linux__ +extern int _ucmark(void); +#endif + +#define STKSZ (256*1024) + +static int fails; + +static void +eq(const char *what, long got, long want) +{ + if(got == want) + printf(" PASS %-46s %ld\n", what, got); + else { + printf(" FAIL %-46s got %ld want %ld\n", what, got, want); + fails++; + } +} + +/* ---- section 0 ---- */ +static int s0_count; + +/* ---- section 1 ---- */ +static ucontext_t s1_main, s1_ctx; +static char *s1_lo, *s1_hi; +static int s1_ran, s1_onstack; + +static void +s1_fn(void) +{ + char probe; + + s1_ran++; + s1_onstack = (&probe >= s1_lo && &probe < s1_hi); + swapcontext(&s1_ctx, &s1_main); +} + +/* ---- section 2 ---- */ +static ucontext_t s2_main, s2_ctx; +static int s2_ran; + +static void +s2_fn(void) +{ + s2_ran++; + /* returns: uc_link must take us back */ +} + +/* ---- section 3 ---- */ +static ucontext_t s3_main, s3_ctx; +static int s3_got[8], s3_ran; + +static void +s3_fn(int a, int b, int c, int d, int e, int f, int g, int h) +{ + s3_got[0] = a; s3_got[1] = b; s3_got[2] = c; s3_got[3] = d; + s3_got[4] = e; s3_got[5] = f; s3_got[6] = g; s3_got[7] = h; + s3_ran++; +} + +/* ---- section 4 ---- */ +static ucontext_t s4_main, s4_ctx; +static int s4_ticks; + +static void +s4_fn(void) +{ + int i; + + for(i = 0; i < 5; i++){ + s4_ticks++; + swapcontext(&s4_ctx, &s4_main); + } +} + +/* ---- section 5 ---- */ +static ucontext_t s5_main, s5_ctx; +static int s5_seen; + +static void +s5_fn(void) +{ + volatile long a = 0x1111111111111111L; + volatile long b = 0x2222222222222222L; + + swapcontext(&s5_ctx, &s5_main); + if(a == 0x1111111111111111L && b == 0x2222222222222222L) + s5_seen = 1; + swapcontext(&s5_ctx, &s5_main); +} + +static char * +newstack(ucontext_t *uc, ucontext_t *link, char **lo, char **hi) +{ + char *s = malloc(STKSZ); + + if(s == 0){ + printf(" FAIL could not allocate a stack\n"); + fails++; + exit(fails); + } + memset(s, 0, STKSZ); + getcontext(uc); + uc->uc_stack.ss_sp = s; + uc->uc_stack.ss_size = STKSZ; + uc->uc_stack.ss_flags = 0; + uc->uc_link = link; + if(lo) *lo = s; + if(hi) *hi = s + STKSZ; + return s; +} + +int +main(void) +{ + ucontext_t c0; + int i; + + /* a fault, a kill and a clean finish all give the prompt back */ + setvbuf(stdout, (char*)0, _IONBF, 0); + printf("ucontext-test: starting\n"); +#ifndef __linux__ + printf("ucontext-test: _ucmark = %d\n", _ucmark()); +#endif + + /* ---- 0: getcontext/setcontext round trip ---- */ + printf("\nSection 0: a resumed getcontext returns 0\n"); + /* + * The PASS line below prints TWICE and that is the point: the + * second is the resumed path re-entering at getcontext's return. + */ + s0_count = 0; + i = getcontext(&c0); + eq("getcontext returned 0", i, 0); + s0_count++; + if(s0_count == 1) + setcontext(&c0); /* re-enters getcontext's return */ + eq("setcontext came back round", s0_count, 2); + + /* ---- 1: a function on its own stack ---- */ + printf("\nSection 1: the function runs, on the stack it was given\n"); + newstack(&s1_ctx, &s1_main, &s1_lo, &s1_hi); + makecontext(&s1_ctx, s1_fn, 0); + swapcontext(&s1_main, &s1_ctx); + eq("the function ran", s1_ran, 1); + eq("its locals are inside the given stack", s1_onstack, 1); + + /* ---- 2: uc_link ---- */ + printf("\nSection 2: returning follows uc_link\n"); + newstack(&s2_ctx, &s2_main, (char**)0, (char**)0); + makecontext(&s2_ctx, s2_fn, 0); + swapcontext(&s2_main, &s2_ctx); + eq("the function ran", s2_ran, 1); + /* + * There is deliberately no `eq("uc_link honoured", 1, 1)' here. + * *A check that cannot fail is not a check*: if uc_link were + * ignored, `_ctxfinish' would call `exit(0)' and this line would + * never run, so REACHING section 3 is the result -- the same + * shape as the marker printed there, and the reason both are + * written as prose rather than as a passing assertion. + */ + + /* ---- 3: arguments, at the cap ---- */ + printf("\nSection 3: eight arguments, the most this port passes\n"); + printf(" (if nothing follows this line, the ucontext cell above\n"); + printf(" the argument slots was overwritten and _ctxfinish\n"); + printf(" exited: see ucontext.c's frame picture)\n"); + newstack(&s3_ctx, &s3_main, (char**)0, (char**)0); + makecontext(&s3_ctx, (void(*)(void))s3_fn, 8, + 11, 22, 33, 44, 55, 66, 77, 88); + swapcontext(&s3_main, &s3_ctx); + eq("the function ran", s3_ran, 1); + for(i = 0; i < 8; i++){ + char nm[48]; + sprintf(nm, "argument %d", i); + eq(nm, s3_got[i], 11*(i+1)); + } + + /* ---- 4: ping-pong ---- */ + printf("\nSection 4: five switches through one pair\n"); + newstack(&s4_ctx, &s4_main, (char**)0, (char**)0); + makecontext(&s4_ctx, s4_fn, 0); + for(i = 0; i < 5; i++) + swapcontext(&s4_main, &s4_ctx); + eq("ticks", s4_ticks, 5); + + /* ---- 5: locals survive a switch away and back ---- */ + printf("\nSection 5: a context's locals survive being left\n"); + newstack(&s5_ctx, &s5_main, (char**)0, (char**)0); + makecontext(&s5_ctx, s5_fn, 0); + swapcontext(&s5_main, &s5_ctx); + swapcontext(&s5_main, &s5_ctx); + eq("both locals intact after the round trip", s5_seen, 1); + + printf("\nucontext-test: %d failures\n", fails); + return fails; +} diff --git a/sys/src/ape/lib/ap/arch/amd64/mcontext.s b/sys/src/ape/lib/ap/arch/amd64/mcontext.s new file mode 100644 index 000000000..db911b910 --- /dev/null +++ b/sys/src/ape/lib/ap/arch/amd64/mcontext.s @@ -0,0 +1,128 @@ +/* + * amd64 machine-context switch for . + * + * The file is `mcontext.s' and not `ucontext.s' because `ucontext.c' + * sits beside it and mk derives both objects from the base name. + * + * ***THIS IS `setjmp.s' WITH THE RETURN-VALUE CONVENTION CHANGED, AND + * THAT IS THE WHOLE DESIGN.*** The two problems are the same one: save + * enough of the machine that execution can be resumed somewhere else. + * Read `setjmp.s' first -- its header carries the two findings this + * file inherits rather than re-derives: + * + * - **eight registers, not two.** 6c has REGEXT = D_R15, so R15 and + * R14 are reserved program-wide and R13/R12 may follow them as + * caches for global variables. Restoring only SP and PC leaves the + * resumed code reading another context's globals. + * - **the restore must not WRITE to the stack.** Upstream longjmp + * stores the target PC at 0(SP) and RETs, which faults when SP is + * at USTKTOP. `ADDQ $8,SP' then `JMP' does the same thing without + * the store, and a ucontext resumed at the very top of a freshly + * allocated fiber stack is exactly that case. + * + * ***AND THE ONE THING THESE ROUTINES KNOW IS "EIGHT SLOTS".*** No + * offset of `uc_link', `uc_stack' or `uc_sigmask' appears here; + * `ucontext.c' passes `&ucp->uc_mcontext' and does the rest in C, so no + * structure offset is written down in two languages. `_ctxreturn' hands + * its own SP to C for the same reason -- *the frame layout belongs to + * `makecontext', which is C, so the stub carries no magic number.* + * + * Plan 9 amd64 calling convention (6c): RARG (= BP) carries the first + * argument, and the SECOND is at 8(FP), because regaalloc1() reserves a + * 0(FP) slot for the first even though it arrives in a register. + * + * ***AND NO REGISTER SURVIVES A CALL HERE, WHICH IS MEASURED.*** + * `out/bin/6c -S' on a function that computes three values, calls + * through a function pointer and then uses them spills all three to the + * stack and touches BX/R12-R15 not at all. So `_ctxreturn' could not + * have been written to keep the ucontext pointer in a callee-saved + * register across the context's own function -- *there are none* -- + * which is why `makecontext' puts it on the new stack instead. + * (`main9.s:7' calls R12-R15 "callee-saved REGEXT registers"; both can + * be true, since a register the allocator never hands out is trivially + * preserved. Nothing here relies on either reading.) + */ + +/* + * _getmcontext(mcontext_t *mc) -- save; returns 0 here, and 0 again + * when a later _setmcontext resumes it. + */ +TEXT _getmcontext(SB), $0 + MOVQ SP, 0(RARG) + MOVQ 0(SP), AX /* return PC */ + MOVQ AX, 8(RARG) + MOVQ BP, 16(RARG) /* BP = RARG = mc; restored on resume */ + MOVQ BX, 24(RARG) + MOVQ R12, 32(RARG) + MOVQ R13, 40(RARG) + MOVQ R14, 48(RARG) + MOVQ R15, 56(RARG) /* R15 = REGEXT */ + MOVL $0, AX + RET + +/* + * _setmcontext(const mcontext_t *mc) -- resume; does not return. + * + * AX is zeroed before the jump so that the `_getmcontext' or + * `_swapmcontext' being resumed appears to return 0, the same reason + * longjmp sets it. *Without that line a resumed getcontext returns + * whatever happened to be in AX*, and its caller believes it. + */ +TEXT _setmcontext(SB), $0 + MOVQ RARG, R11 /* R11 = mc (scratch, never in the set) */ + MOVQ 16(R11), BP + MOVQ 24(R11), BX + MOVQ 32(R11), R12 + MOVQ 40(R11), R13 + MOVQ 48(R11), R14 + MOVQ 56(R11), R15 + MOVQ 0(R11), SP /* restore SP */ + ADDQ $8, SP /* skip the return-address slot */ + MOVQ 8(R11), R11 /* R11 = target PC */ + MOVL $0, AX /* resumed call returns 0 */ + JMP R11 /* no stack write: safe at USTKTOP */ + +/* + * _swapmcontext(mcontext_t *old, const mcontext_t *new) + * + * The second argument is read FIRST, because everything after it + * clobbers the stack pointer it would have been read through. + */ +TEXT _swapmcontext(SB), $0 + MOVQ newmc+8(FP), R11 /* second arg, before SP moves */ + + MOVQ SP, 0(RARG) + MOVQ 0(SP), AX /* return PC */ + MOVQ AX, 8(RARG) + MOVQ BP, 16(RARG) + MOVQ BX, 24(RARG) + MOVQ R12, 32(RARG) + MOVQ R13, 40(RARG) + MOVQ R14, 48(RARG) + MOVQ R15, 56(RARG) + + MOVQ 16(R11), BP + MOVQ 24(R11), BX + MOVQ 32(R11), R12 + MOVQ 40(R11), R13 + MOVQ 48(R11), R14 + MOVQ 56(R11), R15 + MOVQ 0(R11), SP + ADDQ $8, SP + MOVQ 8(R11), R11 + MOVL $0, AX + JMP R11 + +/* + * _ctxreturn -- where a makecontext'd function RETURNS to. + * + * It is reached by RET, so there is no return address at 0(SP): the + * stack pointer is whatever `makecontext' arranged, one slot past the + * cell that held this address. That SP is the only thing needed to + * find the ucontext again, and `makecontext' -- which built the frame + * -- is the only code that should know where in it the pointer sits. + * So this hands SP to C and stops. + */ +TEXT _ctxreturn(SB), $0 + MOVQ SP, RARG /* _ctxfinish(sp) */ + JMPF _ctxfinish(SB) diff --git a/sys/src/ape/lib/ap/arch/amd64/mkfile b/sys/src/ape/lib/ap/arch/amd64/mkfile index de7738e4f..ee71a9156 100644 --- a/sys/src/ape/lib/ap/arch/amd64/mkfile +++ b/sys/src/ape/lib/ap/arch/amd64/mkfile @@ -11,6 +11,8 @@ OFILES=\ main9p.$O\ notetramp.$O\ setjmp.$O\ + mcontext.$O\ + ucontext.$O\ strchr.$O\ strlen.$O\ tas.$O\ diff --git a/sys/src/ape/lib/ap/arch/amd64/ucontext.c b/sys/src/ape/lib/ap/arch/amd64/ucontext.c new file mode 100644 index 000000000..b3db5388d --- /dev/null +++ b/sys/src/ape/lib/ap/arch/amd64/ucontext.c @@ -0,0 +1,220 @@ +/* + * POSIX user contexts: the C half. + * + * `ucontext.s' holds three register-only primitives that know nothing + * but "eight slots"; everything structural is here, so **no offset into + * `ucontext_t' is ever written down in two languages**. See that file's + * header for why the register set is eight and why the restore must not + * write to the stack. + * + * WHERE IT CAME FROM. php's `Zend/zend_fibers.c' is unconditional and + * wants either boost.context's GAS assembly or this; APExp had neither. + * `docs/notes/php.md' has the survey. + * + * ***THE WRAPPERS BELOW ARE ARCHITECTURE-INDEPENDENT AND LIVE IN AN + * ARCHITECTURE DIRECTORY ANYWAY***, because `makecontext' is NOT -- the + * argument layout is 6c's amd64 convention -- and because a shared + * `ucontext/' directory would be built for all eleven targets and link + * against `_getmcontext' on ten of them that do not have it. *A second + * architecture is the right moment to split this*, not before: there + * would be nothing to check the split against today. + */ +#include "lib.h" +#include +#include +#include +#include +#include +#include +#include + +extern int _getmcontext(mcontext_t *); +extern void _setmcontext(const mcontext_t *); +extern void _swapmcontext(mcontext_t *, const mcontext_t *); +extern void _ctxreturn(void); + +/* + * THE NEW CONTEXT'S STACK FRAME, as `makecontext' builds it and + * `_ctxreturn' reads it back. Addresses increase downward in this + * picture; the stack itself grows the other way. + * + * base[0] the return address: _ctxreturn + * base[1] 6c's reserved 0(FP) slot for the first + * argument, which actually arrives in BP + * base[2..UC_ARGMAX] arguments 1..UC_ARGMAX-1, at 8(FP) upwards + * base[UC_ARGMAX+1] the ucontext this frame belongs to + * + * `func' is entered with SP = base, exactly as a CALL would leave it. + * When it returns, the RET pops base[0] and `_ctxreturn' sees + * SP = &base[1], so the ucontext is at sp[UC_ARGMAX] -- **one constant, + * in one language**, which is the whole reason `_ctxreturn' hands its + * stack pointer to C instead of dereferencing it itself. + * + * ***UC_ARGMAX IS A CAP AND NOT A CONVENTION, AND THE REASON IS 6c's + * FIRST-ARGUMENT REGISTER.*** On an ABI that passes the first six + * arguments in registers the stack image does not change shape with + * `argc', so the bookkeeping cell can sit at a fixed offset whatever + * the count -- which is how glibc does it. Here only argument ZERO is + * in a register and the rest are contiguous from 8(FP), so the cell + * must come after a FIXED number of argument slots or its offset moves. + * Eight is chosen rather than derived; php passes none, and POSIX only + * promises `int' arguments. + */ +#define UC_ARGMAX 8 +#define UC_SLOTS (UC_ARGMAX + 2) + +/* + * Version marker for `ucontext-test.c'. `pcc -o x x.c' relinks against + * the INSTALLED libap, so a test built from a fresh pull can run + * days-old library code. Thirteenth use of the idiom. + */ +int +_ucmark(void) +{ + return 1; +} + +static void +ucdie(const char *msg) +{ + write(2, "makecontext: ", 13); + write(2, msg, strlen(msg)); + write(2, "\n", 1); + abort(); +} + +int +getcontext(ucontext_t *ucp) +{ + if(ucp == 0){ + errno = EINVAL; + return -1; + } + sigprocmask(SIG_SETMASK, (sigset_t*)0, &ucp->uc_sigmask); + /* + * Returns 0 now, and 0 again when a later _setmcontext resumes + * the saved PC -- which is this function's own return address, so + * control re-enters here and falls out of the bottom. The + * sigprocmask above is deliberately NOT re-run on that path: the + * side that resumes a context is the side that installs its mask. + */ + return _getmcontext(&ucp->uc_mcontext); +} + +int +setcontext(const ucontext_t *ucp) +{ + if(ucp == 0){ + errno = EINVAL; + return -1; + } + /* sigprocmask's `set' is not const (signal/sigprocmask.c:8) */ + sigprocmask(SIG_SETMASK, (sigset_t*)&ucp->uc_sigmask, (sigset_t*)0); + _setmcontext(&ucp->uc_mcontext); + errno = EINVAL; /* not reached */ + return -1; +} + +int +swapcontext(ucontext_t *oucp, const ucontext_t *ucp) +{ + if(oucp == 0 || ucp == 0){ + errno = EINVAL; + return -1; + } + sigprocmask(SIG_SETMASK, (sigset_t*)0, &oucp->uc_sigmask); + sigprocmask(SIG_SETMASK, (sigset_t*)&ucp->uc_sigmask, (sigset_t*)0); + _swapmcontext(&oucp->uc_mcontext, &ucp->uc_mcontext); + return 0; +} + +void +makecontext(ucontext_t *ucp, void (*func)(void), int argc, ...) +{ + unsigned long long *base; + unsigned long long top; + va_list ap; + int i; + + if(ucp == 0) + ucdie("null context"); + if(func == 0) + ucdie("null function"); + if(argc < 0 || argc > UC_ARGMAX) + ucdie("more arguments than this port passes"); + if(ucp->uc_stack.ss_sp == 0 || ucp->uc_stack.ss_size < UC_SLOTS*8 + 64) + ucdie("stack missing or too small"); + + top = (unsigned long long)(unsigned long)ucp->uc_stack.ss_sp + + (unsigned long long)ucp->uc_stack.ss_size; + top &= ~(unsigned long long)15; /* 6c does not require 16; + * its frames are not a + * multiple of it. Free. */ + base = (unsigned long long *)(unsigned long)(top - UC_SLOTS*8); + + base[0] = (unsigned long long)(unsigned long)_ctxreturn; + base[1] = 0; /* 6c's reserved 0(FP) slot */ + for(i = 2; i <= UC_ARGMAX; i++) + base[i] = 0; + base[UC_ARGMAX+1] = (unsigned long long)(unsigned long)ucp; + + /* + * POSIX says the arguments are `int'. They are read here as the + * widest integer instead, and that is not laxity: **every vararg + * occupies eight bytes on this ABI** -- `stdarg_arch.h' advances + * the list by 8 for sizes 1, 2 and 4 alike -- so reading eight is + * reading the whole slot. A callee declaring `int' then takes the + * low four bytes and is right whatever is above them, while a + * caller that passed a POINTER (common, and what every real + * makecontext user does) is served correctly rather than + * truncated. *Reading `int' here would be the narrower choice and + * the wrong one.* + */ + va_start(ap, argc); + for(i = 0; i < argc; i++){ + unsigned long long v = (unsigned long long)va_arg(ap, long long); + if(i == 0) + ucp->uc_mcontext.mc_regs[_MC_ARG] = v; /* BP = RARG */ + else + base[i+1] = v; /* 8(FP) up */ + } + va_end(ap); + if(argc == 0) + ucp->uc_mcontext.mc_regs[_MC_ARG] = 0; + + /* + * `_setmcontext' loads SP and then adds 8 before jumping -- the + * no-stack-write form of a RET -- so the SP recorded here is one + * slot BELOW the frame, and `func' begins with SP = base. + */ + ucp->uc_mcontext.mc_regs[_MC_SP] = + (unsigned long long)(unsigned long)base - 8; + ucp->uc_mcontext.mc_regs[_MC_PC] = + (unsigned long long)(unsigned long)func; + + /* + * mc_regs[3..7] -- BX, R12..R15 -- are left exactly as they are. + * ***THAT IS WHY POSIX MAKES `getcontext' A PRECONDITION OF + * `makecontext'***: those registers may hold 6c's caches for + * global variables (REGEXT is R15, descending), so the right value + * for a new context is the CALLER's, which the required + * `getcontext' has already put there. Zeroing them would hand the + * new context a null view of every register-cached global. + */ +} + +/* + * Where a context's function returns to. `_ctxreturn' in the assembly + * hands over its stack pointer and nothing else; the frame layout is + * this file's, so the arithmetic is here. + */ +void +_ctxfinish(void *sp) +{ + ucontext_t *ucp; + + ucp = ((ucontext_t **)sp)[UC_ARGMAX]; + if(ucp != 0 && ucp->uc_link != 0) + setcontext(ucp->uc_link); + exit(0); +} From 7694b9aa055954db77969048d12a08fcd31928a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 07:20:33 +0000 Subject: [PATCH 3/3] notes: ucontext for amd64, and the no-callee-saved-registers fact The measurement is load-bearing beyond this port: a host 6c -S shows kencc amd64 spilling everything across a call, so nothing may be kept in a register through a call into arbitrary code. It goes in the invariant list because it is surprising and because main9.s says something that reads like the opposite. php blocker 1 moves from open to "written and host-checked, unmeasured on the VM" -- the port itself has not been pointed at it yet. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WGAwvvTwDg2yknFkmZ3qzs --- CLAUDE.md | 43 ++++++++++++++++++++++++++++++++++++------- docs/notes/php.md | 4 +++- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9c566826f..e0b56a064 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -271,6 +271,14 @@ reach its start routine and `pthread_cond_timedwait` must actually wait; the two are different bugs and the sections are split so), `poll-test` (the count is of pollfd ENTRIES, a negative fd is ignored, a closed one is POLLNVAL — the two rules whose absence makes a `while(poll())` caller SPIN), +`ucontext-test` (**the one test here with a REAL REFERENCE +IMPLEMENTATION** — glibc has ucontext, so all six sections were checked +against a working one before the VM saw them, 0 failures; *a failure on +Plan 9 that also fails on gcc is the test's bug*. Section 1 asks whether +the function ran **on the stack it was given**, which a switch that +quietly used the caller's would otherwise pass; section 3 passes the +maximum arguments because the ucontext cell sits just above them and the +failure mode of an off-by-one is `exit(0)`, so it prints a marker first), `rdev-test` (two different files must be distinguishable — it transcribes diffutils' `same_file()` verbatim, because the question is what THAT function answers), `stdio-test`. @@ -282,8 +290,8 @@ so a test built from a fresh pull can run days-old library code and say nothing. `_sock_listenmark`, `_execmark`, `_ttymark`, `_getcwdmark`, `_printfmark`, `_dupmark`, `_dotdirmark`, `_tempmark`, `_fdinfomark`, `_umaskmark`, `_bufmark`, `_rdevmark`, `_selregmark`, `_pthreadmark`, -`_syncmark`, `_mmapmark` each make the test **fail to LINK** against a libap -predating its fix. The idiom has paid twelve times; bump one when a file changes in a way a test must see — and +`_syncmark`, `_mmapmark`, `_ucmark` each make the test **fail to LINK** against a libap +predating its fix. The idiom has paid thirteen times; bump one when a file changes in a way a test must see — and it matters most where the bug's whole symptom is that nothing is printed. @@ -313,6 +321,18 @@ field was. The harness wraps `malloc` now, as it already wrapped `free`. *The assertion had been phrased against the thing under test instead of against something independent of it*, which is the shape to look for when a break does not fire. +**`makectx-xcheck.{c,sh}`** checks `makecontext`'s FRAME on the host, +and **it compiles the real `ucontext.c` rather than a copy** — no +extraction and no markers to rot, which is possible only because +everything structural in that file is C and the assembly knows just +"eight slots". It exists because the one number spanning the two +languages (where the ucontext pointer sits in the new frame) fails +SILENTLY on the VM. No external oracle, so its control is a deliberate +break: the cell off by one gives **3** failures, `SP` not one slot below +**1**. ***Its first run reported its own STUB***: the real +`_setmcontext` never returns, the fake recorded and returned, and +section 3 duly failed — *the assertion was right and the fake was +wrong*, which is the shape to recognise rather than argue with. **`hostkencc/clisp-mirror.sh` stages clisp's generated tree on the host** so the host kencc can compile and inspect it -- `--flags` prints the compile line, `--compile` builds all 31 TUs and GATES. *Its @@ -1129,11 +1149,12 @@ two are the opposite of what it says — `HAVE_TM_ZONE` and `HAVE_ST_BLOCKS` are both YES, `struct tm` and `struct stat` having those members. *A sweep over function names, asked about a struct field, answers `absent` confidently.* -**What can still stop it, in order**: (1) **fibers have no context -switch** — `zend_fibers.c` is unconditional and wants either -boost.context's GAS assembly or ``, and APExp has neither; -a libap `ucontext` for amd64 is the reusable half, and it is a decision -to make BEFORE the mkfile, since it changes what the mkfile builds. +**What can still stop it, in order**: (1) fibers — **CLOSED on the +host, unmeasured on the VM**: `zend_fibers.c` is unconditional and +wants either boost.context's GAS assembly or ``, and APExp +now has the second (`lib/ap/arch/amd64/mcontext.s` + `ucontext.c`, +`docs/notes/libap.md`). amd64 only; the other ten follow the same +shape when wanted. (2) **`ext/opcache` is mandatory in 8.5** — there is no `--disable-opcache`, only `--disable-opcache-jit`, which is what drops the 13-file `jit/ir` code generator; opcache only has to COMPILE, since @@ -1698,6 +1719,14 @@ the index, so that nothing here is a surprise. reason it scrubs `FD_ISTTY` and `FD_ISREG`. `FD_BUFFEREDX` is poison: `read()` and `select()` both answer EIO for it without touching the fd. +- **kencc amd64 has NO callee-saved registers, measured.** A host + `6c -S` on a function that computes three values, calls through a + function pointer and then uses them spills all three to the stack and + touches BX and R12-R15 not at all. *So nothing may be kept in a + register across a call into arbitrary code* — which is why libap's + `ucontext` puts its bookkeeping pointer on the new stack. `main9.s:7` + calls R12-R15 "callee-saved REGEXT registers" and both can be true: a + register the allocator never hands out is trivially preserved. - **A process gets FIVE spare segment slots, measured twice and the second time at 64KB**, so `mmap` cannot be backed by `segattach` in general. `mmaptrim-probe` section 0b got 5 at 2MB and 5 at 16MB diff --git a/docs/notes/php.md b/docs/notes/php.md index 03a9e94df..db3cfff44 100644 --- a/docs/notes/php.md +++ b/docs/notes/php.md @@ -239,7 +239,9 @@ version, then add one library at a time. ## Open, as a list -* fibers: no context switch (§1) — **decide `ucontext` or boost asm** +* fibers: `ucontext` is written (`docs/notes/libap.md`) and passes on + the host against glibc; **not yet run on the VM**, and php has not + been pointed at it (`ZEND_FIBER_UCONTEXT` in the `config.h`) * `main/php_config.h` not written yet; the inventory is derived, the answers are not * `memfd_create` is declared with no implementation, which is a libap