Сломанный Ctrl+C — сначала гасла VM, а потом не прерывался sleep: Java, KVM и Linux

—

от автора

Как я проверял путь управляющего символа через Java, UART, KVM и Linux — и почему исправный IRQ не гарантировал завершения процесса.

Внутри небольшой виртуальной машины на Java уже работал Linux. Ядро загружалось через KVM, BusyBox открывал интерактивную оболочку, через виртуальный последовательный порт можно было вводить команды и получать ответ.

И вот я запускаю что-то подольше — sleep 30 или ls -R /. Через несколько секунд ожидание надоедает, рука сама тянется к Ctrl+C. Нажимаю — и вместо остановки команды гаснет вся VM. Как если бы попытка прервать sleep в обычном терминале выключала компьютер.

С первым сбоем разобрался довольно быстро: Ctrl+C обрабатывал терминал хостовой ОС, посылая SIGINT в том числе JVM. Исправил настройки терминала, снова запустил sleep 30, нажал Ctrl+C… и не произошло ничего. Виртуальная машина теперь жила, но гостевой процесс, похоже, приобрёл иммунитет к прерыванию.

На этот раз пришлось проследить почти весь путь ввода: от байта 0x03 в Java через виртуальный UART и KVM_IRQ_LINE до драйвера Linux, TTY и группы процессов переднего плана. Причина в итоге нашлась там, где я сначала не ожидал её искать — в способе запуска гостевой оболочки.

В статье покажу не только исправление, но и ход диагностики. Этот метод полезен и вне виртуализации: в терминальных мостах, контейнерах, собственных CLI и любых случаях, когда символ до приложения вроде бы доходит, а ожидаемого сигнала нет.

Контекст: PanamaKVM — экспериментальный Java VMM, который работает с Linux KVM через FFM API, без собственной JNI-библиотеки. Как он загружает Linux и доходит до интерактивной оболочки, я рассказывал в первой статье. Но читать её заранее не обязательно: здесь нам нужна лишь та часть VMM, через которую проходит ввод.

Путь Ctrl+C: от хостового терминала через Java, UART и KVM до генерации SIGINT в гостевой Linux

Путь Ctrl+C: от хостового терминала через Java, UART и KVM до генерации SIGINT в гостевой Linux

Два сбоя за одним сочетанием клавиш

Симптом №1: Ctrl+C выключает VM. Терминал, из которого запущена Java, сам обрабатывает управляющий символ. Его line discipline — механизм обработки терминального ввода — вместо передачи байта приложению генерирует SIGINT для foreground process group хостового терминала. Это группа процессов, которой терминал в данный момент отдан для работы на переднем плане. В неё попадает и JVM.

Симптом №2: JVM остаётся жива, но sleep 30 не прерывается. После изменения настроек хостового терминала Java уже читала 0x03 и передавала его в гостевую последовательную консоль. Однако из того, что байт доставлен, ещё не следует, что процесс обязан завершиться.

Удобнее разделить происходящее на три этапа:

  1. Доставка символа: байт 0x03 проходит через хостовое приложение и оказывается на входе гостевого TTY.

  2. Генерация и адресация сигнала: гостевая line discipline распознаёт VINTR и направляет SIGINT текущей foreground process group.

  3. Реакция процесса: сигнал приводит к стандартному действию, перехватывается обработчиком, игнорируется или остаётся заблокированным.

Если работает первый этап, это почти ничего не доказывает о третьем. Даже появившееся на экране ^C не гарантирует генерации сигнала: это может быть просто эхо управляющего символа.

Где заканчивается хостовый терминал

Терминальный ввод в Linux настраивается через termios. Для нашего случая важны четыре параметра (подробнее — в termios(3)):

Параметр

Что важно для этой ошибки

ISIG

Если включён, управляющий символ VINTR преобразуется в SIGINT терминальной foreground-группе

ICANON

В каноническом режиме обычный ввод буферизуется до завершения строки; для передачи по байтам это мешает

ECHO

Отвечает за отображение введённых символов на данном терминале

VINTR

Назначает специальный символ прерывания; типичное значение — ^C, то есть 0x03

В реализации HostTerminal.java я использовал следующий набор настроек:

String state = readState();if (state == null || !runStty("-icanon", "-echo", "-isig", "min", "1", "time", "0")) {    if (state != null) {        runStty(state);    }    return UNCHANGED;}return new HostTerminal(state);

Для первого симптома решающим был -isig: хостовый TTY перестал превращать ^C в SIGINT, адресованный в том числе JVM. Вместо этого символ мог попасть в стандартный ввод Java-приложения.

Остальные настройки отвечают за другое:

  • -icanon позволяет читать ввод без ожидания Enter. Каноническая обработка строки, в том числе стирание символов, больше не выполняется в прежнем режиме.

  • min 1 time 0 означает: в неканоническом режиме read() ожидает хотя бы один байт, без тайм-аута. Это не полный raw mode: остальные флаги termios мы здесь не отключали.

  • -echo не даёт хосту самостоятельно отображать введённые символы — за видимый ввод теперь отвечает гостевая консоль.

Получается слегка непривычная вещь: хостовый и гостевой TTY должны быть настроены противоположным образом. На хосте нужен -isig, чтобы не останавливать Java. В госте — isig, чтобы уже Linux мог превратить 0x03 в SIGINT для своей группы процессов. Это два независимых терминала, а не один общий набор настроек.

Java не прерывает VM — она передаёт байт

После изменения настроек управление действительно переходит к Java. В ConsoleSession.java виртуальный поток читает System.in и передаёт байты дальше (это тело input pump):

TerminalControlResponseFilter input = new TerminalControlResponseFilter(uart::offerInput);int next;while ((next = System.in.read()) >= 0) {    input.accept(next);}input.finish();

Фильтр отбрасывает некоторые ответы хостового терминала — например, ESC [ row ; column R, ответ на запрос позиции курсора. 0x03 к ним не относится: он проходит к uart::offerInput. Отдельной команды «прервать VM» в этом коде нет.

Эту границу удалось проверить через strace -ff -e read,ioctl: в трассе Java-процесса было видно чтение "\3". Значит, байт дошёл до JVM. Но дальше его ждут ещё виртуальное устройство, KVM и Linux — один read() ничего о них не доказывает.

В этот момент первое предположение уже можно было исключить: хост больше не забирал Ctrl+C себе. Но где же тогда терялась ожидаемая реакция?

Виртуальный UART: байт в RX-очереди ещё не означает прерывание

Здесь важно различать два вида «прерываний»: IRQ уведомляет гостевое ядро о событии устройства, а SIGINT — это Unix-сигнал, адресованный процессам. Между ними ещё должна сработать терминальная подсистема Linux.

Для serial console в PanamaKVM реализована минимальная модель 16550-совместимого COM1: базовый порт 0x3f8, линия IRQ4 и набор регистров, достаточный для работы гостевого serial-драйвера. Именно сюда Java передаёт 0x03.

Для входящего байта нужны прежде всего:

Регистр

Адрес при DLAB=0

Назначение в RX-пути

RBR

0x3f8, чтение

Гость забирает следующий байт из RX-очереди

IER

0x3f9

Бит 0 разрешает receive-data interrupt

IIR

0x3fa, чтение

Сообщает причину ожидающего interrupt

FCR

0x3fa, запись

Управление FIFO, в том числе очистка RX

LSR

0x3fd

Бит 0 показывает Data Ready

В Uart16550.java добавление входного байта выглядит так:

public synchronized void offerInput(int value) {    if ((value & ~0xff) != 0) {        throw new IllegalArgumentException("UART input must be an unsigned byte: " + value);    }    receivedBytes.add(value);    updateReceiveInterruptLine();}

Для взаимодействия с гостем важнее не сама add(), а пересчёт линии прерывания:

private void updateReceiveInterruptLine() {    boolean asserted = !receivedBytes.isEmpty()            && (interruptEnable & INTERRUPT_ENABLE_RECEIVED_DATA) != 0;    if (receiveInterruptAsserted != asserted) {        receiveInterruptAsserted = asserted;        receiveInterruptLine.setLevel(COM1_INTERRUPT, asserted);    }}

Инвариант этой упрощённой модели конкретный: линия RX IRQ активна, пока очередь не пуста и разрешено прерывание приёма. После чтения RBR очередь уменьшается, состояние линии пересчитывается; при пустой очереди или отключённом разрешении происходит deassert.

Здесь легко допустить ошибку: положить 0x03 в очередь и решить, что теперь Linux его получит. Но без уведомления драйвера, работающего через прерывания, байт может так и остаться в очереди до следующего опроса или другого события. Если выставить IRQ и не снять его вовремя, можно получить некорректные повторные уведомления или проблемы с формированием нового фронта. Конкретный эффект зависит от конфигурации interrupt controller.

Как байты UART попадает в KVM

Связь устройства с VM создаётся в GuestRunner.java:

Uart16550 uart = new Uart16550(System.out, machine::pulseInterrupt, machine::setInterruptLine);

Следующая граница — KVM backend. В KvmVirtualMachine.setInterruptLine() Java через FFM формирует структуру из двух 32-битных полей и вызывает ioctl(KVM_IRQ_LINE):

MemorySegment level = arena.allocate(IRQ_LEVEL_SIZE, Integer.BYTES);level.set(JAVA_INT, 0, interrupt);level.set(JAVA_INT, Integer.BYTES, asserted ? 1 : 0);operatingSystem.ioctl(vmDescriptor, KvmUapi.IRQ_LINE, level);

Первое поле struct kvm_irq_level — GSI/IRQ, второе — уровень 0 или 1. По официальному KVM API, этот ioctl задаёт состояние входа interrupt controller, созданного в ядре. Для edge-triggered конфигурации нужен переход уровня из 1 в 0 для формирования следующего фронта. Поэтому нельзя автоматически отождествлять уровень на выходе модели UART и способ обработки IRQ контроллером.

Есть и обратное направление. Когда гость читает регистр UART, KVM возвращает KVM_EXIT_IO: управление переходит VMM, VmRunner направляет обращение через I/O bus в эмулятор, после чего выполнение продолжается через KVM_RUN. Получается два разных направления: host кладёт байт и уведомляет гостя; guest читает UART через port I/O. Контракт KVM_EXIT_IO, VmRunner.java.

Работа UART RX и IRQ: поступление байта, выставление прерывания и его снятие после чтения данных гостевой системой

Работа UART RX и IRQ: поступление байта, выставление прерывания и его снятие после чтения данных гостевой системой

Guest TTY: кто превращает 0x03 в SIGINT

На этом работа Java и KVM в обработке Ctrl+C практически заканчивается. VMM не должен искать процесс sleep и посылать ему сигнал. Он предоставляет гостю serial port и доставляет события от устройства. А смысл байта определяет уже Linux.

Упрощённый путь внутри Linux выглядит так:

serial8250_rx_chars() → TTY flip buffer → N_TTY line discipline → проверка ISIG и VINTR → SIGINT для foreground process group.

В качестве фиксированного примера возьмём исходники Linux 6.12 — drivers/tty/n_tty.c. В обработке специальных символов есть такой фрагмент:

if (L_ISIG(tty)) {    if (c == INTR_CHAR(tty)) {        n_tty_receive_signal_char(tty, SIGINT, c);        return;    }}

Далее __isig() получает foreground process group терминала и вызывает kill_pgrp(). Здесь символ превращается в сигнал, адресованный группе процессов.

Именно поэтому интерактивной оболочке не нужно самой читать ^C и вызывать kill: при включённом ISIG это делает TTY-подсистема ядра. При этом какое действие выполнит получивший сигнал процесс — отдельный вопрос.

Здесь есть ещё одна ловушка. При запуске foreground-команды интерактивная оболочка может передать терминал другой группе. Поэтому pgrp самого shell и tpgid терминала во время работы sleep могут различаться — и это нормально. Проверять нужно не только PID команды, но и pgrp, session, tty_nr, tpgid — поля 5-8 /proc/<pid>/stat.

Как локализовать ошибку, не переписывая половину VMM

К этому моменту были проверены настройки хостового терминала, чтение 0x03 Java-процессом, вызовы KVM_IRQ_LINE, счётчики прерываний ttyS0 и настройки гостевого TTY. Но всё это ещё не отвечало на вопрос, почему не завершается sleep.

Решающей стала другая проверка: отправить SIGINT нужной foreground process group напрямую из второго процесса внутри гостя. Она обходит хостовый терминал, Java, UART и преобразование VINTR в гостевом TTY. sleep всё равно продолжал работать. После этого искать первопричину в обработчике KVM_EXIT_IO было уже бессмысленно.

Вот как выглядела локализация по границам. Это сводка сохранившихся проверок, а не поминутная расшифровка отладки:

Граница

Чем проверялась

Что подтвердилось — и чего проверка не доказывала

Host → Java

stty, strace -ff -e read,ioctl, чтение "\3"

JVM получала байт; о гостевом TTY это ничего не говорило

Java → UART/KVM

RX-контракт, KVM_IRQ_LINE

Состояние IRQ менялось; отдельный ioctl не доказывал полную доставку

KVM → Linux

/proc/interrupts, serial tty

Serial-прерывания обрабатывались; счётчик сам по себе не привязан к конкретному байту

Guest TTY

stty -a, isig, intr = ^C

Настройки допускали генерацию SIGINT

Foreground job

pgrp, tpgid из /proc/<pid>/stat

Терминал был передан группе выполняемой команды

Сигнал → процесс

Прямой SIGINT foreground-группе

Сбой сохранялся даже в обход всего транспорта

Оставались варианты уже на стороне процесса: заблокированный сигнал, собственный обработчик или SIG_IGN — явное игнорирование сигнала. Для такой проверки полезен /proc/<pid>/status:

# В отдельной гостевой сессии: подставьте PID работающей команды.pid=123grep -E '^(Name|Pid|PPid|SigBlk|SigIgn|SigCgt):' /proc/"$pid"/status

SigIgn содержит маску игнорируемых сигналов, SigBlk — заблокированных, SigCgt — сигналов с установленными обработчиками. Номер SIGINT в Linux — 2, поэтому важен бит 0x2 в SigIgn, а не равенство всей маски двум. Смотреть нужно данные целевого процесса, пока команда ещё выполняется. Подробности — в proc_pid_status(5) и signal(7).

Полного исторического вывода этих масок у меня не сохранилось, поэтому не буду выдавать предполагаемое значение SigIgn за записанный тогда результат. Но сочетание прямого SIGINT, способа запуска оболочки и последующего исправления довольно точно указало, где искать причину.

Причина: один & в запуске shell

Выяснилось, что интерактивная оболочка запускалась из неинтерактивного /init в фоне. В сокращённом виде исторический запуск выглядел примерно так (это реконструкция по хронике разработки, а не сохранённый Git diff):

setsid -c cttyhack sh -i &wait "$!"

Выглядит безобидно: запустить оболочку в фоне, а затем дождаться её завершения. Но здесь важен именно &. Когда job control не действует, ash запускает фоновую команду с игнорированием SIGINT и SIGQUIT. Это можно проверить не в моём VMM, а в исходниках BusyBox ash 1.36.1, forkchild():

if (mode == FORK_BG) {    ignoresig(SIGINT);    ignoresig(SIGQUIT);    // ...}

Версия BusyBox в собранном guest initramfs отдельно не закреплена; 1.36.1 здесь — версия исходников, по которой я разбираю сам механизм, а не заявленная версия исторического бинарника.

Есть и ещё одна деталь: состояние S_HARD_IGN в ash используется для сигналов, которые уже были игнорируемыми при входе в оболочку. Поэтому запуск следующего sh -i сам по себе не означает, что прежняя настройка исчезнет.

Почему это влияет даже на sleep, запущенный позднее? Из-за правил наследования signal disposition:

  • fork() наследует dispositions родительского процесса;

  • execve() сбрасывает пользовательские обработчики сигналов, но сохраняет игнорируемые сигналы;

  • setsid и корректный controlling terminal определяют сессию и адресацию терминальных сигналов, но не обязаны сбрасывать SIG_IGN;

  • включение guest stty isig разрешает генерацию сигнала терминалом, но не задаёт, как отреагирует процесс-получатель.

Подробнее про это наследование можно посмотреть в источниках: signal(7), раздел Signal dispositions, и execve(2).

Теперь картина складывается. Ctrl+C превращается в гостевой SIGINT, группа выбрана правильно, но sleep унаследовал SIG_IGN и продолжает работать. Ни stty, ни setsid не исправляют реакцию процесса на уже полученный сигнал. И ни один из этих механизмов сам по себе не является особенностью KVM.

Минимальная проверка без KVM

Чтобы отделить эту причину от всей истории с VM, её можно воспроизвести на обычном Linux. Команда специально устанавливает SIGINT=SIG_IGN, затем заменяет себя на sleep через exec:

python3 -S -c 'import os, signal; signal.signal(signal.SIGINT, signal.SIG_IGN); os.execlp("sleep", "sleep", "8")'

Запустите пример в интерактивном терминале Linux и нажмите Ctrl+C до истечения восьми секунд. sleep продолжит ждать: игнорирование SIGINT пережило exec. Python здесь нужен только для подготовки состояния, дальше выполняется обычная программа. И никакого UART или KVM в опыте нет.

Это именно демонстрация механизма наследования, а не повторение исторического запуска BusyBox один в один.

Исправление: запустить оболочку без исходного игнорирования

В текущем guest /init команда уже запускается синхронно:

if "$busybox" setsid -c "$busybox" cttyhack "$busybox" sh -c \    'stty isig intr "^C"; exec /bin/busybox sh -i' \    </dev/console >/dev/console 2>&1; then    shell_status=0else    shell_status=$?fi

Исправление составное, и важно не смешать назначения его частей:

Без &: нет прежнего фонового запуска из неинтерактивного /init и связанного с ним исходного SIG_IGN в этом сценарии.

setsid -c и cttyhack: создают необходимый контекст сессии и помогают получить controlling terminal для интерактивной оболочки. Их назначение — job control и TTY.

stty isig intr "^C": настраивает гостевой TTY так, чтобы 0x03 был символом прерывания.

exec /bin/busybox sh -i: заменяет промежуточную оболочку интерактивной. Важно: сам exec не сбрасывает SIG_IGN. Исправление работает потому, что этот shell больше не получает нежелательное игнорирование на предыдущем этапе.

После выхода из синхронно запущенной оболочки /init получает её статус, выполняет sync и reboot -f. В этом расследовании речь шла о foreground-команде sleep, а не о специальных правилах сигналов для PID 1.

После исправления в WSL/KVM были проверены два сценария: Ctrl+C прерывал sleep 30 и непрерывный вывод yes; в обоих случаях shell оставался работоспособным и принимал следующую команду. Это зафиксировано в хронике разработки. Полного автоматического PTY/KVM-теста всей цепочки в репозитории пока нет — текущие тесты проверяют отдельные компоненты и smoke boot. Поэтому я не считаю эти проверки воспроизведёнными в CI.

Что я теперь проверял бы при неработающем Ctrl+C

Если бы пришлось расследовать похожую проблему снова, я начал бы не с изменения виртуального устройства, а с нескольких коротких проверок. Они применимы и к терминальным прокси, и к контейнерам, и к собственным VMM.

  1. Кто первым обрабатывает Ctrl+C? Проверить ISIG у хостового TTY. Если сигнал получает терминальный мост или JVM, до гостя байт может вообще не доходить.

  2. Приложение действительно читает 0x03? Посмотреть системные вызовы или добавить диагностику на входе. Одного отображённого ^C недостаточно.

  3. Если есть виртуальный UART, что с RX и IRQ? Проверить не только очередь, но и RBR/IIR/LSR, enable-биты, assert/deassert. Отдельный KVM_IRQ_LINE не заменяет end-to-end проверки.

  4. Кому гостевой TTY посылает сигнал? Проверить stty -a, controlling terminal, pgrp, session и tpgid выполняемой команды.

  5. Что произойдёт, если обойти весь путь ввода? Послать SIGINT напрямую правильной process group и проверить результат вызова. Если процесс всё равно работает, перейти к SigBlk, SigIgn, SigCgt и поведению программы.

  6. Что процесс унаследовал при запуске? Проверить фоновый &, неинтерактивный shell, fork, exec и signal dispositions. Исправная генерация сигнала ещё не означает, что он завершит процесс.

Диагностика Ctrl+C: последовательная проверка доставки символа, генерации сигнала и реакции процесса для локализации неисправности

Диагностика Ctrl+C: последовательная проверка доставки символа, генерации сигнала и реакции процесса для локализации неисправности

Вместо вывода

Самой сложной частью этой истории оказалась не реализация KVM_IRQ_LINE и даже не виртуальный UART. Их хотя бы можно проверять отдельно: увидеть байт, изменение IRQ, вызов ioctl, счётчик прерываний в госте. Сложнее было вовремя перестать искать ошибку именно в том слое, который я тогда разрабатывал.

Проверки показывали, что 0x03 проходил через Java и виртуальный UART, гостевые serial-прерывания работали, а foreground-группа была выбрана правильно. Но даже прямой SIGINT изнутри гостя не останавливал sleep: процесс унаследовал игнорирование сигнала. Всё из-за одного & в запуске оболочки — и того, что exec не сбрасывает SIG_IGN.

Из этого расследования я бы забрал один приём: наблюдать каждую границу отдельно и, когда возможно, обходить половину системы прямым экспериментом. Это часто полезнее, чем добавлять очередное исправление в первый подозрительный компонент.

PanamaKVM — исходники версии v0.1.0. Конкретные участки консоли, UART и guest /init доступны по ссылкам в статье.

Источники для дальнейшего погружения

ссылка на оригинал статьи https://habr.com/ru/articles/1094024/