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>
This commit is contained in:
Nexoniarz 2026-09-22 00:29:39 +02:00
commit b012060699
4 changed files with 112 additions and 108 deletions

View file

@ -206,24 +206,15 @@ right then.
in 18 after.** See [../architecture/kernel.md](../architecture/kernel.md),
"Per-CPU state".
- [ ] **`$(command substitution)` hangs every shell.** Not job control, not
SMP, not zsh — `mksh -c 'X=$(echo hi)'` hangs the same way, on one CPU,
with `set +m`. Pipelines, subshells, background jobs and foreground
externals all work; only `$( )` does not.
Narrowed a long way this session and still open. The kernel's half of
the SIGCHLD/sigsuspend contract is now *proven* rather than assumed —
`cmd/sigtest` asserts the whole pattern and passes — and a full syscall
trace of a hang shows every return value correct. The contradiction is
at one line of zsh's `waitforpid()`, which ends its loop on `ESRCH` from
`kill(pid, 0)`; the trace shows that `kill` returning ESRCH and the loop
being entered regardless. See
- [x] ~~**`$(command substitution)` hangs the shell.**~~ **Not a kernel bug.**
`ports/zsh/Makefile` answered configure's "does `kill(pid, 0)` return
ESRCH correctly?" with `no`, which makes zsh `#define ESRCH EINVAL`.
`waitforpid()` ends its wait on `errno != ESRCH`, so with the kernel
correctly returning ESRCH (3) and zsh comparing against EINVAL (22), the
shell waited for ever on a child it had already reaped. One word in a
cross-compile cache. Autoconf's own default for that check is `yes`;
omitting the line would have been right. See
[../history/kernel.md](../history/kernel.md), section 7.
Two real bugs were found and fixed on the way: the signal-return
trampoline was hardcoded to Linux's `rt_sigreturn` number, so handlers
in first-party binaries called `open()` and then executed stack bytes;
and `SIG_BLOCK`/`SIG_UNBLOCK`/`SIG_SETMASK` were not defined anywhere.
- [ ] **`getrusage` does not distinguish `RUSAGE_CHILDREN`** from `RUSAGE_SELF`.
- [ ] **`/proc/<pid>/exe` is a regular file, not a symlink**, so `readlink()`
fails and `ps -o comm` prints `[init]`. Symlinks themselves now work —