In this blog I share my observations, thoughts and experience about computers, linguistics, philosophy and many other things that interest me.

Sunday, April 26, 2026

QRV v0.26: mksh — A Real Shell for a Real System

esh served its purpose. It got QRV to a shell prompt, proved the IPC stack, and ran through two months of bring-up. But it has no variables, no conditionals, no scripting. What QRV has needed for a while — and now has — is a real shell.

v0.26 lands mksh, the MirBSD Korn Shell (R59, 2025-04-26), ported from scratch in a single day. It runs interactively, handles history with cursor keys, shows the current directory in the prompt, and runs sequences of commands without exiting:

MIRBSD KSH QRV-1 (mksh R59 base)
[/]# ls
bin  dev  proc  rd
[/]# cd /disk2/bin
[/disk2/bin]# ls -la
[/disk2/bin]# ./syscall_testing
...
=== Results: 89 tests, 89 passed, 0 failed ===
[/disk2/bin]#

The [/disk2/bin]# prompt is live: mksh re-evaluates $PWD on every prompt redraw. The cd you just ran is reflected immediately.

This matters beyond the convenience. A real shell means the init script that lives in modpkg.cpio — the script that starts every daemon at boot — can now have conditionals, variables, functions, and proper error handling. That is the foundation of a BSD-style rc system: hardware probing, conditional driver loading, different boot scenarios from a single init script. The modest CPIO image suddenly has real expressive power.


What the Port Required

mksh is about 37,000 lines of C. The upstream codebase carries three decades of platform accretion: EBCDIC support, OS/2, z/OS, Windows, AIX, Solaris, multiple editor modes (emacs, vi, gmacs), a German-language boolean type (Wahr/Ja/Nee/isWahr), and a 3,147-line monolithic sh.h that aggregates everything from every platform the shell has ever run on.

None of that goes into QRV. The port started with explicit disciplines: Apache-2.0 relicensing (enabled by the MirOS sublicensing right, with the original MirOS text preserved), K&R formatting, no fork() by design, poll() instead of select() as a QRV project rule, and every line earning its place.

sh.h: from 3,147 lines to 244

The original sh.h was replaced with a 244-line foundation containing only what the .c files actually use. Everything else was extracted into topical headers: env.h, shf.h, cclass.h, var.h, lex.h, proto.h, tree.h, msgs.h. Each domain in its own file, with a one-line rationale for every declaration that remains.

German booleans

931 substitutions, automated:

Wahr   → bool      (296 hits)
Ja     → true      (292)
Nee    → false     (309)
isWahr → ((bool))  (34)

These were fine in mksh. They have no place in QRV.

The line editor

mksh's edit.c is 5,832 lines: emacs mode, vi mode, gmacs mode, kill rings, undo, configurable bind tables, modal editing, vi tab-complete heuristics. QRV needs exactly none of that. A fresh line editor was written from scratch — 553 lines — covering the keystrokes that actually matter:

^A/^E/^B/^F       home / end / back / forward char
^P/^N + arrows    previous / next history
^R                reverse incremental history search
^K/^U/^W          kill-to-eol / kill-line / kill-word-back
^L                redraw
DEL/^H/^D         backspace / delete / EOF
ESC [A/B/C/D      cursor keys

Net: −5,279 lines of code the QRV shell will never need.

No fork()

QRV has no fork() by design. The original exchild() in jobs.c was 135 lines of fork-then-child-or-parent bookkeeping. The replacement is 25 lines: run the AST in this process, update job bookkeeping. External commands go through posix_spawn() + waitpid() at the TEXEC site in exec.c. The shell stays resident throughout.

This was also the source of v0.26's last bug. The fork-removal rewrite unconditionally passed XEXEC to execute(). In upstream mksh, XEXEC means "I'm a forked child — call unwind(LEXIT) when done." With no child, that unwind exited the shell after the first external command. Found and fixed: the test is simple in retrospect — run ls, then run pwd, and check whether the shell is still alive.

libc foundations

The port surfaced three missing pieces in QRV's libc, all added as part of this release:

setjmp/longjmp. <setjmp.h> had been declared but never implemented. The RISC-V assembly saves callee-saved integer registers (ra, sp, s0s11), mirroring the layout of the kernel's existing xfer-fault recovery path. Nine new tests: round-trips, the longjmp(env, 0) → 1 POSIX rule, callee-saved values surviving across call boundaries.

posix_spawn / posix_spawnp. The kernel side (_PROC_POSIX_SPAWN) and the spawnattr_* / file_actions_* helpers already existed. The entry point symbols were simply missing. Adapted from openqnx's reference implementation with the Adaptive Partitioning wire format removed (it was removed from QRV's taskman in an earlier release).

Signal API. POSIX signals implemented on top of QRV's existing pulse model. A per-process signal table in libc; the system thread that every QRV process already has translates _PULSE_CODE_SIGNAL pulses into sigaction-registered handlers. sigaction, sigprocmask, signal, raise, and kill all work. 19 new tests, all passing. raise(SIGUSR1) runs the handler synchronously before returning; kill(getpid(), SIGUSR2) round-trips through taskman → pulse → system thread → handler.

The test suite grew from 61 to 89 tests across this release: 9 for setjmp/longjmp, 19 for signals. All 89 pass.


qrvfs Format v2

One more thing that landed quietly: the filesystem got a format upgrade. qrvfs v1 used 9 direct + 1 single-indirect block, capping file size at ~2 MiB. That was fine for a test image. It is not fine for anything approaching real use.

v2 repartitions the same 10 address slots: 7 direct + single + double + triple indirect. Maximum file size: ~512 GiB. mkfs-qrv was rewritten around a recursive walk_indirect() that allocates index blocks lazily. A 1.1 GiB test file round-trips byte-perfect, exercising the triple indirect path.

v2 superblocks are incompatible with v1. qrvfs_init() rejects v1 images with a clear error.


What Comes Next

Background jobs (posix_spawn with detach rather than waitpid), poll() with real readiness probing (wiring _IO_NOTIFY through the resmgr layer), and getppid() / umask() / ioctl() / tcflush() — the eight libc stubs that currently back the shell's less-used features. Then the init script gains real conditionals, and the rc system starts to take shape.

No comments: