SchOS/docs/architecture/terminals.md
Nexoniarz 4e3e345b41 kbd: three layouts, and the arrow keys were sitting on top of UTF-8
`∞`, `→`, `≤` and `'` could not be typed. `ą` and `ę` could. That pairing is
the whole diagnosis: KBD_UP was 0xE2 and its neighbours filled 0xE0..0xE9,
which are exactly the UTF-8 lead bytes for three-byte characters. Every
character in U+0800..U+FFFF -- most punctuation, every arrow, every maths
symbol, the whole of CJK -- arrived as a value the console read as an arrow key
and helpfully turned into `ESC [ A`. Two-byte sequences lead with 0xC4 and
nothing claimed 0xC4, so Polish came through and the rest did not.

The chord codes already knew this rule and said so in a comment. The navigation
keys predate them and were never brought in line. Stated once, in kbd.h: a key
produces either a Unicode codepoint or a value above 0x10FFFF, and nothing in
between means anything. KEY_* -> KBD_*, which also settles a silent collision
-- include/input.h defines its own KEY_HOME, KEY_UP and KEY_END as evdev
numbers, and kbd.c includes both headers, so three macros were being quietly
redefined in the one file that used them.

Then the layouts, which is what the codepoint-sized keycodes were for.

kernel/dev/keymap.c: us, pl, de. A layout is four tables -- plain, Shift,
AltGr, Shift+AltGr -- of uint32 codepoints. uint32 and not uchar is the reason
the file exists: `ó` fits in a byte and `ą` does not, and half a Polish
alphabet is not a Polish layout.

Two things deliberately are *not* tables:

  - **Ctrl is derived from the plain codepoint.** The table it replaces was
    indexed by scancode, which quietly assumed US -- on QWERTZ the Y and Z keys
    swap, so Ctrl+Z would have suspended a job only on some layouts.
  - **Caps Lock is a case toggle on the result**, so AltGr+a with Caps Lock is
    `Ą`. No second table could say that, because Caps Lock combines with AltGr
    rather than replacing it. Latin Extended-A alternates upper/lower by
    codepoint but not all the same way round -- Ł is U+0141 (odd) and Ą is
    U+0104 (even) -- so Polish lands in both halves and the parity matters.

kbdgetc() encodes to UTF-8 and returns the bytes one call at a time.
consoleintr()'s contract is bytes: the serial line hands it UTF-8 already and
it could not tell an encoded codepoint from a byte of the same value.

The layouts live in the kernel. Linux's loadkeys compiles a text keymap and
uploads it a key at a time through KDSKBENT, which puts the data in userspace
-- and a machine whose /usr has not mounted then has no layout at all. Here the
kernel always has a working keyboard and `loadkeys` only names which one.
KB_GETLAYOUT/KB_SETLAYOUT/KB_LISTLAYOUT; /etc/vconsole.conf and an
os.schos.keymap job for the boot-time choice. No dead keys, so French and
Spanish are absent rather than wrong.

/etc/locale.conf and LANG=C.UTF-8 in profile and zshenv. Only the charset half
of that is real -- musl decodes UTF-8 whatever the locale says and ships no
locale data -- but an unset LANG reads as "ASCII only" to everything that asks,
which is its own way of producing `?`.

And two symlink bugs found while chasing

    zsh: locking failed for /root/.zsh_history: operation not permitted

on every boot. symlink() is the classic atomic lock -- create the link, and if
it exists someone else holds it -- and zsh distinguishes EEXIST ("contended,
retry") from anything else ("this cannot work, give up"). sys_symlink returned
a bare -1, which is -EPERM, so every zsh on the machine took the second branch
on a filesystem that supports symlinks perfectly well. Same errno class as
commit 75ccf98.

The second one the first was hiding: **divesymlink() has been in fs.c since the
fork and nothing ever called it.** A symlink behaved as a small file containing
its own target, so `cat link` printed the target's *name*. open() and openat()
follow the final component now (O_NOFOLLOW gives ELOOP), and stat() and lstat()
stop being the same function, since which one follows is the only difference
between them. Mid-path symlinks are still not resolved -- that belongs in
namex(), which would re-enter lookup holding a directory lock.

Verified on the emulated keyboard, not just over serial: AltGr+a/e/s gives
ąęś, Shift+AltGr gives ĄĘŚ, Caps Lock+AltGr gives ĄĘŚ, de types zy for the
physical y/z keys and @€µ on AltGr. `∞ 'x' → ≤ • — …` typed over serial now
survives. Three fresh boots: 0 lock failures, 0 faults.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 01:09:32 +02:00

398 lines
19 KiB
Markdown

<!-- SPDX-License-Identifier: Apache-2.0 -->
# Virtual terminals
Six of them, `/dev/tty1` through `/dev/tty6`.
| | |
|---|---|
| **AltGr+F1 .. F6** | go to that terminal |
| **AltGr+-** / **AltGr+=** | previous / next terminal *in use* |
| `chvt N` | the same, from a script or a serial console |
The chord is settable — `chvt -c` — for the reasons below. Each one is an independent terminal: its own screen, its own
cursor, its own line discipline, its own foreground process group and its own
session. Two shells on two terminals are two unrelated processes that happen to
share a keyboard and a screen.
A seventh is deliberately not bound. That is where a display server goes,
and it will claim switching with `VT_SETMODE` rather than a chord.
---
## What makes switching possible
A backing store, and nothing else.
Before this, the console drew straight into the framebuffer and scrolled by
moving pixels. That works exactly as long as nothing else ever wants the
screen: there was nowhere to put terminal 2 while terminal 1 was visible, and
nothing to repaint terminal 1 from when it came back.
So each terminal owns a grid of cells and the framebuffer shows whichever grid
is active. `vt_switch()` is three lines — set the active index, repaint from
the grid, return — because nothing has to be *saved* on the way out. The
outgoing terminal's grid has been current all along.
The rule the whole layer is built on:
> **A terminal does not know whether it is visible.**
Writes land in the grid, the cursor moves, the grid scrolls, all of it
unconditionally. Drawing is a separate question, asked once, at the bottom of
`kernel/dev/vt.c`, answered by comparing against `vt_active`. Every alternative
spreads "am I on screen" through the escape parser, and then a terminal
switched away from mid-sequence comes back wrong.
## A cell
Eight bytes:
| | |
|---|---|
| `ch` | the Unicode codepoint, 32 bits |
| `fg`, `bg` | palette indices, 0-15 ANSI plus two theme defaults |
| `attr` | reverse video; room for underline and the rest |
| `pad` | to eight, so the grid indexes without a multiply |
`ch` was one byte holding a CP437 glyph, not a codepoint: the console mapped
UTF-8 down to CP437 on the way in, because that is what `include/font.h`
contained. Widening it to the codepoint is what made Ą, Ę, Ś, Ω and the rest
possible, and no font change could have done it alone — a byte cannot *hold*
U+0104 whatever is drawing it. Mapping on the way in also meant the decision
was made once, at write time, and a later font could not revisit it.
The cost is real: the grid went from four bytes per cell to eight, and six
terminals at 240x75 is 864 KB of BSS rather than 432 KB. Paid once at link
time, which is the only way to pay it — there is no contiguous multi-page
allocator to ask.
The colours stay indices rather than RGB, for the same reason they always
were: a cell holding two 32-bit colours would be sixteen bytes.
## What is per-terminal
Everything that used to be a single global:
| Was | Now |
|---|---|
| `int tty_fg_pgrp = 1;` | `v->fg_pgrp`, one per terminal |
| one `struct termios tio` | `v->tio` |
| one 256-byte input ring | `v->in` |
| one escape-parser state | `v->esc_*` |
| one UTF-8 decoder | `v->u8_*` |
| one cursor, in pixels, in `gfx.c` | `v->cx`, `v->cy`, in cells |
`TIOCSCTTY` used to be `return 0` — it claimed to make a terminal the caller's
controlling terminal and did nothing, because there was one terminal and
nothing to choose between. It records `proc->ctty` now, which is what
`/dev/tty` resolves through.
## The devices
| Path | Minor | What |
|---|---|---|
| `/dev/console` | 1 | the **active** terminal, so kernel messages follow a switch |
| `/dev/tty` | 10 | the caller's **controlling** terminal, via `proc->ctty` |
| `/dev/tty1`..`tty6` | 11-16 | that terminal, whether or not it is on screen |
`/dev/tty` was an alias for `/dev/console` with the same major and minor. They
are different questions and now have different answers.
## The ioctls
Linux's numbers, because they do Linux's job.
| | |
|---|---|
| `VT_ACTIVATE` | switch. Argument is a 1-based number **by value**, not a pointer |
| `VT_WAITACTIVE` | returns immediately: `vt_switch` repaints synchronously, so there is nothing left to wait for |
| `VT_GETSTATE` | active terminal and a bitmask of those in use |
| `VT_OPENQRY` | the first terminal nobody has open |
| `VT_GETMODE`/`VT_SETMODE` | accepted and ignored — the kernel drives switching and there is no process mode to set |
| `VT_GETCHORD`/`VT_SETCHORD` | SchOS's own: which key combination switches. See below |
| `KB_GETLAYOUT`/`KB_SETLAYOUT`/`KB_LISTLAYOUT` | SchOS's own: the keyboard layout, by name |
## The keyboard
`kbd.c` turns scancodes into characters and chords. `keymap.c` holds the
layouts. Between them is one rule, and it is the rule the whole keycode space
is built around:
> **A key produces either a Unicode codepoint or a value above `0x10FFFF`.
> Nothing in between means anything.**
The chord codes always obeyed it. The navigation keys did not: `KEY_UP` was
`0xE2`, and `0xE0..0xE9` are exactly the UTF-8 lead bytes for three-byte
characters. Typing any of U+0800..U+FFFF — most punctuation, every arrow, every
maths symbol, the whole of CJK — produced a value the console read as an arrow
key and turned into `ESC [ A`. `∞`, `→`, `≤` and `'` vanished from the input
line while `ą` and `ę` came through, because those are two-byte sequences whose
lead byte is `0xC4` and nothing claimed `0xC4`. They are `KBD_*` now and sit
above every codepoint there is.
What the chord *means* is decided in `console.c`, where the terminals are. The
keyboard driver names it and stops.
### Layouts
Three, in the kernel: `us`, `pl` (Polish programmers), `de` (QWERTZ). `loadkeys`
lists and switches them; `/etc/vconsole.conf` records the choice and the
`os.schos.keymap` job applies it at boot.
**In the kernel, not in files.** Linux's `loadkeys` compiles a text keymap and
uploads it a key at a time through `KDSKBENT`, which puts the data in userspace
— and a machine whose `/usr` has not mounted then has no layout at all. Here
the kernel always has a working keyboard and userspace only names which one.
A layout is four tables of codepoints — plain, Shift, AltGr, Shift+AltGr —
indexed by scancode. Two things are deliberately *not* tables:
- **Ctrl is derived from the plain codepoint**, not looked up. The table it
replaced was indexed by scancode, which quietly assumed US: on QWERTZ the Z
and Y keys swap, so Ctrl+Z would have suspended a job only on some layouts.
- **Caps Lock is a case toggle on the result**, so `ą` from the AltGr level
becomes `Ą` — which no second table could have said, because Caps Lock
combines with AltGr rather than replacing it.
Codepoints and not bytes is the entire point: `ó` fits in Latin-1 and `ą` does
not, and half a Polish alphabet is not a Polish layout. `kbdgetc()` encodes to
UTF-8 and hands the bytes to `consoleintr()` one call at a time, because that
function's contract is bytes — the serial line delivers UTF-8 already and
nothing could tell an encoded codepoint from a byte of the same value.
**No dead keys.** On a real German layout the acute and circumflex keys wait
for the next key and combine; here they type the accent alone. It needs
per-keyboard pending state that this interface has no room for, so layouts that
depend on it — French AZERTY, Spanish — are not shipped rather than shipped
wrong.
### Locale
`/etc/locale.conf`, read by `/etc/profile` and `/etc/zsh/zshenv`, default
`LANG=C.UTF-8`.
**Only the charset half of it is real.** musl decodes and encodes UTF-8
whatever the locale says and ships no locale data, so collation stays codepoint
order and messages stay English whichever language is named. What `LANG` does
is tell the programs that ask that the terminal is UTF-8 — an unset `LANG`
reads as "ASCII only", and that is what makes a program fall back to `?`.
### The chord is a setting, and that is the point
**There is no combination that is free for everyone on every host**, and a
guest cannot find out what its host takes: a key the host swallowed is
indistinguishable from a key nobody pressed. Three parties have a claim on
every candidate.
| | |
|---|---|
| **The host takes it, and the guest never sees it** | |
| `Alt+F4`, `Alt+F2` | window manager |
| `Ctrl+Alt+F1..F6` | display manager — and it matches **loosely**, so adding Shift does not get past it |
| `Ctrl+Alt+`digit | QEMU's own console tabs |
| **Applications want it, and lose it permanently if the kernel eats it** | |
| `Alt+`anything | Meta. zsh binds Meta-digit as `digit-argument`; so does every readline editor |
| `Ctrl+`letter | control characters |
| `Ctrl+Shift+`digit | types a character — `Ctrl+Shift+2` is `^@` |
| `F1..F6` alone | help, and whatever a full-screen program wants |
| **The keyboard layout wants it** | |
| `AltGr+`letter | Polish diacritics — ą ć ę ł ń ó ś ź ż — and on some setups AltGr arrives as Ctrl+Alt |
So the kernel ships a default and lets it be moved:
```sh
chvt # report the active terminal and the current chord
chvt -c altgr+F # AltGr+F1..F6 (the default)
chvt -c ctrl+shift+F # Ctrl+Shift+F1..F6
chvt -c alt+1 # Alt+1..6
chvt -c off # no chord; chvt N is then the only way
```
`off` is a legitimate setting, not a failure mode. On a machine whose host
grabs everything worth binding, `chvt N` from a shell is the honest answer.
The default is **AltGr+F1..F6**, and the `AltGr+`letter row above needs reading
carefully: what a Polish layout wants is AltGr with a **letter**. AltGr with a
**function key** produces nothing on any layout, no window manager binds it,
and no application asks for it. It is the one combination all three parties
leave alone.
That distinction is only expressible because Right Alt is tracked as its own
modifier bit. It used to share `ALT`, which is harmless for typing — neither is
used to look up a character — and made this impossible to say.
Verified rather than argued: with the default set, `AltGr+F2` switches,
`AltGr+F1` returns to a pixel-identical tty1, and `AltGr+a` still reaches the
shell, so ą is untouched.
Whatever the modifiers are, they also bind **minus** and **equals** to the
previous and next terminal. Those step only through terminals that are *in
use* — the same `opened` count `VT_GETSTATE` reports — because six terminals
with two shells on them means four blank screens in between, and cycling past
them is worse than not having the key.
Which chords reach a guest at all was **measured, not assumed**: a temporary
probe in `kbdgetc` logged every modified keypress, and QEMU's `sendkey` fired
each candidate at it. Everything arrived, `Ctrl+Alt+F2` included — so that
conflict is the host's display manager, not QEMU, and no amount of extra
modifiers gets around a grab that matches loosely.
The modifier test is an **equality**, not a mask:
```c
if (vt_chord_mods != 0 &&
(shift & (CTL | SHIFT | ALT | RALT)) == vt_chord_mods && ...)
```
A mask fires on any superset, so a chord aimed at the host would switch
terminals behind your back. Right Alt is tracked as its own bit (`RALT`) for
the same reason; it used to share `ALT`, which is harmless for typing — neither
is used to look up a character — and wrong for chords.
`include/posix.h`'s `VTC_*` bits mirror `include/kbd.h`'s modifier bits, and a
`_Static_assert` in `kbd.c` holds them together. A mismatch would be silent:
a chord set from userspace would simply never match, and the only symptom
would be a key that does nothing.
### Taking the chord back
Three layers, in increasing order of how much a program is claiming:
| | |
|---|---|
| **`VT_SETMODE(VT_PROCESS)`** | the terminal's owner handles switching itself. The kernel stops acting on the chord entirely. This is what a display server uses. |
| **`EVIOCGRAB` on `/dev/input/event0`** | an exclusive keyboard grab. Checked in `kbdgetc` *before* the chord is even recognised, so a grabbing program receives every scancode including this one. |
| nothing | the kernel switches, which is the default and right for a plain terminal |
`VT_SETMODE` used to be accepted and ignored — a program could ask for the
chord, be told yes, and still not get it. It works now.
What `VT_PROCESS` does **not** do yet is the release handshake: Linux signals
the owning process on every switch request and blocks until it answers
`VT_RELDISP`, which is how a display server releases the GPU before the screen
changes hands. Nothing here has anything to release, so the kernel simply
stops switching rather than negotiating. `struct vt_mode` already carries
`relsig`/`acqsig` for when Schcom needs it.
Input goes to whichever terminal is on screen. That is what makes a terminal a
terminal: the keyboard follows the eyes.
## Two rules that are easy to get wrong
**The grid is the only source of truth for the size.** `TIOCGWINSZ` reports
`v->cols`/`v->rows`, not a fresh `gfx_cols()`/`gfx_rows()`. When it computed
its own answer there were two, and they disagreed: the grids are sized at
`vt_init()`, which runs before the display driver is found on the PCI bus, so
they were 80x24 while `TIOCGWINSZ` said 160x50. Text stopped two thirds of the
way down the screen, `ls` formatted for a width the grid did not have and split
words across the wrap, and zsh's cursor tracking was wrong on every line --
three symptoms, one disagreement. `vt_resize()` now corrects the grids when a
scanout arrives, and the ioctl reads them.
**A glyph in the last column does not wrap.** It sets `wrap_pending`, and the
wrap happens when the *next* glyph arrives. Every real terminal behaves this
way and none of them document it clearly. Wrapping immediately makes a line
exactly as wide as the screen take two rows instead of one, and makes it
impossible to write the bottom-right cell without scrolling -- and zsh's
partial-line marker depends on the rule directly: it writes exactly
`COLUMNS - 1` spaces and expects the cursor to still be on that line. With
immediate wrap, a reverse-video `#` appeared above every prompt.
Anything that moves the cursor deliberately -- CR, LF, backspace, an escape
sequence -- clears the debt.
## Ctrl-C used to kill the shell
Worth its own note, because the cause was one character and the effect was a
machine you could not use.
When Ctrl-C arrives, `consoleread()` discards the partial line and breaks off
the read in progress. It returned **`-1`** to do so — and `-1` is `-EPERM`. So a
program that handles `SIGINT` by carrying on saw a permanent error instead of
an interrupted call, and exited:
```
zsh: error on TTY read: operation not permitted
schinitd: restarting os.schos.console-shell
```
and then the next Ctrl-C did the same thing to the replacement. A shell that
cannot survive Ctrl-C is a shell nobody can use, and the supervisor faithfully
restarting it made it a loop.
It returns **`EINTR`** now, which is what a signal-interrupted read is required
to give: the whole convention — retry, or return to the caller — is built on
receiving that and not something else.
## Editing a line you have already typed
Two bugs, both in the same half-second of using a shell, and both worth
recording because the symptom pointed away from the cause.
**Arrow keys corrupted the next two keystrokes.** They arrive from the keyboard
as single keycodes and were pushed into the input buffer exactly like that. No
terminal has ever worked that way. `consoleintr()` translates them to the
DEC/xterm sequences now -- `ESC[A`, `ESC[D`, `ESC[5~` and the rest -- which is
what every terminfo entry and every line editor already expects.
The *value* those keycodes had was a second bug wearing the same coat, and it
outlived the fix: `KEY_UP` was `0xE2`, a UTF-8 lead byte, so a character typed
from the other direction was eaten just as thoroughly. See the keycode rule
above.
**Then the end of the line vanished as you typed into it.** With the arrows
working, editing mid-line left the buffer perfectly correct and the screen
wrong, which is a maddening pair. The cause is one line in `vt_putc()`:
backspace *erased the cell it moved onto*.
That is what a line-discipline erase does. It is not what the BS control
character does — a program that wants to rub a character out sends `\b \b`,
backspace, space, backspace, and the separation matters because programs also
use plain `\b` simply to go back. zsh does, on every mid-line edit:
```
echo alpha \b\b zha \b\b
```
Move back two, rewrite the tail, move back two again to leave the cursor where
the user expects it. Against a destructive backspace that last pair deleted the
`ha` it had just drawn.
`\b` moves and nothing else now; `0x7f` is ignored, as it is on a real
terminal; and `consputc()` spells its own erase out as `\b \b`, exactly as it
already did for the serial line. `ESC[@`, `ESC[P` and `ESC[X` — insert, delete
and erase characters — were added at the same time, because `TERM=linux`
declares them and a terminal that drops them silently is the same class of bug
waiting for a different editor.
## What it does not do
- **No scrollback.** The grid is exactly the visible screen. Shift+PageUp does
nothing because there is nothing above the top row to show.
- **No reflow on resize.** The geometry is read once at `vt_init()`. Nothing
resizes a framebuffer console today; a compositor will, and that is when this
has to grow.
- **No `VT_RELDISP` handshake.** `VT_PROCESS` stops the kernel switching, but
the kernel does not signal the owning process and wait for an answer. A
display server that needs to release the GPU *before* the screen changes
hands will need that, and `struct vt_mode` already has the fields.
- **No terminal past 6.** Linux allows 63. Six is what the chords reach and
what the grids are sized for.
- **Only 240x75 of any screen.** Larger framebuffers work; the grid is clamped
and the remainder stays background. Raising `VT_MAXCOLS`/`VT_MAXROWS` is the
fix, and costs BSS.
- **No UTF-8 the font lacks.** The console draws through the TrueType
rasteriser now, so the limit is the font's `cmap` rather than a 256-entry
table — Latin, Latin Extended, Greek, Cyrillic and the common punctuation and
maths symbols all render. Anything genuinely absent still draws as `?`, which
is deliberate: drawing a *different* character would be worse.
- **No shaping, and no wide characters.** One codepoint is one cell of one
advance. Combining marks land on top of their own cell rather than the
preceding one, and CJK draws at half the width it wants. Both need a
width table and a cell that can span two columns.
- **No dead keys**, so French and Spanish layouts are absent rather than
broken. See above.
- **No per-terminal layout.** One keyboard, one layout. Switching to tty3 does
not move anybody's fingers.
- **Only three layouts.** Adding one is a table in `keymap.c` and a line in the
catalogue; the mechanism is not the limit, the typing is.