SchOS/docs/architecture/terminals.md
Nexoniarz 2556a955b3 docs: architecture describes what is built now
kernel (threads, demand paging, TLB shootdowns, dynamic linking, RAM direct map), syscalls, filesystem (/proc, /sys,
/dev), terminals (12, VT handshake, ptys), networking (r8169 through LDLS), LDLS (module sets; Linux code may ship),
devices (what stays in the kernel; hybrid.md's lasting content), boot (the loader's placement of the kernel),
graphics (fbdev, GMMOS, QR 1-40), audio (ALSA over OSS), debugging, usb. New: packages.md (the apk root) and
desktop.md (Xorg on the framebuffer).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVRhxi9zpdW2uDUN4FVcao
2026-10-26 07:57:25 +01:00

20 KiB

Virtual terminals

Twelve of them, /dev/tty1 through /dev/tty12, one per function key.

Ctrl+Alt+F1 .. F12 go to that terminal — on the console and under X alike
Alt+F1 .. F12 the same, on the text console only (X keeps plain Alt)
Ctrl+Alt+- / Ctrl+Alt+= 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 display server takes one of them with VT_SETMODE and does its own switching; X switches with Ctrl+Alt+F-key, which is why that is the default here too.


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/gfx/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/tty0 the active terminal, which is what Xorg and openvt ask for
/dev/tty1..tty12 11-22 that terminal, whether or not it is on screen
/dev/ptmx, /dev/pts/N pseudo-terminals, below

/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..F12 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..F12 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:

chvt                        # report the active terminal and the current chord
chvt -c ctrl+alt+F          # Ctrl+Alt+F1..F12    (the default)
chvt -c altgr+F             # AltGr+F1..F12       (the default until 2026-10)
chvt -c ctrl+shift+F        # Ctrl+Shift+F1..F12
chvt -c alt+1               # Alt+1..9
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 Ctrl+Alt+F1..F12. It was AltGr+F-key for a long time — AltGr with a function key produces nothing on any layout, no window manager binds it, and no application asks for it — but X owns the keyboard while it runs and switches only with Ctrl+Alt+F-key, so the console and the desktop answered different keys for the same thing. One chord everywhere wins. Inside a VM whose host grabs Ctrl+Alt+F-key, chvt -c altgr+F brings the old one back.

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.

AltGr+letter still reaches the shell untouched, so ą is unaffected either way.

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 twelve 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:

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/uapi/posix.h's VTC_* bits mirror include/dev/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.

Handing a terminal to a program

A display server takes a terminal with VT_SETMODE(VT_PROCESS) and KDSETMODE(KD_GRAPHICS), and from then on switching away from it is a request: the kernel sends the owner relsig and waits for VT_RELDISP, so the server stops drawing and only then says yes; switching back sends acqsig and the server redraws. A terminal in graphics mode is not repainted by the kernel. If the owner dies, the terminal goes back to the kernel whole. This is how Xorg runs on a terminal of its own, with Ctrl+Alt+F-key switching between it and the text consoles and Ctrl+Alt+Backspace ending the session.

Pseudo-terminals

/dev/ptmx makes a pair on every open and hands back the master; the slave is /dev/pts/N, which the program on the other side gets as its stdin, stdout and controlling terminal. Between them is a line discipline, the console's in miniature: what the master writes is the slave's input (canonical editing, echo, ^C as SIGINT to the foreground group) and what the slave writes is read back by the master with NL turned into CR NL. xterm, tmux, script and sshd all need exactly this. The pairs share one lock; kernel/dev/pty.c.

The text cursor

A steady inverted block — the cell with its colours swapped relative to what it would show, so a cursor on reverse-video text is the plain text and every cursor on screen is visible against its cell. ESC[?25l hides it, ESC[?25h shows it. It is drawn into the grid's repaint and does not blink.

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(); the console's font is sized to the screen at that point, and a resolution change from schmode repaints it, but text is not re-wrapped.
  • No terminal past 12. Linux allows 63. Twelve is one per function key.
  • 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, so the limit is the font's cmap — 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.
  • No per-terminal layout. One keyboard, one layout. Switching to tty3 does not move anybody's fingers.
  • Only three layouts (US, Polish programmers', German). Adding one is a table in keymap.c and a line in the catalogue; the mechanism is not the limit, the typing is.