Design Note: Console Service - separating logs from the interactive console
Status: BUILT and shipped. services/console/ is the terminal; 1,172 lines of terminal
emulation left the kernel for it, and what ring 0 kept is the boot/panic blit in kernel/src/bootcon
(CLAUDE.md 11.4, amended 2026-08-17). Hardware-verified on the Pi 2: chaos 50 rounds, 0 kernel
panics, selfcheck 350/0.
(This line said "DESIGN (draft for discussion). Not yet implemented." long after it shipped. The
design below is unedited - it is why the console looks the way it does - but the header claimed the
opposite of the truth, which is worse than being thin.)
Date: 2026-06-05
Pins: §26.10 (kernel = mechanism, not policy), Appendix B.3 (shell = capability-broker holding a console cap), Appendix C.1 / live observe.
1. Problem
There is one console stream, and everything dumps to it. The kernel's
kprintln, every service's ctx.log/ctx.print, the xhci driver's progress,
and the shell's prompt all write the same append-only stream that is mirrored to
both the serial port and the framebuffer (TV). So the interactive prompt
fights with asynchronous log output: gsh> and xhci: keyboard ready race for
the bottom of the screen, observe now output interleaves with whatever else is
logging, and there is no way for a full-screen view to own the display.
This blocks two things:
- A clean, stable interactive prompt on the TV.
- Live
observe- a full-screen view needs to clear+home and repaint in place, readqto quit, and own the screen so log lines don't smear its frame. Impossible while logs share the surface.
The interim fixes already in main (the boot-flush yield, the observe now
park-wait, the inline prompt) are workarounds for not having this separation.
2. Current state (what exists today)
(Note 2026-10-09: "today" is 2026-06-05. The output diagram below shows the in-kernel fbcon that
§9 removed, so fb::put_byte is not the path any more and the TV is drawn by the console
service. §9.2 has the split as built.)
Output - all one path:
kprintln! / ctx.log (syscall 5) / ctx.print (syscall 22)
→ kernel log::write_fmt
→ serial_write_byte (COM1) ─┐ mirrored, one stream
→ fb::put_byte (framebuffer/TV) ─┘ to both surfaces
Input - kernel-owned ring:
USB keyboard → xhci driver → gs::driver::input::push (syscall 20)
→ console_push_byte → console input ring (+ echo to the console) + wake
shell → ConsoleRead (syscall 17) → reads the ring
events holds the bounded IPC trace ring (474 lines, utilities/47_events.md)
- it was a stub that logged "ready" and parked when this was written. What has NOT
changed is the half that matters here: all logging still short-circuits through the
kernel ring buffer to serial and the framebuffer, and no log line goes to the
eventsservice. That is deliberate, not an omission - logging must not depend on a service that can be killed (CLAUDE.md 11.4).
3. The split
The fix is to stop having one stream. There are two distinct kinds of output, and they want different destinations:
| Kind | Examples | Wants to go to |
|---|---|---|
| Log (diagnostic) | kernel kprintln, xhci: progress, spawn[...], cap-test | the log stream (serial + a queryable buffer) |
| Console (interactive) | the shell prompt, observe output, command results | the interactive console (the TV surface) |
And the interactive console is owned by a console service the shell brokers (Appendix B.3) - it holds the keyboard + display and gives the shell a clean surface, separate from the log firehose.
Division of labour (per §26.10 - kernel is mechanism, console service is policy):
- Kernel keeps the mechanism: rendering a glyph to the framebuffer, the serial UART, the keyboard input ring. It does not decide layout.
- Console service owns the policy: the terminal model - where the prompt sits, what scrolls, the cursor, foreground ownership for a full-screen app. It drives the display through a kernel console-output capability and reads the keyboard through a console-input capability; the shell holds a cap to it.
The two physical surfaces fall out naturally:
- Serial = the log/debug stream. Unchanged for debugging (TeraTerm shows the verbose logs). Kernel + service logs go here.
- Framebuffer/TV = the interactive console, owned by the console service. The kernel stops mirroring log output to the framebuffer; the console service owns what the TV shows.
4. Proposed staging
A full console service is a genuine subsystem (Appendix D calls it far-future). Stage it so each step is useful on its own.
Stage 1 - separate the streams (clean TV, no new service yet)
- Split output into log vs console at the API:
ctx.log/kprintln→ log stream → serial (+ the kernel ring buffer, later drained by theevents). No longer mirrored to the framebuffer.- a console output path → the framebuffer (the interactive surface).
- The shell's prompt/results and
observe's frames use the console path; all thexhci:/spawn[...]/kernel diagnostics use the log path. - Result: the TV shows a clean interactive session; serial keeps the logs. The boot chatter stops smearing the prompt on the TV without silencing it on serial. This alone fixes the felt problem and needs no new service - just a routing split in the kernel console layer.
Stage 2 - the console service (userspace) + terminal model
- A userspace
consoleservice owns the interactive surface: a scrolling output region plus a fixed input line redrawn after any output, the cursor, and a foreground-app API (take the screen, clear+home, stream keys, release). - The shell brokers it (holds a cap);
ctx.log-style console output from the shell goes through the console service. - Unlocks live
observe: it asks the console service for the foreground, repaints each tick, readsq, and releases - with no log lines smearing it.
Stage 3 - events becomes real (optional, parallel)
- Route
ctx.logto theeventsservice (today a stub) so logs have a real home (a queryable buffer,osdev logs <svc>, later a file viafs). Independent of Stages 1-2; makes the log stream first-class.
5. Key decisions - RESOLVED (2026-06-05)
All settled per the recommendations below: (1) land Stage 1 first as its
own mergeable win, then Stage 2; (2) console output reaches the framebuffer
via a kernel render path (the kernel keeps glyph rendering = mechanism);
(3) cursor control via a minimal ANSI subset (one escape stream works on
the TV and a serial terminal); (4) the console service owns the keyboard
(Stage 2); (5) log-vs-console routing is by API (ctx.log = log → serial;
new ctx.console_* = console → serial + framebuffer).
Stage 1 mechanism (this branch, first)
serial_write_byte/serial_write_bytes_lockfree→ COM1 only (drop the framebuffer mirror). This makes all existing logs -kprintlnand every service'sctx.log- serial-only automatically; the TV goes quiet during boot.- New
console_write_*(kernel) → COM1 and the framebuffer. The keyboard echo uses this so typed input still shows on the TV. - New
ConsoleWritesyscall (23) +ctx.console_write/console_writeln- the interactive path. Gated byLOG_WRITEfor now (Stage 2 introduces a dedicated console cap held only by the console service). - The shell (prompt + command output) and observe (its frame) switch their
user-facing output to
ctx.console_*. Everything else keepsctx.log→ serial. - Result: TV shows a clean interactive session (shell, echo, observe); serial keeps the full logs and the session, so debugging/capture is unaffected.
5a. Stage 2 direction - REVISED (2026-06-05)
Stage 1 changed the premise. The console service's original headline job - keeping
the input line from being smeared by async log output - is already solved:
Stage 1 stopped mirroring logs to the framebuffer, so the TV only ever shows
console output. The only remaining job a console service was for is foreground
input arbitration for a full-screen live view (live observe reading q).
A confirmed kernel constraint then settled the design: there is one console ring
and one CONSOLE_READ_WAITER (arch/x86_64/mod.rs) - exactly one task reads the
keyboard at a time, and there is no select(). A separate always-on console
service that owns the keyboard would block in ConsoleRead and could not also
field out-of-band "take/release the screen" messages without either busy-polling
(throwing away the idle-halt work) or reworking the hardware-verified USB keystroke
path into IPC messages. The kernel's single-waiter slot is the foreground-input
ownership primitive already.
Decision: build the live-view seam now, shell-brokered, not as a separate
service. The reusable foundation is the utility-facing contract - "become the
console reader, paint via console_write + ANSI, poll q, release on exit" - which
is identical whether the shell or a future console service brokers it. The shell
is already the Appendix B.3 capability-broker and fits the kernel's waiter model.
A dedicated console service is deferred until a real multi-consumer need pulls it
into existence (a real events consuming the log stream, multiple terminals,
multiple foreground apps) - at which point it takes over brokering with zero
changes to the utilities (§26.2: features pulled into existence; nothing built
speculatively, nothing wasted).
This supersedes decision (4) below ("console service owns input"): input stays shell-brokered for now. Decisions (1) Stage 1 first, (2) kernel render path, (3) ANSI subset, and (5) routing by API stand.
Stage 2 as built:
- 2a (mechanism): minimal ANSI subset in the fbcon - clear, cursor position,
erase line, hide/show cursor - plus
InspectKernelquery 9 for screen geometry. - 2c (live
observe): a non-blockingTryConsoleRead(24) and aConsoleEcho(25) echo toggle, both gated byCONSOLE_READ;observegains aMODE_LIVE_FGthat hides the cursor, suppresses echo, repaints in place (home +ESC[Kper line, no full-clear flicker) every ~0.5 s, and pollsq; the shell spawnsobserve-live, stops reading the keyboard while it runs, and resumes when it parks (the foreground handoff). The first client of the seam validates it. - (There is no "2b separate console service" - folded away by the decision above.)
5b. Original options (for reference)
-
Scope for this branch. Stage 1 only (separate streams → clean TV, small, high-impact), or push through Stage 2 (the console service + live observe) in the same branch? Recommendation: land Stage 1 first as its own mergeable win, then Stage 2.
-
How does "console" output reach the framebuffer? The kernel owns the framebuffer (arch layer), so a userspace service can't write it directly. Options:
- (a) Kernel render API / console-output cap - the console path is a syscall the kernel renders to the framebuffer (kernel keeps glyph rendering = mechanism; the service controls layout). Recommended - matches §26.10.
- (b) Map the framebuffer to the console service (like the xhci BAR) - the service renders glyphs itself. More control, but duplicates the font renderer and is serial-blind.
-
Cursor control: ANSI escapes vs positioned-write syscalls. For the console service to manage a terminal (clear, home, move cursor), either the kernel fbcon interprets a minimal ANSI subset (and a serial terminal understands the same escapes for free), or the kernel exposes positioned-write primitives (explicit, but serial-blind). Lean: ANSI subset - one escape stream works on both the TV and a serial terminal.
-
Keyboard ownership. Does the console service own keyboard input (the shell asks it for lines), or does the shell keep reading
ConsoleReaddirectly and use the console service only for output? Lean: console service owns input too, so it can do line editing and route keys to a foreground app (observe'sq). -
Log routing granularity. Stage 1 needs to mark output as log-vs-console. By API (
ctx.log= log, a newctx.console_*= console - simple, explicit) or by a level/tag? Lean: by API.
6. Out of scope (far-future, not this work)
Multiple virtual terminals, a real VT100/xterm emulator, copy/paste, resize, colour themes beyond the current green-on-black. The goal here is a clean, stable interactive console with foreground-app support, not a terminal emulator.
Amendment 2026-09-18: scrollback is IN, and it is the one item on this list that was not a nicety. This section listed "scrollback paging" alongside copy/paste and colour themes, as though the three were the same kind of want. They are not. Without scrollback a framebuffer console loses output permanently: reaching the bottom of the screen scrolls the top away and there is nowhere for it to have gone. That is not a missing convenience, it is the display discarding information the operator asked for - and it is the stated reason
helpandtraceeach grew a pager of their own - andhelp's has since been deleted because this replaced it (§10).The rest of the list stands.
7. First step once a direction is agreed
Stage 1: in the kernel console layer, split the framebuffer mirror off the log path and give the shell/observe a console-output path to the framebuffer; verify the TV shows a clean session while serial keeps the full logs (shell-test + a framebuffer screendump). Then Stage 2.
8. Input under a starved timer ISR - drain-on-read (2026-07-09)
Built 2026-07-09. Not a design question - a robustness fix to the input mechanism of §2, surfaced by the same T630 storm that produced
docs/persistence.md§6.16 (a rapid doublekill all-services). No new syscall, no format change, no amendment.
The keyboard/serial input path of §2 depends on the timer ISR to poll the UART RX line and push
bytes into the console ring. Under a storm - the timer starved by long IF=0 console writes and
IPI/shootdown pressure - that ISR fires late or not at all, so typed bytes pile up in the UART's 16-byte
RX FIFO and never reach the ring. The console looks dead even though the hardware already holds the
input.
Fix: drain on read, not only on tick. The console read syscalls (ConsoleRead / TryConsoleRead)
now call uart_rx_drain_now before consulting the ring: with interrupts disabled (cli), drain the
UART RX FIFO into the console ring, then restore the prior IF. A task blocked in ConsoleRead therefore
pulls its own input straight off the hardware the instant it asks for it, independent of whether the
timer ISR is keeping up. The tick-driven poll stays (it is what wakes a blocked reader), but
correctness no longer hinges on it. This is the input-side echo of the storage stack's Commandment
VIII fix (§6.16): don't wait on a proxy (the tick) for a truth (a keystroke) you can observe directly.
The drain is bounded (16-byte FIFO into a fixed ring) and IF-guarded (it never re-enters and never leaves
interrupts unexpectedly enabled); no new unsafe beyond the existing arch UART accessors.
Companion: lazy, bounded shell history. The shell persists its command history to /users/default/history
(the §15 monotonic-counter pattern). It used to read that file back at startup - an fs round-trip
fired during the shell's own re-init, which is exactly when the storage stack may be mid-recovery (a
contributing masked race in the §6.16 wedge). The read is now lazy: the shell loads /users/default/history
on the first up-arrow, not at spawn. A fresh prompt never blocks on fs coming up, and the shell's
re-init path touches no disk. In-memory history stays bounded (a fixed ring); saving remains best-effort
(§15). Two small changes, one theme: the interactive path must stay responsive precisely when the rest of
the system is busy recovering.
9. Stage 4 - the console service is real, and fbcon leaves the kernel (2026-08-17)
Status: BUILT and HARDWARE-VERIFIED on
feat/pi2-arm32-hardening(Pi 2, 2026-08-17: chaos 50 rounds, 0 kernel panics, 0 liveness wedges, selfcheck 350/0, 0 console writes lost). This section supersedes decision (2)(a) of §5 ("kernel render path") and decision (4) of §5a ("input stays shell-brokered" - still true; only OUTPUT moves). Driven byscripts/commandments.py, for whichkernel/src/fbconwas the last standing Commandment I violation: 1,172 lines of terminal emulation that can claim none of the kernel's six responsibilities (§4.3) and no sanctioned support role.
9.1 Why (2)(a) was wrong
§5 chose "the kernel keeps glyph rendering = mechanism, the service controls layout", reasoning from
§26.10. That reading does not survive contact with §4.4: a font rasteriser, an ANSI/CSI state machine, a
UTF-8 decoder, a shadow grid and a scroll strategy are a display driver, and §4.4 forbids drivers in
the kernel by name. The measured boundary (the commandment walk, 2026-08-14; its surviving findings are now in audits/userspace-audit.md Audit 11) also showed there is
no cheap partial slice - the emulator is reached THROUGH put_byte, so removing CSI handling alone
leaves the shell's cursor moves and colours going nowhere. The service must take rendering entirely.
The recorded user position settled it: "fbcon is not special, only the kernel is special; I really don't
want to continue adding exceptions to the kernel." So option (b) of §5b - map the framebuffer to the
service, as the xHCI BAR is mapped to xhci - is what gets built. Its stated drawback ("duplicates the
font renderer and is serial-blind") is answered below.
9.2 The split
Kernel (bootcon/, 539 lines) | console service (1,785 lines) | |
|---|---|---|
| Serial | owns it, unchanged - still the source of truth | never touches it |
| Framebuffer | a minimal boot/panic blit | the whole terminal |
| Text model | none (no grid, no cursor, no scrollback) | ANSI/CSI, UTF-8, shadow grid, cursor, scroll, reverse video, scrollback (§10) |
| Geometry | private to its own blit, never published | the single source of truth for rows/cols |
Not serial-blind. ConsoleWrite (syscall 23) still writes serial synchronously, exactly as today, so
a captured build/serial_output.log is unchanged and the interactive session still appears in it. What
the syscall stops doing is rendering; it enqueues the same bytes to the service's ordinary IPC endpoint
(no new ring - the bounded queue every service already has). Serial is truth, the display is a mirror -
which is already the stated ARM policy (mirror()).
A full queue blocks the WRITER. The writing task is parked as a blocked sender until the terminal drains (§8.5, §8.6) - the kernel itself never waits. This is a correction to the first draft of §9.2, which dropped instead: see §9.8, item 4, for why dropping could not work at any rendering speed.
Not a duplicated font renderer. The kernel's blit is deliberately cruder than the terminal's: no box glyphs, no reverse video, no shadow grid, and a "scroll" that clears and starts at the top. It is a floor, not a small terminal, and the two are not two copies of one thing.
9.3 What earns the kernel's remaining blit
Impossibility, which is the only thing that does (the commandment walk: the bar the control channel failed and the supervisor spawn clears). A panic halts every core, including the console service, so a panic cannot ask a service to report it. On a Pi wired to a TV with no serial cable, a kernel with no blit dies with a frozen screen and no reason on it - the silent failure invariant 12 exists to forbid. Boot output has the same shape: it precedes every service, including the one that would render it.
Both are the §11.4 ring-buffer argument applied to a machine with no serial port, so bootcon claims the
same kernel-log-floor role and nothing wider. §11.4 is amended to say so, because it currently reads
"serial console", which is x86-shaped - on a PC serial is always there.
9.4 Ownership of the framebuffer is explicit and one-way
Two writers to one framebuffer is not a race the service can defend against: its shadow grid would be silently wrong about what is on screen. So ownership is a state, not a convention:
kernel owns it --(framebuffer GRANTED at spawn: bootcon::release)--> service owns it
^ |
+------ bootcon::reclaim_on_death (the service died) --------------------+
+------ bootcon::reclaim_for_panic -------------------------------------+
(Corrected 2026-10-09: this diagram and the sentence under it said the service calls release once
it has mapped and cleared. The kernel calls it, from the spawn path (task/mod.rs), when it grants
the framebuffer - §9.8 item 3 records why the grant became the handover - and takes the screen back
on the service's death as well as on a panic.) The panic path
reclaims unconditionally and clears, because by then the service is halted and its grid describes nothing.
9.5 Memory attributes - the constraint that shapes the mapping
The service maps the same physical pages the kernel mapped. ARM leaves mismatched memory attributes for one physical page UNPREDICTABLE, so both mappings must agree. They agree on Normal non-cacheable:
- The service cannot do cache maintenance -
unsafeis forbidden in services (§18.2) andDCCMVACis PL1-only on ARMv7 anyway. So a cacheable mapping is not available to it at any price. - Therefore the kernel's mapping becomes non-cacheable too (
section_fb), andfb_commit(a clean-to-PoC on ARM, ansfenceon x86) collapses on ARM to a store barrier - aDSB, ordering only, nothing to clean - while KEEPING its name and its rectangle argument, because an arch that maps its framebuffer cacheable still needs both. (No arch does today. That parenthetical used to name the Pi 4; see 9.5.1 - it now maps Normal non-cacheable like the others.)FB_READBACK_CHEAPdisappears with it: reading back a non-cacheable framebuffer is never cheap, so the terminal always repaints from its shadow grid andscroll_by_copyis deleted.
Non-cacheable here means Normal non-cacheable, not Device. The distinction matters and the
neutral PageFlags::PCD currently encodes the wrong one on ARM: Device semantics (no gathering, no
reordering, no speculation) exist to protect stores with side effects, and a framebuffer store has none
- it is memory the display happens to scan. Non-gathering would make every 32-bit pixel store its own
bus transaction, roughly 1.4M of them per full-screen repaint on a Pi 2. So the ARM page-table encoder
gains a Normal-non-cacheable case and
mmu::section_fbmatches it;PCDkeeps meaning Device for the driver MMIO grants that genuinely need it.
Cost: the kernel's boot text is slower to paint. It is boot text, and the terminal's own scroll was already repaint-from-shadow on x86 for the same reason.
9.5.1 AArch64 had the SAME mistake as ARM, and it cost 582 ms a repaint (2026-08-29)
The paragraph above predicted this exactly - "non-gathering would make every 32-bit pixel store its own
bus transaction" - and then it happened on the OTHER ARM port. AArch64's map collapsed PCD || PWT to
Device, so the Pi 4's framebuffer grant was Device-nGnRnE. One 1920x1080 repaint measured 582 ms
(~14 MB/s), slow enough that selfcheck never reached its end on that board - which for days looked
like a filesystem or scheduling fault rather than a display one.
Fixed by letting the neutral layer say what the region IS, instead of inferring it from cache bits:
PageFlags::WRITE_COMBINE means "a framebuffer, not device registers", and each arch picks its own type
- AArch64 Normal non-cacheable via MAIR slot 2, arm32 the encoding it already had, and an arch with
nothing better ignores the bit (so x86 is unchanged). After: no
paint tookwarning on any machine, andselfcheckcompletes on the Pi 4.
A second bug hid behind the same proxy. The kill-path reclaim skips device MMIO so it is never freed
into the RAM pool, and it identified "device MMIO" as PCD && PWT. The x86 framebuffer is mapped
PCD-only deliberately (strong-UC cost 596 ms per scroll on the Wyse; UC- lets the firmware's
write-combining MTRR apply), so that guard silently stopped covering it - and every console death
freed the framebuffer into the allocator. Measured after chaos max-carnage: 315392 double-frees, all
inside 0x90000000..0x91f97000 (exactly 3840x2160x4), and free_frames 6666 above total_frames. Worse
than the wrong number: the display's pages became allocatable.
The guard is now PCD alone, AND the framebuffer range is reserved in the allocator
(memory::allocator::reserve_no_free) - because a check that reads a proxy can be disarmed by someone
optimising something else, who has no reason to know a safety check depends on how they map a page. The
resource itself is where the refusal belongs.
The same mistake existed on x86, in the other direction (2026-08-25)
The paragraphs above reason carefully about the ARM encoding and say nothing about x86, where the
framebuffer grant was mapped PCD | PWT. On x86 that is PAT entry 3 - strong uncacheable, the one
memory type an MTRR can never upgrade - so every 4-byte pixel store went to the bus alone. Measured on
a Wyse 5070 at 3840x2160: 596 ms to repaint one scroll, about 19 MB at roughly 32 MB/s. The display
was unusable and it read as "the console is slow" rather than as a mapping bug.
PCD alone selects entry 2, UC-, which yields write-combining wherever the range's MTRR says WC -
and firmware routinely marks a framebuffer WC for exactly this reason. One flag: 596 ms -> 29-41 ms,
about 650 MB/s. Strictly no worse where the MTRR says UC, because the effective type is then unchanged.
The two ports need OPPOSITE bits for the same intent, which is why this is worth stating in one place:
| wants gathering | encoding | |
|---|---|---|
| x86 | PCD alone (UC-, upgradable to WC by MTRR) | PCD | PWT is strong UC and is NOT |
| arm32 | PCD | PWT (Normal non-cacheable) | PCD alone is Device and forbids gathering |
So the x86 fix is #[cfg(target_arch = "x86_64")]-gated. Applying it everywhere would have slowed the
Pi for precisely the reason it sped up the PC, and worse, mismatched mmu::section_fb's view of the
same physical pages - which on ARM is unpredictable rather than merely slow. Borrow the silicon's
requirement, not the other port's answer (§26.14).
9.6 Surfaces added and removed
| Added | a framebuffer grant in the spawn path; one service_config row (since step C, a row in the supervisor's spawn table, services/supervisor/src/main.rs; the grant is by the FRAMEBUFFER device kind, CLAUDE.md 12.3). No new syscall - see the correction below |
| Removed | InspectKernel query 9 (dims_packed) - the shell asked the KERNEL for terminal geometry, which is a service's question; FB_READBACK_CHEAP from the arch contract, halving the framebuffer surface an arch owes to fb_commit alone; ~840 lines of ring-0 code |
The introspection pin shrinks by one, and the syscall pin does NOT grow. That is better than the trade the audit predicted: terminal geometry left the kernel and nothing was added to pay for it.
Corrected 2026-09-26. This table said the split ADDED a
ConsoleDrainsyscall and aCONSOLE_RENDERauthority gating it, and the sentence above said "the syscall pin grows by one". Neither exists - not inkernel/, not inservices/, not insdk/, andSyscallNumberhas no such entry. What shipped is what §9.2 of this document describes:ConsoleWriteenqueues the same bytes to the service's ordinary IPC endpoint, with no new ring and no new syscall. The wrong half mattered more than it looks: someone auditing the kernel's syscall surface against Commandment I would have gone looking for an addition that was never made.
9.7 Geometry has ONE owner
ctx.console_dims() no longer reads query 9. The shell asks the console service, which is where the
safe-area inset, the cell size and the font-scale rule live. The kernel's bootcon computes rows/cols for
its own blit and never publishes them - a private working value is not a second source of truth, but a
published one would be (Commandment III). A shell that cannot reach the console service reports the
console unavailable rather than guessing a size.
9.8 What the hardware found that review did not
Five defects, none of which a checker or a build could have caught, listed because each is a different KIND of miss:
- A placeholder ELF.
arm_build.pykept its own copy of the ARM service list, so the console shipped as a stub and failed to spawn. The second list is now DERIVED fromkernel/build.rs, not reconciled against it - the third time that shape has bitten this repo. - The wrong green.
grant()reconstructed the framebuffer's channel shifts fromblend_lut[255]rather than storing them, and the foreground(0x80, 0xFF, 0x80)has red equal to blue - so blue always resolved to red's shift. Ambiguous by construction, for any palette with a repeated component. §26.13: discipline over cleverness. - A handover window. Ownership moved on the first byte DELIVERED, which on a quiet boot is seconds after the service clears the screen. Both parties drew in between. The GRANT is the handover.
- Drops that no amount of speed could fix. A 16-deep queue cannot absorb a thirty-write burst
however fast the renderer is, so
observe nowlost its tail every time. The writer now blocks (§8.5/§8.6 back-pressure); the kernel still never waits. reclaim_for_panichad zero callers. The amendment in §9.3 argues this module earns ring-0 residency because a panic cannot ask a service to report it - and the panic handler did not call it, so the first real panic showed a black screen and said nothing. A guarantee asserted in the constitution and absent from the code is worse than an unimplemented feature: the document says it is covered, so nobody looks. There is no test for it (§22 explains why - it is a negative property on a machine that cannot be failed on demand in QEMU), which is exactly why it needed reading rather than assuming.
10. Scrollback (2026-09-18)
The console retains lines that scroll off the top, and PgUp/PgDn walk them.
10.1 Why it belongs to the service and not the kernel
§11.4's boot/panic floor deliberately keeps no history: reaching the bottom of the
screen clears it and starts again, because a panic must not depend on a data structure
that could itself be corrupt, and serial holds the full transcript either way. This is the
console service, which owns the terminal model (§9.2), and history is exactly that.
The kernel is not touched by this work.
10.2 The ring
32 KiB of bytes and 512 line slots, evicting oldest-first on whichever bound binds first. Both bounds are real: a screen of blank lines must not be able to evict a screen of real ones, so there is a ceiling on count as well as on bytes. Measured on the first boot, the ring filled at 512 lines / 26,008 bytes - about 51 bytes a line, so the two ceilings are well matched rather than one being decorative.
The bound is the STACK, not the memory limit, and that is worth knowing before anyone
raises it. The service may use 8 MiB, but Term is a local of service_main and the user
stack is 256 KiB. The first cut (64 KiB + 1024 lines) put Term::new at 180 KiB and
service_main at 118 KiB - 299 KiB together, which faults on the first store of its own
prologue. See §10.7, item 3.
Addressing is monotonic: wpos counts every byte ever written and never wraps, and the
storage index is wpos % SB_BYTES. That turns "has this line been overwritten?" into one
comparison rather than an interval-overlap test against a wrapped region, which is where a
ring like this usually goes wrong. A line may straddle the end of the buffer; readers copy
it in two pieces.
Trailing blanks are stripped on the way in. The shadow grid is space-filled to its full width, so storing a line verbatim would spend 208 bytes on it regardless of content and cut what fits by an order of magnitude.
Attributes are not retained, stated here rather than left to be discovered. The shadow grid carries one reverse-video bit per cell; scrollback keeps characters only, halving the memory. A reverse-video line scrolled back to shows as plain text. Reverse video lives in full-screen apps, which own the screen and whose output is not scrollback material.
10.3 One capture point
scroll(), where row 0 is about to be overwritten by row 1. That is the exact and only
moment a line leaves the screen - every other path either stays on screen or clears it
deliberately. Retaining it there means scrollback cannot miss output, whatever produced it,
and no producer needs to know scrollback exists.
10.4 Scrolling was a REQUEST, and the request is GONE
This service used to answer REQ_SCROLL: move your view, and report where it now is. The SDK's
console_scroll(action) was how a holder of the keyboard asked, the shell drove it from a mode of
its own, and output arriving snapped the view back to live.
All of it is deleted, and the reason is backlog/fixed/37.
To answer "move your view" this service had to paint_view and present BEFORE it could compute
the reply - a full repaint of the framebuffer, synchronously, inside the caller's request. On a
3840x2160 panel that is the most expensive thing it does. The caller allowed one second and ran on
the same core. A scroll therefore "took over two seconds to answer", and four rounds of investigation
looked for a lost reply that never existed: nothing was stuck, the work did not fit the deadline.
Holding a scrolled view also cost this service state it could otherwise do without - a view offset, an in-view flag, and a branch in the output path to snap back out of it. State that only one feature needs, and that feature was the one that did not work.
10.5 What replaced it: REQ_HISTORY, and the work moved
The ring stays; the VIEW went. REQ_HISTORY hands the caller bytes out of the scrollback - a bounded
memcpy, no painting - and the caller paints its own screen with ordinary output, which is a send and
carries no deadline at all. The scrollback utility (utilities/54_scrollback.md) reads the whole
history ONCE when it opens, so a keypress costs this service nothing whatsoever.
The lesson is about which side does the work, not about tuning a number. Widening the deadline would have hidden the same coupling: this service was both the thing being read from and the thing being drawn to, one endpoint and one 16-deep queue, so a request made mid-frame could always end up behind painting the caller itself had just asked for. Moving the work removes that by construction; a larger timeout only makes it rarer.
Untrusted content is still the reason it was a request and not an escape sequence. A file being
read can contain any bytes, so a scroll expressed as a CSI sequence would let a file scroll the
terminal displaying it - the argument dir already makes about filenames, one layer out. That
argument is intact and now applies to REQ_HISTORY, which carries a reply cap where output does not.
10.7 What adding it uncovered
Three defects, each older and worse than the feature, all of which had survived because nothing measured them:
osdev buildhad been shipping a staleconsole. Its crate list omittedconsole,control,timeandhw-enumerator, so it relinked the supervisor around whatever copies were lying in the target directory. Sixteen copies of that list; there is one now. Same shapeservices/supervisor/build.rsalready records for its USB list: a missing binary trips a guard and a stale one does not, because a stale file is not missing.scripts/stack_fit_check.pywas blind on x86, and was called only from two hand-written board scripts - so the gate existed on two boards and nowhere else, including every QEMU suite. It now knows the x86 prologue and its probe loop (teaching it only the first was an 18x undercount, precisely on the large frames it exists to catch), has a command line, and runs fromosdev build.Term::newmaterialised the terminal twice - 180 KiB, from a 118 KiBservice_main. Its own doc comment warned about exactly this while the code did it; it was survivable only becauseFbwas small.blank()is aconst fnreturning a literal now, built directly into the caller's local, withattach(fb)doing the init through&mut self- which is what the comment said all along.
10.8 What it does NOT do yet
help's pager is GONE, and trace's is permanent.
The condition this was held against is met: scrollback was verified on a Dell Wyse 5070 on
2026-09-18 - PgUp entered history, End returned, typing returned on its own, and the serial log
carries all three transitions. help prints its table and returns; a long one is recovered with
PgUp, or asked for with help | paginate.
trace's pager stays for a reason that has nothing to do with scrollback: it PINS A COLUMN
HEADER while you scroll. Scrolled back through a grid in a scrollback buffer the column names are
off the top and you are reading unlabelled columns, and no scrollback can fix that. That is the
whole difference between the two pagers.
Verified in QEMU (osdev test shell): PgUp enters history, output returns the view to live by
itself, End returns to live while scrolled, Home is taken as a scroll while scrolled and
still edits the line when it is not.
(Note 2026-10-09: the two verifications above, on the Wyse and in QEMU, were of the scrolled VIEW
that §10.4 records as deleted. PgUp now opens the scrollback utility one page back
(utilities/54_scrollback.md), which reads the ring through REQ_HISTORY; there is no scrolled
state at the prompt for output or End to return from.)