Commit graph SchOS/docs/history
Author SHA1 Message Date
Nexoniarz
26e5d89c73 smp: every stack on every AP was misaligned by eight
`jmp mpenter`.

x86-64 requires rsp to be 16-byte aligned immediately before a `call`, so a
function starts with rsp ending in 8 -- the return address -- and its
`push %rbp; mov %rsp,%rbp` prologue leaves %rbp 16-byte aligned. Compilers
build on that: an aligned vector store to a frame slot is `movaps`, and
`movaps` to a misaligned address is #GP.

entry64mp loaded the AP's stack pointer, which is page-aligned, and *jumped*.
Nothing pushed a return address, so %rbp came out eight bytes off -- on every
frame, on every application processor, for the life of the machine.

It cost nothing for as long as no function an AP ran needed an aligned vector
store. Then the console's glyph path started returning a small struct by value
(the cell-as-a-surface change), clang spilled it with movaps, and CPU 1 took a
#GP the instant it announced itself on a verbose boot:

    unexpected trap 13 from cpu 1 rip ffff800000120a3b <draw_glyph_px+0x1cb>

    120a37: 0f 10 45 90    movups -0x70(%rbp), %xmm0
    120a3b: 0f 29 45 b0    movaps %xmm0, -0x50(%rbp)   <- #GP

movups on the line above loads fine; movaps stores and faults. The
displacement is a multiple of 16, so what is misaligned is %rbp itself, which
is a fact about how the function was entered.

`call mpenter`, not `jmp`. Eight bytes for an ABI the whole toolchain assumes.

**It was hiding behind a second bug.** The first report was not a #GP at all:

    Stop code     ACQUIRE
          #2  cprintf+0xae
          #3  trap+0x2c4

trap() reported the fault with cprintf(), cprintf() takes the console lock,
and the faulting code was already holding it -- on a verbose boot every
character is drawn under that lock. acquire() panicked about a lock its own
call chain owned, and the trap number, the faulting address and the registers
were all lost. A kernel-mode trap ends in panic() a dozen lines later anyway,
so the console goes into panic mode *before* the report rather than during it.

**And a third, in the same boot.** The console reported "JetBrains Mono, 8x16
cells" -- a name from one font and metrics from another. The built-in bitmap
became a provider like any other in the previous commit, so draw_glyph_px()
installs it lazily on the first glyph; on a verbose boot that happens before
gfx_font_init() runs, which then returned early because a font was already
installed. The console spent the whole boot in the fallback while claiming not
to be. "A font is installed" and "a typeface was chosen" had been the same test
and quietly stopped being; they are two flags now.

Verified: verbose boot reaches a shell twice running with no traps and no
panics, and reports 10x21 cells -- the real typeface -- where it reported 8x16
before. Both supported configurations quiet: one boot each, no traps, no
panics, socktest and memfdtest green.

Written up as section 22 of docs/history/kernel.md, because the interesting
part is not the missing eight bytes but that three separate faults had to be
peeled off in order before the first one could be seen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 19:37:35 +02:00
Nexoniarz
fdbd9bcf95 zsh: two more cache answers that claimed capabilities SchOS lacks
Checking the rest of ZSH_CACHE after the killesrch fix, on the principle that
one wrong answer in a hand-written block is rarely the only one. Two more, both
`yes`, both measurable on the running system in a line each.

**zsh_cv_sys_path_dev_fd=/dev/fd.** Neither /dev/fd nor /proc/self/fd exists --
/proc/self does not exist at all. So `<(cmd)` failed at the point of use with

    cat: /dev/fd/11: No such file or directory

which reads as a broken command rather than as a feature the system does not
have.

**zsh_cv_sys_fifo=yes.** mkfifo() does not fail here. It *succeeds*, and
produces a character device: mknod() turns S_IFIFO into a T_DEV and keeps the
major/minor, so `ls -l` shows `crw-rw-rw- 438, 0` where a `p` belongs, and
nothing written to it comes back out. Succeeding wrongly is what made `yes`
look defensible.

Both `no` now. `<(cmd)` says "doesn't look like your system supports FIFOs",
which names the missing thing instead of blaming cat, and `$( )` is unaffected.

The two gaps are recorded in docs/architecture/filesystem.md rather than left
implied by a build flag -- named pipes and a path to one's own descriptors are
both real features, and neither exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 00:36:48 +02:00
Nexoniarz
b012060699 zsh: one word in a cross-compile cache, and every $(...) hung
`X=$(echo hi)` never returned. It has been in docs/history/kernel.md section 7
as "mksh's job control still doesn't reliably progress ... root cause not yet
isolated" with three suspects, and it was none of them, and it was not the
kernel at all.

ports/zsh/Makefile carries a block of `zsh_cv_*` answers for the checks
autoconf cannot run when cross-compiling. One of them was

    zsh_cv_sys_killesrch=no

which reads like a reassuring property and is in fact the answer to the
question configure asks: *does kill(pid, 0) return ESRCH correctly?* `no` makes
configure emit BROKEN_KILL_ESRCH, and Src/zsh_system.h answers that with

    #ifdef BROKEN_KILL_ESRCH
    #undef ESRCH
    #define ESRCH EINVAL
    #endif

zsh's waitforpid() ends its wait on `errno != ESRCH` (Src/jobs.c:1569). With
ESRCH redefined to EINVAL (22) and the kernel correctly returning ESRCH (3),
that condition was true for ever: the shell sat in sigsuspend waiting for a
child it had already reaped. An instrumented zsh says the whole thing in one
line -- kill returned -1, errno is 3, and the constant it is compared against
is 22:

    WFPkill -1 3 22

**Autoconf's own cross-compilation default for that check is `yes`.** Deleting
the line would have been correct. Writing it down as "a fact about SchOS" is
what got it wrong, and the block's comment now says to read each name as the
question rather than as a property.

Verified: `X=$(echo inline)`, `$(/bin/echo external)`, backticks, nested
`$(echo $(echo deep))`, a slow child, and `$(ls /bin | wc -l)` all return.
mksh -- as /bin/sh -- was never affected.

Two corrections to yesterday's note, both confident and both wrong:

  - "Not zsh, mksh hangs the same way" was false. mksh installs as /bin/sh,
    so that test was a `command not found` whose output scrolled past. Had the
    binary been named right, this would have localised to zsh's build in one
    step instead of a day.
  - cmd/sigtest had already proven the kernel's SIGCHLD/sigsuspend contract,
    and a musl-linked probe had already shown kill(pid,0) returning -1 with
    errno == ESRCH == 3 and zsh's own loop condition evaluating false. Both
    results were right; both were read as "so it must be subtler in the
    kernel". What they actually pointed at was the one place the probe and the
    shell differed -- how each was compiled.

The lesson is narrow enough to state: when a probe that reproduces a program's
exact syscall sequence behaves correctly and the program does not, stop looking
under the syscalls and look at the build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 00:29:39 +02:00
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
Nexoniarz
14f0e4d0e2 kernel: processes have an environment now, and zsh is the shell
Porting zsh found four defects in the kernel and one in the filesystem. None
of them were about zsh; all of them had been there the whole time.

**exec threw the environment away.** kernel/fs/exec.c wrote a single zero for
envp with the comment "(no environment)" -- not yet, but ever, for every
process. musl has been passing a perfectly good envp to execve (Linux 59, which
SchOS answers) and it was never read. So $TERM was unset, $PATH was whatever
each program invented, and a variable exported in a shell did not survive the
exec meant to carry it. exec() takes an envp, sys_exec reads argument three,
and schinit manifests grew an Environment key. The userland two-argument
exec(path, argv) stays as a wrapper that passes null explicitly -- the raw stub
is execve now, so a short caller cannot leave %rdx holding a stray pointer.

**Which overflowed the kernel stack.** Three pointer arrays, ~1.8 KB, on a
4096-byte stack, under exec -> loaduvm -> readi -> SchFS -> btree. Pid 1 went
down on the first exec. The canary caught it and named the process, which is
what it is for. They live in one kalloc'd page now.

**waitpid returned a bare -1** for both "no children" and "interrupted". musl
reads [-4095,-1] as -errno, so userspace saw EPERM; zsh printed "wait failed:
operation not permitted" and abandoned the rest of /etc/zsh/zshrc. -ECHILD and
-EINTR now. Third time a bare -1 has cost real time -- on a system with no
permission model, EPERM should be read as "a syscall returned -1".

**NVMA was 64.** musl's allocator takes a mapping per size class and does not
release them eagerly, so compinit ran out of slots and got -1, which is EPERM,
which malloc turns into NULL, which zsh reports as "fatal error: out of memory"
-- on a machine with 1000 MB free. 256 slots, and -ENOMEM.

**And a small write to SchFS costs 58 ms.** 200 writes to one open file take
11.75 s; the same to /dev/null take 0.14 s. That is the design stated at the
top of kmount.c -- one transaction group per operation, and a commit is two
flushes and an uberblock -- not a bug. But compdump writes its digest with
thousands of separate prints, so it takes minutes and looks like a hang.
compinit -D, which skips the dump, finishes in 3.6 s. This one is a decision
rather than a fix, so it is written up and left: /etc/zsh/zshrc keeps compinit
switched off and explains exactly why, with the measurements.

zsh runs as /bin/zsh and is the console shell. ZLE works -- arrow keys recall
history through terminfo, $COLUMNS reads the real 160 because TIOCGWINSZ was
fixed, colour and prompt expansion work, startup is 0.14 s. mksh keeps /bin/sh,
so schinit's no-jobs fallback is a different program from the one that could
have failed.

Also fixed while here: AT_CLKTCK was the literal 100 and is USER_HZ; the
extract rule in four ports could move a source tree inside itself when make
re-ran it, which is how zsh's configure came to write modfile=zsh-5.9/... into
config.modules and fail three steps later.

schfs-check passes. Full writeup in docs/history/kernel.md section 14.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-12 23:26:50 +02:00
Nexoniarz
9d4cdb44fc kernel: the clock ran 7x slow because the GPU never acknowledged its interrupt
fastfetch reported 3 seconds of uptime after 15 real ones, `time` claimed
3.12s for a 20-second command, and ping's one-second interval was closer to
six. One cause, not three: `ticks` was advancing at ~14.5 Hz instead of 100.

It was not the timer. virtio-gpu-pci shares IRQ 11 with the e1000 and
registered no interrupt handler at all. PCI INTx is level-triggered, so the
device held the line asserted, the kernel signalled EOI, and it fired again
immediately -- 72,000 interrupts a second, 83% of CPU0, starving the tick.
The e1000's handler read ICR as zero half a million times, correctly
answering "not mine", which is what made it look like a network-free problem.

Reading virtio's ISR byte clears it and drops the line. That is the fix.

irq_dispatch now measures each line's rate against the TSC -- not ticks,
which is the first casualty of a storm -- and names the attached handlers
once above 20000/s. It reproduces the original fault as a single line.

Found along the way, and fixed because they were real:

  - lapicinit loaded TICR with xv6's uncalibrated 10000000, whose own comment
    admitted it was never measured. calibrate_timers() now measures the LAPIC
    and the TSC against the 8254 PIT. (It came back at 999.9 MHz, so this was
    not the bug -- but the next machine is not obliged to be 1 GHz.)
  - microdelay() was an empty function body. Every microdelay(1000) in the
    xHCI reset path and the USB hub power-on sequence returned instantly.
  - HZ is a constant in param.h now, not the literal 100 written out at a
    dozen ticks-to-seconds conversions. USER_HZ is separate and deliberately
    does not follow it: musl answers sysconf(_SC_CLK_TCK) with 100 always, so
    times() and /proc/stat scale at the syscall boundary the way Linux does.
  - TIOCGWINSZ answered a hardcoded 24x80. gfx_cols()/gfx_rows() have known
    the real geometry all along; at 1024x768 the console is 128x48, so every
    program that lays out a screen was formatting for the wrong one.
  - TCFLSH fell through to the catch-all `return 0`, claiming to discard
    input without discarding any.

uptime now advances 5.01 seconds per 5 real seconds. schfs-check passes.

Full investigation, including the two wrong hypotheses and the measurement
flaw that protected them, in docs/history/kernel.md section 13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-12 21:34:36 +02:00
Nexoniarz
1e52cb591b boot: the loader jumped into the framebuffer, because rbx was not clobbered
Boot died straight after "exiting boot services" with a KVM internal error,
RIP=0x801a5dc5 and RBX=0x80000000 -- the framebuffer base. The bytes at RIP were
0x0C0E13 and 0xD1D5DC: SCH_BG and a grey from the splash's metal gradient. The
machine was executing the picture of itself starting up.

The hand-off to the kernel writes rbx and did not say so:

    "mov %1, %%rbx\n\t"      <- writes rbx
    "jmp *%2\n\t"
    : : "r"(PML4_ADDR), "r"(fb), "r"(entry)
    : "memory");            <- rbx not declared clobbered

With rbx absent from the clobber list the compiler may allocate an input there,
and clang 21 allocated `entry`:

    movq %rax, %cr3
    movq %rcx, %rbx         <- the framebuffer, into rbx
    jmpq *%rbx              <- %2 was rbx, and now holds the framebuffer

Declaring "rbx" forbids the allocation. Verified in the generated assembly
rather than by reasoning: jmpq *%rbx became jmpq *%r14, and the machine now
boots through to schinit: ready.

This had been latent since the loader was written. It worked only because every
compiler that had built it happened to choose a different register, and it
surfaced the first time the binary was relinked with a different clang -- which
only happened because the previous commit fixed the Makefile dependency that had
been leaving the loader stale. A stale artifact hides the bug the fresh one
reveals.

It is also the third time this project has been broken by a register the code
depends on but never asks for: docs/history/kernel.md section 4 (the BIOS
loader passing the framebuffer in EAX by luck) and section 8 (%fs:0 used as
scratch when the ABI reserves it). Written up as section 7b, with the one-line
recipe that settles all three -- read the generated assembly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-12 18:44:24 +02:00
Nexoniarz
57aff40228 docs: four directories, and a rule for when a thing leaves the roadmap
docs/ was ten files where a plan, a description, a guide and a bug report all
sat next to each other. It is now architecture/ (what SchOS is), plan/ (what it
will be), guides/ (how to work on it) and history/ (why it is like this), with
an index that states the one rule holding it together: when something is
finished it moves from plan/ to architecture/ and is rewritten as a description
of the thing rather than a record of building it.

A ticked checkbox says the work happened. It does not say what the work
*produced*, and six months later the checkbox is all that is left. The network
stack proved the point: finished, verified end to end, and for a month the only
description of it anywhere was a checkbox four levels of indentation deep.

So roadmap.md went from 534 lines to 181 -- nothing deleted, the done work
graduated. Seven subsystems that were finished and undescribed now have
descriptions: boot, kernel, syscalls, filesystem, networking, graphics, usb,
init. Every one ends with "What it does not do", which is the section that pays;
a document listing only strengths is marketing, and the reader who needs it most
is the one about to trip over a limitation nobody wrote down.

schfs.md still opened with "phases 1 through 3 1/2 are done, phase 4 is next"
long after all five had landed. It is a description now, with the phase record
and the hard-won bugs moved to history/.

Three corrections found on the way, all load-bearing:

  * projects/schcom/README.md claimed SCM_RIGHTS was done. There is no
    sendmsg/recvmsg in the kernel at all, and an entire GUI plan rested on it.
  * The root README said "No TCP/IP" months after the stack landed.
  * history/kernel.md sec.7 was marked "in progress" on a problem that process
    groups solved.

Also adds plan/1.0.md, which draws a finish line so the open list stops being
infinite, and marks schwm/ and schde/ superseded: in Wayland the compositor is
the window manager, so that split dissolves rather than being abandoned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-12 17:13:35 +02:00