SchOS/docs/architecture/terminals.md
Nexoniarz 75ccf9841c errno: -1 is EPERM, and four places were saying it by accident
Ctrl-C killed the shell. consoleread() breaks off the read in progress when
SIGINT arrives and returned -1 to do it -- and -1 is -EPERM by the time it
reaches a program, so every program that handles SIGINT by carrying on saw a
permanent error instead of an interrupted call:

    zsh: error on TTY read: operation not permitted
    schinitd: restarting os.schos.console-shell

and the next Ctrl-C did it to the replacement. EINTR now, which is what a
signal-interrupted read is required to give. Verified: Ctrl-C an interruptible
command, shell survives, zero restarts.

Same mistake, same file family: link, unlink, rename and renameat returned -1
on every error path. Callers branch on errno -- `if (unlink(p) < 0 && errno !=
ENOENT)` is the standard shape, and against EPERM it always complains -- which
is why zsh reported failures for things that had worked. They return ENOENT,
EEXIST, EXDEV, EISDIR, ENOTEMPTY and EFAULT now. About ninety other `return -1`s
remain in sys_fs.c; these four are done because these four were what a program
tripped over. /dev/mem's unknown minors return ENODEV rather than -1 too.

/dev/urandom was a linear congruential generator seeded with a **constant**, so
every boot produced the same bytes in the same order -- not weak, the same. A
key, a nonce, a temporary filename: shared with every SchOS machine that had
ever run. Seeded at boot from RDRAND where the CPU has it, from the cycle
counter and the wall clock where it does not, and re-stirred per byte. Still an
LCG and still not a CSPRNG; what changed is the part that was dangerous rather
than merely poor.

**Not fixed, and recorded as such:** rename() still will not replace an existing
target, so zsh's history save fails on every exit after the first. An attempt to
unlink the target first was reverted -- the directory is an HTree, dirunlink
followed by dirlink of the same name did not behave, and it panicked in
filewrite with the old file already gone. Losing both files is worse than
refusing, so it refuses until the HTree's slot reuse is understood.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-14 22:12:28 +02:00

13 KiB

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

Four bytes:

ch the glyph the font draws — not a codepoint
fg, bg palette indices, 0-15 ANSI plus two theme defaults
attr reverse video; room for underline and the rest

ch stores the glyph rather than the codepoint because the console maps UTF-8 to CP437 before it gets here, and that is what include/font.h contains. Storing the codepoint would mean re-running the mapping on every repaint and getting a different answer for anything the font cannot draw.

The colours are indices rather than RGB because a cell holding two 32-bit colours would be twelve bytes instead of four, and the grids are static: six terminals at 240x75 is 432 KB of BSS, paid once at link time rather than failed to pay at run time. There is no contiguous multi-page allocator to ask.

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

The keyboard

kbd.c recognises Ctrl+Alt+Shift+F1..F6 and returns KEY_VT_SWITCH + n, which is above 0xFF on purpose: every value that fits in a byte is already either a character or a UTF-8 continuation byte, and a chord that collided with one would be typed into the terminal instead of switching away from it.

What the chord means is decided in console.c, where the terminals are. The keyboard driver names it and stops.

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:

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:

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.

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. CP437 has ~256 glyphs. Anything else draws as ?, which is deliberate: drawing a different character would be worse.