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
This commit is contained in:
Nexoniarz 2026-09-12 23:26:50 +02:00
commit 14f0e4d0e2
22 changed files with 684 additions and 24 deletions

View file

@ -904,3 +904,130 @@ separate and deliberately does not follow it: musl answers
are scaled at the syscall boundary the way Linux does it. Before, `sys_times`
returned raw ticks -- correct only while `HZ` happened to be 100, and silently
wrong the day anyone changed it.
## 14. A shell that would not start, and four bugs underneath it
Porting zsh was supposed to be a port. It found four defects in the kernel and
one in the filesystem, none of which had anything to do with zsh, and every one
of which had been there the whole time.
### The environment did not exist
`kernel/fs/exec.c` built the initial stack for every new program and wrote this:
```c
stack_slots[slot++] = 0; // end of envp (no environment)
```
Not "no environment yet" — no environment, ever, for any process. musl calls
`execve(path, argv, envp)`, which is Linux syscall 59, which SchOS answers; the
third argument was simply never read. So `$TERM` was unset, `$PATH` was whatever
each program invented for itself, and a variable exported in a shell did not
survive the exec that was supposed to carry it.
That is fatal for zsh specifically. A line editor with no `TERM` cannot ask
terminfo how to move a cursor, so ZLE is exactly as useless as it would be with
no terminal library linked at all.
`exec()` takes an `envp` now, `sys_exec` reads the third argument, and schinit
manifests grew an `Environment` key:
```
Environment = TERM=linux
Environment = PATH=/bin:/usr/bin
```
The userland `exec(path, argv)` spelling stays, as a wrapper in `lib/glue.c`
that passes a null environment explicitly. The raw stub is `execve` now —
otherwise a two-argument caller would leave `%rdx` holding whatever happened to
be in it, and the kernel would read that as a pointer.
### Which immediately overflowed the kernel stack
The three pointer arrays exec needs came to about 1.8 KB, and `KSTACKSIZE` is
4096, under a call chain that already runs exec → loaduvm → readi → SchFS →
btree. The first exec after the change took pid 1 down.
The canary caught it cleanly, named the process, and printed a stop code, which
is the whole reason it exists. The arrays live in one `kalloc`'d page now.
### `wait` said "operation not permitted"
```
compdef:80: wait failed: operation not permitted
```
`waitpid` returned a bare `-1` for two different failures: no children to wait
for, and interrupted by a signal. musl reads any return in [-4095, -1] as
`-errno`, so `-1` arrived in userspace as `EPERM`.
zsh saw it, printed that message, and abandoned the rest of `/etc/zsh/zshrc` —
so the shell came up with its default prompt and no completion, which reads as
a broken config file rather than as a `wait()` that could not say what happened.
It returns `-ECHILD` and `-EINTR` now.
This is the third time a bare `-1` has cost real time (see §12, `sys_chdir`).
**EPERM on SchOS should be read as "a syscall returned -1 and meant something
else."** There is no permission model here for it to legitimately come from.
### mmap ran out of slots and blamed memory
```
compdef:80: fatal error: out of memory
```
On a machine with 1000 MB free, of which zsh had used 1.5. `NVMA` was 64 — the
per-process mmap table — and musl's allocator takes a fresh mapping per size
class and does not release them eagerly. compinit reads twelve hundred
completion functions, ran out of slots, and got `-1` from `sys_mmap`, which is
`EPERM` again, which `malloc` turns into NULL, which zsh reports as out of
memory. Three translations from the truth.
`NVMA` is 256 and the failure returns `-ENOMEM`.
### And underneath all of it: a small write costs 58 ms
With everything above fixed, `compinit` still appeared to hang. It was not
hanging. Measured on this machine, 200 writes:
| | time | per write |
|---|---|---|
| to one open file on SchFS | 11.75 s | **58 ms** |
| open + write + close each time | 21.39 s | **107 ms** |
| to `/dev/null` | 0.14 s | 0.7 ms |
`compdump` writes its digest with a few thousand separate `print` calls. At
58 ms each that is minutes, and it looks exactly like a hang. `compinit -D`,
which skips the dump, finishes in 3.6 seconds — the scan was never the problem.
The cause is not a bug; it is the design, stated plainly at the top of
`kernel/fs/schfs/kmount.c`:
> Every entry point below that changes anything is one transaction group: it
> takes the volume lock, does its work, and commits before returning. That makes
> an operation durable the moment it returns.
A commit is two cache flushes and an uberblock write, and a flush is a barrier.
`schfs_ktx_open` already lets one `write(2)` of 1 MB be a single group — 267
block writes instead of 1410 — but nothing brackets *across* syscalls, so two
thousand small writes are two thousand barriers.
`cp` does not notice, because it uses large buffers. Anything that writes a line
at a time notices enormously.
**This is the open question, and it is a design decision rather than a fix.**
Deferring commits would mean giving up durable-on-return, which is a property
SchFS chose deliberately. The options are a writeback window like every other
filesystem has, committing on `fsync`/`close`/timer, or leaving it and accepting
that line-at-a-time writers are slow. Until it is decided, `/etc/zsh/zshrc`
leaves `compinit` switched off and says why.
### What zsh actually got
It runs, as `/bin/zsh`, and it is the console shell. ZLE works: arrow keys
recall history through terminfo, `$COLUMNS` reads 160 because `TIOCGWINSZ` now
answers with the real console geometry (§13), prompt expansion and colour work,
and startup is 0.14 s. mksh keeps `/bin/sh`, so schinit's no-jobs fallback is a
different program from the one that might have failed.
Completion is the piece that waits on the filesystem.