Console Input & the qsh Shell
Epic #62, phase 1 (issue #63). This document describes the interactive console — the OS's first input path — and qsh, the ring-3 shell built on it.
Why
Until this phase, everything QuantumOS ever did was decided at build time: it booted a fixed constellation of services, ran its gates, and idled. Output existed (the COM1 boot console); input did not. The console input path plus a shell turn the demo kernel into a system a human — or CI — can actually sit at: inspect live state, exercise capabilities, and watch supervision work.
Console input path (kernel/src/console.c)
One 1 KiB ring buffer, two interrupt-driven producers:
- COM1 RX (IRQ4) — bytes typed into the serial console: QEMU
-serial stdio, the qBraid watch window, or CI's piped stdin. - PS/2 keyboard (IRQ1) — scancode set 1 translated to ASCII (US layout, shift tracked; extended
0xE0keys ignored for now), for interactive/graphical boots.
The COM1 transmit path is untouched — it remains early_console_write in boot.S. console_write() adds a raw byte sink (no [user pid] prefix) so a shell can own its own line discipline; every write runs with interrupts disabled so a timer-tick log line can never split it.
Public API (kernel/include/kernel/console.h)
| Function | Purpose |
|---|---|
console_init() | Program COM1 line settings, rescue any byte already received, enable the RX interrupt, reset PS/2 state |
console_com1_irq() | IRQ4 body: drain received bytes into the ring |
console_kbd_irq() | IRQ1 body: translate one scancode, push ASCII |
console_read(buf, len) | Non-blocking drain of buffered input (0 if none) |
console_write(buf, len) | Raw, interrupt-atomic COM1 output |
DEVICE_ID_CONSOLE (= 0x3F8, the port base) identifies the console as a capability resource, the same self-identifying convention as DEVICE_ID_COM2.
The init-ordering subtlety
CI pipes shell input from t=0 — QEMU delivers the first byte into the receiver long before the kernel reaches console_init(). Two rules keep every byte alive:
- Never clear (or even toggle) the FIFOs. QEMU forces a FIFO clear on any change of the FIFO-enable bit, and QEMU refills the receive register asynchronously the moment the guest drains it, so there is no point in the init sequence where a clear is provably safe (a real byte was lost to exactly this race during development:
helparrived ashep). Per-byte RX interrupts are ample for a console. - Drain before enabling the interrupt. Any byte already waiting is pushed into the ring first; a byte that lands between the drain and the IER write still raises the IRQ, because Data-Ready is level-evaluated when IER changes.
The smoke test gates this with the input integrity check: the first piped command must execute intact (qsh: commands: help).
Syscalls
SYS_CONS (15)
rdi = op (SYS_CONS_READ/SYS_CONS_WRITE), rsi = buffer, rdx = length. Raw console I/O, moved through a bounded kernel bounce buffer (CONS_MAX_BYTES = 256) and clamped to the caller's mapped user half. Gated on a CAP_RESOURCE_DEVICE capability over DEVICE_ID_CONSOLE — CAP_READ to read, CAP_WRITE to write, EPERM without. Reads are non-blocking (0 when nothing is buffered). Only qsh is granted the console, and the capless ghost_test proves the denial by attack every boot (CONS: capless caller denied (EPERM)).
SYS_SYSINFO (16)
rdi = op (SYSINFO_PS/SYSINFO_MEM), rsi = buffer, rdx = length. Kernel-formatted, read-only introspection text — uncapped like SYS_GETPID/SYS_TICKS, because it names no authority:
SYSINFO_PS— onePS: <pid> <name> <STATE>line per live process, formatted byprocess_format_ps()inprocess.c(which owns the table). The kernel formats so the user side needs no struct ABI.SYSINFO_MEM— oneMEM: heap free=<n> bytes, frames free=<a>/<b>line from the kernel heap and physical frame allocator.
Output is bounded by SYSINFO_MAX_BYTES (1024); the PS formatter stops at whole rows.
qsh (user/qsh.c)
A ring-3 user-process service, watchdog-monitored like every other citizen. Declarative grants (re-minted on every start): the console device capability and a quantum-pool read capability. One IPC send-cap each way wires it to ghostd.
Line discipline lives in the shell, not the kernel: qsh echoes, handles backspace, assembles lines. Every logical output line is written with a single SYS_CONS call so tick logs never split it.
Builtins: help, echo <text>, ps, free, uptime, pid, qrand, qseed, ghost (STATUS query to ghostd over capability IPC), imprint [--energy <0-100>] <text> / recall <probe> (the kernel holographic field, epic #95: the shell holds the CAP_RESOURCE_FIELD capability over region 0, so the operator can store text and recover it later from a corrupted probe — recall the cxt sxt returns FIELD: winner="the cat sat" with the exact stored bytes; the optional --energy percent sets the slot's importance, which drives recall ranking and eviction), field (epic #127 B1: SYS_FIELD_INFO, a READ-ONLY enumeration of the region — FIELDINFO: region=0 live=N cap=8 then a FIELDSLOT: line per live slot with its energy, effective energy, retrievals, age, and a bounded preview; unlike recall it does NOT reinforce, so it never perturbs the field), fieldtest (asserts the cross-region EPERM and degenerate-probe n=0 contracts from the cap-holding side; ghost_test proves the capless side), audit (epic #133 Phase D: SYS_AUDIT, the capability AUTHORITY LEDGER — the KERNEL records every capability GRANT, DENY, and SPAWN, so a citizen cannot forge or suppress its own entry; audit prints AUDIT: total=… then an `AUDIT: seq=… pid=… kind=GRANT|DENY|SPAWN|MDENY|QUOTA res=TYPE:id perms=… verdict=OK|EPERM` line per event — read-only introspection that makes "authority IS the capability set, and every attempt to exceed it is provable" observable), manifest (epic #135 Phase D: SYS_MANIFEST, the per-pid INTENT MANIFEST — a policy layer above raw capabilities that the KERNEL builds from each citizen's grant flags, so a citizen cannot forge its own row; manifest prints a `MANIFEST: pid=… bound=1 spawn=used/max cpu_ticks=… line per bound citizen plus a MANIFEST: pid=… allow res=TYPE:id perms=…` line per declared resource — declared intent made inspectable, and the spawn quota's enforcement observable), clear, exit.
exit is a supervised death: the shell terminates, its heartbeat goes silent, and the service watchdog restarts it (~2 s); the reborn shell introduces itself with QSH: reborn (restart=N). That banner is the merge-gate proof that the operator surface survives its own crash.
qseed probes the quantum-pool capability out of band (via qrand_seed_present(), whose success space is only {0,1}) before reading the seed. SYS_QSEED returns an unconstrained 64-bit value, so testing the returned value directly against the -4 (EPERM) errno sentinel — as it once did — misreported the single valid boot seed 0xFFFFFFFFFFFFFFFC as a denial. The ci-smoke-qseed boot pins that exact collision seed and asserts qseed prints it rather than denied (EPERM).
CI gates (all in make ci-smoke and the Integration job)
The smoke test pipes help ps free uptime ghost qrand exit into QEMU's stdin and asserts, from the same boot:
QSH: QuantumOS interactive shell ready— shell up with its console capqsh: commands: help— first piped command intact (input integrity)qsh RUNNING—psshows the shell observing itself in the tableMEM: heap free=— live memory statsqsh: ghost R=— field status fetched from ghostd over IPCqsh: uptimeandqsh: qrand— kernel ticks + quantum-pool drawCONS: capless caller denied (EPERM)— capability gate, by attackQSH: reborn— watchdog restarted the shell afterexit
Quiet boot: a clean interactive console (quiet)
The demo kernel narrates itself: a per-second Timer tick: heartbeat, the paradoxd/ghostd services logging their steady-state dynamics, the per-process lifecycle (Process created/destroyed, reaped process, syscall: user process exited), the per-service service started / health-monitor lines, the demo kernel-threads' alive heartbeat, and the watched-svc watchdog demo's online/going silent. Because the flaky-demo service crashes and restarts on a ~1.5 s cycle, most of this repeats forever. All of it lands on the same serial console qsh uses, so for hands-on interactive work (typing http, nslookup, …) the prompt scrolls away under the chatter.
Adding the token quiet to the kernel command line (-append quiet, alongside qseed=…) silences that steady-state output:
- the kernel's periodic timer-tick heartbeat is suppressed (the one-time "interrupts are live" line still prints);
boot_log_v()— aboot_logvariant that no-ops underquiet— carries the kernel's repeating lifecycle/demo lines (process create/destroy/reap, service start, thealivethread heartbeats); label+hex pairs are gated as whole blocks so quiet never emits an orphaned hex value;SYSINFO_QUIET— a bufferlessSYS_SYSINFOop — exposes the flag to ring 3, and the chatty ring-3 services (paradoxd,ghostd,watched-svc) query it once and go silent on their console output.
One-time boot milestones (…: online, QuantumOS ready, the NET: self-test) still print, so a quiet boot is legible; only the repeating chatter is gone. Measured over a 9 s boot, the ~76 repeating lines a normal boot emits drop to a single one-time line, with qsh fully usable.
The services still run and do their work; they just stop narrating. The default boot is unchanged — quiet is strictly opt-in, so every CI gate that depends on the normal boot output still holds. make ci-smoke-quiet boots -append quiet, types help, and asserts both that the shell still answers and that the heartbeat/chatter is gone. (The kannaka lab harness exposes this as lab-qos-boot --quiet, which pairs with --network for a clean networked shell on a cloud box.)
The screen console: output on real hardware (epic #101)
On a laptop with no serial port, everything above still works — and is completely invisible: input arrives via the PS/2 keyboard, but the boot log, qsh, and even panics used to speak COM1-only. The screen console (kernel/src/vga.c) makes the machine's own display a first-class output device.
API (kernel/include/kernel/vga.h):
| Function | Role |
|---|---|
vga_console_enable() | Take over the 80x25 text screen from the boot splash; called after splash_ready() on text-mode boots only (!fb_available()) |
vga_console_active() | True once enabled — the tee points check this |
vga_console_putc(c) | Write one char (\n/\r/\b/tab handled). Does NOT move the HW cursor — see vga_console_sync |
vga_console_puts(s) | Whole string under an IRQ guard (interrupt-context boot_logs would otherwise tear lines), then one cursor sync |
vga_console_sync() | Move the HW cursor to the pen position — 4 port writes, batched once per write because every port write is a trap under QEMU TCG |
vga_panic_banner(msg) | Red panic banner, independent of console state; called from boot_panic (boot.S) so early bring-up failures are visible without a serial cable. Skipped when a linear framebuffer owns the display |
Tee points: console_write() (the SYS_CONS sink), boot_log(), and user_console_write() (the SYS_WRITE sink — every ring-3 program's [user pid=N] output) write serial first, then the screen when the console is active. The SYS_WRITE tee was the third real-laptop finding: citizens ran to a clean exit 0 while their output went only to a serial port that did not exist. Serial remains authoritative — every CI gate reads it, none changed. console_write also latches off a dead COM1 (transmit register never drains within the spin cap): a machine with no UART behind 0x3F8 pays the spin once, not per byte forever, and the screen carries on alone.
Why it wraps instead of scrolling — two designs were rejected with evidence. A memmove scroll costs ~4000 0xB8000 accesses per line, each an MMIO callback under QEMU TCG, all with interrupts off: it starved the ring-3 services outright (ghostd's field never synchronized, heartbeats missed, watchdog reborn-storms) and broke the paradoxd/ghostd coupling CI gate on ~half of runs. CRTC start-address panning is cheap but QEMU's text renderer places the origin at 2x the programmed start value while real VGA uses 1x (character units) — verified by poking the CRTC from the QEMU monitor and screendumping — so no single value renders correctly on both. The wrap console (pen returns to the top and clears ahead of itself; a moving blank separator marks the newest line) costs a flat 160 cell writes per line and no CRTC state at all.
Booting into it: make iso builds a GRUB ISO with three menu entries backed by two images of the same kernel. GRUB honours the multiboot header's video request over gfxpayload (verified: text/keep still produced a linear framebuffer), so the console entries boot kernel-console.elf — built with -DMB1_TEXT_ONLY, no video request → VGA text → screen console. The default QuantumOS (console) entry adds quiet: on the first real-laptop boot the demo kernel's narration outran the shell prompt faster than a human could type, so the default is the usable interactive machine and console, verbose kernel log is the debugging entry. The graphical wave field entry boots the video-requesting image (1024x768 splash + live field view; its text stays on COM1).
Hardware without a COM1 UART — the first real-laptop boot froze at the 45% splash stage: with no UART behind 0x3F8 every port read floats to 0xFF, so LSR permanently reads "data ready" and console_init's RX rescue drain spun forever (reproduced exactly in QEMU with -serial none). console_init therefore probes first: a scratch- register echo test (0x5A/0xA5 written to SCR and read back — a floating bus can't echo) sets the presence flag exposed as console_com1_present(). When absent, all COM1 setup is skipped, the transmit tee is latched off, IRQ4 stays masked, and the boot log says so honestly: `CONS: no COM1 UART detected — screen/keyboard console only. The RX drain is bounded (64 bytes, bails on a floating 0xFF` LSR) and the PS/2 stale-byte drain gets the same treatment — nothing in the console layer may ever spin on a status bit the hardware can float.
CI gates (the Real-Hardware Boot Path job):
make ci-smoke-iso— boots the ISO with-cdrom(the real GRUB handoff, not QEMU's-kernelshortcut) and asserts the boot gates,CONS: screen console active (VGA text 80x25), the shell session, and a citizen gate, all from one boot.make ci-smoke-kbd— drivesqshpurely via PS/2 scancodes injected through the QEMU monitor (sendkey); serial carries no input that run, so the executedhelpproves the i8042/IRQ1 path a real laptop keyboard uses.make ci-smoke-noserial— boots with-serial none(no COM1 device at all: ports float exactly like serial-less hardware) and verifies the Lamport boot attestation arriving on COM2 — proof that full service bring-up completes with no console serial port.make ci-smoke-screen— dumps the live VGA text cells through the QEMU monitor (scripts/check_vga_text.py) and asserts a ring-3[user pid=line is actually ON the screen. Serial gates can't see the display; this one reads what the operator reads.
Known limits / follow-ups
- The graphical (framebuffer) boot has no text console yet — its shell output stays on COM1; the screen console is text-mode boots only.
- USB keyboards rely on the BIOS's PS/2 legacy emulation (CSM boots); there is no native USB HID driver. The ISO is BIOS-boot only — no UEFI layer yet.
- The multiboot memory map is not read; the PMM assumes 128 MB (
memory.chardcodes it), so RAM beyond that is ignored on real machines. - Peer IPC capabilities (qsh ↔ ghostd) are not re-minted on watchdog restart — only declared resource caps are. A reborn shell keeps its console but loses
ghostuntil this service.c limitation is fixed (shared with the other demo peers). - Extended PS/2 keys (arrows, etc.) are swallowed; no line history.
- Next phases of epic #62: an embedded initrd + read-only VFS (
ls,cat, issue #64), thenSYS_SPAWN/SYS_WAITPIDso the shell can run programs from the filesystem (issue #65).