tty: the switch chord is a setting, because only the host knows the answer

Third guess at a chord, and the third one would have been wrong too: a display
manager that grabs Ctrl+Alt+F<n> matches loosely, so Ctrl+Alt+Shift+F2 never
reaches the guest either. Extra modifiers do not get past a loose grab.

The real problem is that this is not knowable from inside. A key the host
swallowed is indistinguishable from a key nobody pressed, so the guest cannot
probe for a free combination, and there is no combination that is free
everywhere:

  the HOST takes    Alt+F4, Alt+F2, Ctrl+Alt+F1..F6 (loosely matched),
                    Ctrl+Alt+digit (QEMU's own console tabs)
  APPLICATIONS want Alt+anything (Meta -- zsh binds Meta-digit as
                    digit-argument), Ctrl+letter, Ctrl+Shift+digit
                    (Ctrl+Shift+2 types ^@), bare F-keys
  the LAYOUT wants  AltGr+letter

So it is a setting. VT_SETCHORD/VT_GETCHORD, and in userspace:

    chvt -c ctrl+shift+F       Ctrl+Shift+F1..F6   (the new default)
    chvt -c ctrl+alt+shift+F
    chvt -c alt+1
    chvt -c off                no chord; chvt N is then the only way
    chvt                       report the active terminal and the chord

`off` is a legitimate setting rather than a failure mode. On a machine whose
host grabs everything worth binding, `chvt N` is the honest answer.

The default moved to Ctrl+Shift+F1..F6: display managers bind Ctrl+Alt+F<n>,
not Ctrl+Shift+F<n>, and Ctrl+Shift with a *function* key is not a character
on any layout -- unlike Ctrl+Shift+digit, which is.

Caught while writing it: VTC_RALT was 0x08 while kbd.h's RALT is (1<<7), since
(1<<3) is CAPSLOCK. A chord set from userspace with AltGr would simply never
have matched, and the only symptom would have been a key that does nothing.
A _Static_assert now holds the two headers together.

Verified: Ctrl+Alt+F2 leaves the screen byte-identical, Ctrl+Shift+F2 switches,
Ctrl+Shift+F1 returns to a pixel-identical tty1, and chvt -c round-trips
through alt+1 and off and back. schfs-check passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
This commit is contained in:
Nexoniarz 2026-09-13 13:13:38 +02:00
commit 7c0f7c7d01
8 changed files with 256 additions and 60 deletions

View file

@ -3,12 +3,26 @@
// chvt N -- show virtual terminal N.
//
// The same name and the same argument as the Linux tool, because it does the
// same thing and nobody should have to learn a second one. Alt+F1..F6 does
// same thing and nobody should have to learn a second one. The chord does
// this from the keyboard; this does it from a script, and from a serial
// console where there are no function keys to press.
//
// chvt report which terminal is active and which are in use
// chvt 2 switch to /dev/tty2
// chvt report the active terminal, those in use, and the chord
// chvt 2 switch to /dev/tty2
// chvt -c <spec> set the switch chord
// chvt -c off unbind it entirely; chvt stays the only way to switch
//
// The chord is settable because the right value is a property of the *host*,
// not of SchOS. A display manager that grabs Ctrl+Alt+F<n> takes it before the
// guest sees anything, and it matches loosely -- extra modifiers do not get
// past it -- so a chord that works on one machine is dead on the next. The
// guest cannot detect this: a key the host swallowed is indistinguishable from
// a key nobody pressed. So it is a setting.
//
// chvt -c ctrl+shift+F Ctrl+Shift+F1..F6 (the default)
// chvt -c ctrl+alt+shift+F Ctrl+Alt+Shift+F1..F6
// chvt -c alt+1 Alt+1..6
// chvt -c off no chord at all
#include "types.h"
#include "user.h"
@ -44,6 +58,73 @@ num(const char *s)
return v;
}
static int
same(const char *a, const char *b, int n)
{
for (int i = 0; i < n; i++)
if (a[i] != b[i]) return 0;
return 1;
}
static int
eq(const char *a, const char *b)
{
while (*a && *a == *b) { a++; b++; }
return *a == 0 && *b == 0;
}
// "ctrl+shift+F", "alt+1", "off". Modifiers by name, then a base: F for the
// function keys, 1 for the number row.
static int
parse_chord(const char *spec, struct vt_chord *out)
{
out->mods = 0;
out->base = 0;
if (eq(spec, "off") || eq(spec, "none"))
return 1; // no modifiers: the chord is disabled
const char *p = spec;
while (*p) {
const char *start = p;
while (*p && *p != '+') p++;
int n = (int)(p - start);
if (n == 4 && same(start, "ctrl", 4)) out->mods |= VTC_CTRL;
else if (n == 5 && same(start, "shift", 5)) out->mods |= VTC_SHIFT;
else if (n == 3 && same(start, "alt", 3)) out->mods |= VTC_ALT;
else if (n == 5 && same(start, "altgr", 5)) out->mods |= VTC_RALT;
else if (n == 1 && (*start == 'F' || *start == 'f')) out->base = VTC_BASE_F1;
else if (n == 1 && *start == '1') out->base = VTC_BASE_1;
else return 0;
if (*p == '+') p++;
}
// A base with no modifiers would bind a bare function key, which every
// full-screen program wants. Refuse it here rather than in the kernel, so
// the message can say why.
if (out->base && !out->mods)
return 0;
if (out->mods && !out->base)
out->base = VTC_BASE_F1;
return 1;
}
static void
show_chord(const struct vt_chord *c)
{
if (c->mods == 0) {
puts("off (use chvt N)\n");
return;
}
if (c->mods & VTC_CTRL) puts("Ctrl+");
if (c->mods & VTC_ALT) puts("Alt+");
if (c->mods & VTC_SHIFT) puts("Shift+");
if (c->mods & VTC_RALT) puts("AltGr+");
if (c->base == VTC_BASE_F1) puts("F1..F6\n");
else puts("1..6\n");
}
int
main(int argc, char *argv[])
{
@ -56,6 +137,21 @@ main(int argc, char *argv[])
exit();
}
if (argc == 3 && argv[1][0] == '-' && argv[1][1] == 'c') {
struct vt_chord c;
if (!parse_chord(argv[2], &c)) {
puts("chvt: bad chord -- try ctrl+shift+F, alt+1, or off\n");
exit();
}
if (ioctl(fd, VT_SETCHORD, &c) < 0) {
puts("chvt: kernel refused that chord\n");
exit();
}
puts("chord set to ");
show_chord(&c);
exit();
}
if (argc == 1) {
struct vt_stat st;
if (ioctl(fd, VT_GETSTATE, &st) < 0) {
@ -71,6 +167,11 @@ main(int argc, char *argv[])
putn(i);
}
puts("\n");
struct vt_chord c;
if (ioctl(fd, VT_GETCHORD, &c) == 0) {
puts("chord: ");
show_chord(&c);
}
exit();
}

View file

@ -18,8 +18,8 @@ its distribution.
| API | **SchAPI** | The native programming interface — *designed, not built* |
| Compositor | **Schcom** | Owns the display; v0 runs |
Six virtual terminals sit under all of it -- `/dev/tty1`..`tty6`,
Ctrl+Alt+Shift+F1..F6 --
Six virtual terminals sit under all of it -- `/dev/tty1`..`tty6`, `chvt N`
or a settable chord --
each with its own screen, line discipline and foreground process group. See
[terminals.md](terminals.md).

View file

@ -1,8 +1,9 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
# 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
Six of them, `/dev/tty1` through `/dev/tty6`, reached with `chvt N` or with
a keyboard chord — **Ctrl+Shift+F1 .. F6** by default, and settable, for
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.
@ -107,49 +108,67 @@ 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
### The chord is a setting, and that is the point
**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:
**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** | |
| `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 |
| **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 layout wants it** | |
| `AltGr+`letter | Polish diacritics — ą ć ę ł ń ó ś ź ż — and on some setups AltGr *is* Ctrl+Alt |
| **The keyboard layout wants it** | |
| `AltGr+`letter | Polish diacritics — ą ć ę ł ń ó ś ź ż — and on some setups AltGr arrives as 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.
So the kernel ships a default and lets it be moved:
Which chords actually reach a guest was **measured, not assumed**: a temporary
```sh
chvt # report the active terminal and the current chord
chvt -c ctrl+shift+F # Ctrl+Shift+F1..F6 (the default)
chvt -c ctrl+alt+shift+F # if your host leaves that alone
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 `Ctrl+Shift+F1..F6` because display managers bind `Ctrl+Alt+F<n>`
rather than `Ctrl+Shift+F<n>`, and because `Ctrl+Shift` with a *function key*
is not a character on any layout — unlike `Ctrl+Shift+`digit, which is.
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 reached the guest, including `Ctrl+Alt+F2` —
so the host conflict is the host's display manager, not QEMU.
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 ((shift & (CTL | SHIFT | ALT | RALT)) == (CTL | ALT | SHIFT) && ...)
if (vt_chord_mods != 0 &&
(shift & (CTL | SHIFT | ALT | RALT)) == vt_chord_mods && ...)
```
A mask would fire on any superset, so a chord aimed at the host would switch
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:

View file

@ -44,6 +44,8 @@ void cons_flush_input(int minor);
int cons_fg_pgrp(int minor);
void cons_set_fg_pgrp(int minor, int pg);
int console_vt_hotkey(int key);
extern uint vt_chord_mods; // kernel/dev/kbd.c -- the switch chord
extern uint vt_chord_base;
struct vt *vt_for_minor(int minor);
extern uint lapic_timer_hz; // measured, kernel/arch/x86_64/lapic.c
extern uint64 tsc_hz;

View file

@ -36,40 +36,43 @@
#define KEY_INS 0xE8
#define KEY_DEL 0xE9
// Ctrl+Alt+Shift+F1..F6 -- switch virtual terminal.
// Switch virtual terminal.
//
// Above 0xFF deliberately. kbdgetc returns an int and consoleintr takes one,
// and every value that fits in a byte is already either a character or a
// UTF-8 continuation byte. A chord that collided with one would be typed into
// the terminal instead of switching away from it.
//
// Choosing this took two wrong answers. Anything the kernel swallows is taken
// from every program that will ever run here, permanently, and the obvious
// candidates are all spoken for on one side or the other:
// *Which* chord is a runtime setting, not a constant here, and that is the
// interesting part. Three separate parties have a claim on every candidate:
//
// HOST takes it, before the guest ever sees 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
// the HOST, which sees the keystroke first and may never pass it on
// Alt+F4, Alt+F2 window manager
// Ctrl+Alt+F1..F6 display manager, and it matches loosely --
// adding Shift does not get past it
// Ctrl+Alt+<digit> QEMU's own console tabs
//
// APPLICATIONS want it, and would never get it again
// Alt+<anything> Meta. zsh's own line editor binds Meta-digit as
// digit-argument; every readline editor does too
// 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
// APPLICATIONS, which lose the key permanently if the kernel eats it
// Alt+<anything> Meta. zsh binds Meta-digit as digit-argument
// Ctrl+<letter> control characters
// Ctrl+Shift+<digit> types a character -- Ctrl+Shift+2 is ^@
// F1..F6 alone help, and full-screen programs
//
// THE KEYBOARD LAYOUT wants it
// AltGr+<letter> Polish diacritics, and on some setups AltGr is
// delivered as Ctrl+Alt
// the KEYBOARD LAYOUT
// AltGr+<letter> Polish diacritics, and on some setups AltGr
// arrives as 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 correct for something done rarely
// and deliberately -- and `chvt N` is there for everything else.
// There is no combination that is free for everyone on every host, and the
// guest cannot find out what its host grabs -- the keystroke simply never
// arrives, which is indistinguishable from a chord nobody pressed. So the
// kernel ships a default and lets it be changed: VT_SETCHORD, and `chvt
// --chord` in userspace. Setting it to no modifiers disables the chord
// entirely and leaves `chvt N` as the only way to switch, which is a
// legitimate choice on a machine whose host takes everything.
#define KEY_VT_SWITCH 0x120
#define SCAN_F1 0x3B // F1..F6 are scancodes 0x3B..0x40
#define SCAN_1 0x02 // 1..6 are scancodes 0x02..0x07
// C('A') == Control-A
#define C(x) (x - '@')

View file

@ -140,6 +140,32 @@ struct rlimit {
#define VT_PROCESS 0x01
#define VT_ACKACQ 0x02
// VT_SETCHORD / VT_GETCHORD -- which key combination the kernel switches on.
// SchOS's own, not Linux's: Linux hardcodes Ctrl+Alt+F<n> and nobody has ever
// needed to move it, because Linux is the host. A guest does need to move it,
// because the right answer depends entirely on what the host grabs first --
// and that is not something the guest can discover.
#define VT_GETCHORD 0x5620
#define VT_SETCHORD 0x5621
// Modifier bits, matched exactly. These mirror kernel/dev/kbd.h's values so
// that userspace and the keyboard driver cannot drift apart.
#define VTC_SHIFT 0x01 // kbd.h SHIFT (1<<0)
#define VTC_CTRL 0x02 // kbd.h CTL (1<<1)
#define VTC_ALT 0x04 // kbd.h ALT (1<<2)
#define VTC_RALT 0x80 // kbd.h RALT (1<<7) -- NOT (1<<3); that is
// CAPSLOCK, and bits 3..6 are the other locks
// and the E0 prefix flag
// Well-known base scancodes for the `base` field below.
#define VTC_BASE_F1 0x3B // F1..F6
#define VTC_BASE_1 0x02 // the number row, 1..6
struct vt_chord {
uint16 mods; // exactly these modifiers held, and no others
uint16 base; // scancode of the key meaning terminal 1
};
struct vt_mode {
char mode; // VT_AUTO or VT_PROCESS
char waitv; // unused here

View file

@ -2,9 +2,20 @@
#include "x86.h"
#include "defs.h"
#include "kbd.h"
#include "posix.h"
#include "vt.h" /* NVT -- how many terminals the chords reach */
#include "input.h"
// The chord, settable at run time through VT_SETCHORD. Defaults to
// Ctrl+Shift+F1..F6: the host's display manager binds Ctrl+Alt+F<n> and
// matches it loosely, so anything containing Ctrl+Alt never reaches a guest,
// whatever else is held with it. Ctrl+Shift is not a VT chord on any host and
// is not a character on any layout.
//
// Zero modifiers disables the chord entirely; `chvt N` still works.
uint vt_chord_mods = CTL | SHIFT;
uint vt_chord_base = SCAN_F1;
int
kbdgetc(void)
{
@ -60,19 +71,22 @@ kbdgetc(void)
shift |= shiftcode[data];
shift ^= togglecode[data];
// Ctrl+Alt+Shift+F1..F6 before the character tables, because there is no
// character to
// look up: it is a chord that means "show me a different terminal". What it
// means is decided in console.c, which is where the terminals are; this only
// has to name it. See console_vt_hotkey().
// The switch chord, before the character tables, because there is no
// character to look up: it is a combination that means "show me a different
// terminal". What it means is decided in console.c, which is where the
// terminals are; this only has to name it. See console_vt_hotkey().
//
// The modifier test is an equality, not a mask. A mask fires on any superset,
// so a chord aimed at the host would switch terminals behind your back.
//
// The modifier test is an equality, not a mask. `shift & ALT` would fire on
// Ctrl+Alt+1 and on AltGr+1 as well, so a chord aimed at the host -- or a
// Polish keystroke -- would switch terminals behind your back. Exactly left
// Alt, and nothing else held.
if ((shift & (CTL | SHIFT | ALT | RALT)) == (CTL | ALT | SHIFT) &&
data >= SCAN_F1 && data < SCAN_F1 + NVT)
return KEY_VT_SWITCH + (data - SCAN_F1);
if (vt_chord_mods != 0 &&
(shift & (CTL | SHIFT | ALT | RALT)) == vt_chord_mods &&
data >= vt_chord_base && data < vt_chord_base + NVT)
return KEY_VT_SWITCH + (data - vt_chord_base);
c = charcode[shift & (CTL | SHIFT)][data];
if(shift & CAPSLOCK){
@ -89,3 +103,10 @@ kbdintr(void)
{
consoleintr(kbdgetc);
}
// The two headers describe the same bits to two different audiences, and 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.
_Static_assert(VTC_SHIFT == SHIFT && VTC_CTRL == CTL &&
VTC_ALT == ALT && VTC_RALT == RALT,
"include/posix.h VTC_* must mirror include/kbd.h modifiers");

View file

@ -1187,6 +1187,30 @@ addr_t sys_ioctl(void)
vt_for_minor(f->ip->minor)->mode = m->mode;
return 0;
}
// Which chord the kernel switches on. SchOS's own ioctl -- see the long
// comment in include/kbd.h for why this has to be settable at all.
if (request == VT_SETCHORD) {
struct vt_chord *c = (struct vt_chord*)user_ptr(argp, sizeof(*c));
if (!c) return -EFAULT;
// Only real modifier bits, and a base that leaves room for NVT keys
// below the release flag. A base of 0 with no modifiers is the
// documented way to turn the chord off, so it is not an error.
if (c->mods & ~(VTC_SHIFT | VTC_CTRL | VTC_ALT | VTC_RALT))
return -EINVAL;
if (c->mods && (c->base == 0 || c->base + NVT > 0x80))
return -EINVAL;
vt_chord_mods = c->mods;
vt_chord_base = c->base;
return 0;
}
if (request == VT_GETCHORD) {
struct vt_chord *c = (struct vt_chord*)user_ptr(argp, sizeof(*c));
if (!c) return -EFAULT;
c->mods = (uint16)vt_chord_mods;
c->base = (uint16)vt_chord_base;
return 0;
}
if (request == VT_GETMODE) {
struct vt_mode *m = (struct vt_mode*)user_ptr(argp, sizeof(*m));
if (!m) return -EFAULT;