SchOS/.claude/memory/schos-smp-userspace-corruption.md
Nexoniarz a053a96add signal: the return trampoline was Linux-only, and a test that proves the rest
Went looking for why `X=$(echo hi)` hangs every shell. Did not find it. Found
two other things, and built the instrument that rules out the largest suspect.

**The signal return trampoline was ABI-blind.** deliver_signals() manufactures
a return address for the handler -- seven bytes on the user stack reading
`mov eax, 15 ; syscall`. Fifteen is *Linux's* rt_sigreturn, which is right for
anything musl-linked, and that is every port and every shell. For a first-party
binary it is native syscall 15, which is `open`. So a handler in any cmd/
program ran, returned into the trampoline, called open() with whatever the
handler had left in its registers, got -ENOENT, and carried on executing the
stack bytes after the trampoline.

    pid 14 sigtest: trap 14 err 4 rip 0x46ca7 addr 0xfffffffffffffffe
      rax=fffffffffffffffe ... rsp=46ca0
      ustack: 46e30 46ca0 50f0000000fb8 0 0 0

rip is rsp+7 -- one past a seven-byte trampoline -- and the third word of the
stack is the trampoline's own bytes. It emits the number for the process's ABI
now; syscall_map is the thing that says which numbering this process speaks.

**SIG_BLOCK, SIG_UNBLOCK and SIG_SETMASK were not defined anywhere.**
sys_sigprocmask branched on bare 0, 1 and 2 with the names in comments, so
anything outside the kernel had to spell the number and hope it had the right
one. Defined in posix.h, used in the kernel.

**cmd/sigtest** asserts the SIGCHLD/sigsuspend contract every shell is built
on, and it is written so a permissive stub cannot pass: a blocked SIGCHLD stays
pending, sigsuspend delivers an *already pending* signal without spinning, the
handler runs exactly once, the mask is restored, the child is still reapable,
and a signal arriving mid-wait also wakes it. Eight checks, all green. The
kernel's half of that contract is now proven rather than assumed, which is what
lets the remaining bug be pursued somewhere else.

Along with it, `rt_sigsuspend` in lib/usys.S -- the syscall existed and the
first-party library simply never exposed it.

On the hang itself: it is not job control, not SMP, not zsh (mksh does it too),
and not any value the kernel returns -- a full syscall trace is in
docs/history/kernel.md section 7, which also records what the contradiction
narrows to. That section had said "root cause not yet isolated" with three
suspects; all three are now eliminated, and the note says what to do next
instead of what to guess.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 23:56:34 +02:00

5 KiB

name description metadata
schos-smp-userspace-corruption The intermittent "zsh jumps to rip 0" crash was three shared globals on the syscall entry path — found, fixed, and the rule it generalises to.
type
project

Fixed 2026-09-21. Kept because the shape of the bug generalises.

Was: Roughly one to three processes per boot die with a user-mode fault they could not have caused themselves. It has been written off as "one zsh in ~5 boots makes a wild jump" for a while; it is more than that.

It is SMP-only. Three boots at -smp 1: zero faults. Three at -smp 2: one, three, one. This is the single most useful fact about it and it was not known before.

It is not only zsh. schinitd has taken one too, so it is not something about the shell.

The two signatures seen, both from kernel/arch/x86_64/trap.c's extended dump (which now fires for rip below a page or a faulting address near zero, not only for GP faults):

  • trap 14 err 20 rip 0x0 addr 0x0 — an instruction fetch at zero. A process does not branch there; it was handed a zero and followed it.
  • schinitd: trap 14 err 4 rip 0x5d7b addr 0xffffffffffffffea — -22, which is -EINVAL. Something returned an error and the caller used it as a pointer.

Ruled out: kernel stack depth. This was the leading hypothesis and it is wrong. Raising KSTACKSIZE to 8192 with static per-slot stacks did not change the rate. The search was still worth it — the high-water mark really was 3736 of 4096 bytes in netd, and the AP scheduler stack really was being handed a page it did not own — but neither was this.

Not yet examined: TLB coherence when a page table is modified on one CPU while the process runs on another, kalloc's free list under contention, and the buffer cache. switchuvm() and the p->tf handling were both read and look correct — trap() never overwrites p->tf, only syscall() does.

Reproduce: boot -smp 2 and count trap 1 lines in the serial log. Six tty shells start at once at boot, which is when it almost always happens.

Related: schunix-smp-and-memory-bugs, schunix-console-tty.

It was three globals on the syscall entry path

syscall_entry in kernel/arch/x86_64/trapasm.S has to swap FS_BASE from the user's TLS to the kernel's before touching any %fs: data, and wrmsr destroys rax, rcx and rdx. Those were parked in three globals — _sc_rax, _sc_rcx, _sc_rdx in vm.c — under a comment reading "safe while IF cleared by SYSCALL/SFMASK".

IF answers for an interrupt on this CPU. It says nothing about a second CPU executing the same three stores at the same instant.

%rcx is the user return address — SYSCALL puts the user's rip there, SYSRET takes it back. So the CPU that lost the race returned its process into another process's instruction stream, with %rax (syscall number in, return value out) swapped alongside it. rip 0x0 was a process handed a zero and following it; 0xffffffffffffffea was -EINVAL in a stolen %rax, used as a pointer.

Per-CPU words at %gs:32..48 now, with a _Static_assert pinning all seven offsets because the assembly spells them as literals.

Measured at four CPUs against the same pristine disk image each run: 10 faults across 10 boots before, 0 across 18 after.

The rule

This is the second bug of this exact shape in that file — kernel_fs_base was the first, a single global every CPU wrote in its own seginit(). On the entry path, a global is a bug. There is one per-CPU block; everything that varies per CPU belongs in it. %gs is usable before the FS swap because GS base is per-CPU in hardware and userspace cannot change it (sys_arch_prctl implements ARCH_SET_FS/ARCH_GET_FS only).

How to A/B a probabilistic kernel bug here

Rebuilding between runs dominates the clock. Build both variants once, save build/esp for each, keep a pristine schfs.img, then each trial is just restore-and-boot. Faults land during boot (six tty shells exec'ing at once), so a 40-second timeout is enough — no typed input needed. -smp 4 roughly doubles the rate over -smp 2.

Found while here: $(command substitution) hangs every shell

Unrelated to the above and still open. X=$(echo hi) never returns, in zsh and mksh, on 1 CPU and on 4, interactive and not, with set +m. Pipelines, subshells, background jobs and foreground externals are all fine.

Ruled out: setpgid (real now), SMP, job control, zsh specifically, and the kernel's SIGCHLD/sigsuspend contract — cmd/sigtest proves that one and passes all eight checks. A full syscall trace of a hang has every return value correct.

The contradiction is at Src/jobs.c:1569: zsh's waitforpid() ends its loop on ESRCH from kill(pid, 0), and the trace shows that kill returning ESRCH with the loop entered anyway. Next step is a musl-linked probe that does fork; let the handler reap; kill(pid,0); print errno — everything upstream of that line is known good.

Documented in full at docs/history/kernel.md section 7, which is where to start.