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:
parent
b6450d866d
commit
14f0e4d0e2
22 changed files with 684 additions and 24 deletions
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue