Alt+1..6 was a worse choice than the Alt+F-keys it replaced, and the reason
is the one that matters: anything the kernel swallows is taken from every
program that will ever run here, permanently.
Alt+<digit> is Meta-digit. zsh's own line editor binds it as digit-argument --
Alt+1 Alt+2 x repeats x twelve times -- and every readline editor does the
same. Yesterday's chord broke that system-wide, which is exactly the failure
mode of picking a chord applications want.
Working the whole space instead of one axis of it:
the HOST takes Alt+F4, Alt+F2, Ctrl+Alt+F1..F6
APPLICATIONS want Alt+anything (Meta), Ctrl+letter, Ctrl+Shift+digit
(Ctrl+Shift+2 types ^@), bare F-keys
the LAYOUT wants AltGr+letter -- Polish diacritics
Ctrl+Alt+Shift+F1..F6 is in none of those sets. Display managers bind the same
chord without Shift, so adding it steps out of the host's way, and no terminal
application binds a triple-modified function key.
Which chords actually reach a guest was measured rather than assumed: a
temporary probe in kbdgetc logged every modified keypress and QEMU's sendkey
fired each candidate at it. Everything reached the guest, Ctrl+Alt+F2
included -- so that conflict is the host's display manager, not QEMU. The same
probe corrected an earlier claim: `sendkey altgr-2` produces no event at all,
so the previous commit's "AltGr+2 did not fire" was never actually tested.
And the escape hatch is real now. VT_SETMODE was accepted and ignored, so a
program could ask to own the chord, be told yes, and still not get it.
VT_PROCESS genuinely stops the kernel switching. Together with the EVIOCGRAB
path -- which kbdgetc already checks before the chord is recognised -- there
are three honest layers: nothing, VT_PROCESS, exclusive grab.
What VT_PROCESS still does not do is the VT_RELDISP handshake; struct vt_mode
carries relsig/acqsig for when Schcom needs to release the GPU before the
screen changes hands.
Verified by screendump hash: Alt+2 reaches zsh as Meta, Ctrl+Alt+F2 and
Ctrl+Shift+F2 leave the screen untouched, Ctrl+Alt+Shift+F2 switches.
schfs-check passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
10 KiB
Virtual terminals
Six of them, /dev/tty1 through /dev/tty6, reached with
Ctrl+Alt+Shift+F1 .. F6 or with chvt N. 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.
Why this chord
Anything the kernel swallows is taken from every program that will ever run here, permanently. That is the whole difficulty, and it took two wrong answers to see it. The obvious candidates are all spoken for on one side or the other:
| The host takes it | |
Alt+F4 |
the window manager closes the QEMU window |
Alt+F2 |
GNOME and KDE open a run dialog |
Ctrl+Alt+F1..F6 |
the display manager switches its own terminals |
| Applications want it | |
Alt+anything |
Meta. zsh's own line editor 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 layout wants it | |
AltGr+letter |
Polish diacritics — ą ć ę ł ń ó ś ź ż — and on some setups AltGr is Ctrl+Alt |
Ctrl+Alt+Shift+F1..F6 is in none of those sets. Display managers bind the
same chord without Shift, so adding it steps out of the host's way, and no
terminal application binds a triple-modified function key. Four keys is more
than one wants to press, which is right for something done rarely and on
purpose — chvt N covers everything else.
Which chords actually reach a guest was measured, not assumed: a temporary
probe in kbdgetc logged every modified keypress, and QEMU's sendkey fired
each candidate at it. Everything reached the guest, including Ctrl+Alt+F2 —
so the host conflict is the host's display manager, not QEMU.
The modifier test is an equality, not a mask:
if ((shift & (CTL | SHIFT | ALT | RALT)) == (CTL | ALT | SHIFT) && ...)
A mask would fire 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.
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.
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_RELDISPhandshake.VT_PROCESSstops 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, andstruct vt_modealready 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_MAXROWSis 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.