[Перевод] Как потерять управление оболочкой… Расследование

image

Несколько недель назад я хакал языковые сервера в Zed, пытаясь заставить Zed определять, когда заданный бинарник языкового сервера, например gopls, уже присутствует в $PATH. Если так, вместо загрузки нового бинарника Zed должен использовать его.

Трудность: часто $PATH динамически изменяется такими инструментами, как direnv, asdf, mise и другими, которые позволяют в данной папке установить определённый $PATH. Почему эти инструменты так делают? Потому что это даёт возможность, скажем, в начале $PATH добавить ./my_custom_binaries, пока вы находитесь в my-cool-project. Поэтому нельзя просто использовать $PATH, связанный с процессом Zed, нужен $PATH, как он есть, когда выполняется cd в каталог проекта.

Легко, подумал я. Просто запусти $SHELL, выполни cd в проект, чтобы запустить direnv и всё такое, запусти env, сохрани окружение, выбери $PATH, найди в нём бинарники. И это было легко. Вот часть кода, та часть, которая запускает $SHELL, cd и получает env:

fn load_shell_environment(dir: &Path) -> Result> {
    // Получает $SHELL
    let shell = std::env::var("SHELL")?;

    // Конструирует команду, которую хочется выполнить в $SHELL
    let command = format!("cd {:?}; /usr/bin/env -0;", dir);

    // Запускает $SHELL как интерактивную оболочку (чтобы использовались файлы пользователя rc).
    // и выполняет `command`:
    let output = std::process::Command::new(&shell)
        .args(["-i", "-c", &command])
        .output()?;

    // [... проверка кода выхода, получение stdout, превращение stdout в HashMap и т. д. ...]
}

За исключением одного: после запуска экземпляра Zed в терминале, который выполнял эту функцию, я больше не мог убить Zed, нажав Ctrl-C.

Я мог спамить в терминале ^C, и ничего не происходило. Строки и строки отчаянных ^C, которые никогда не услышат своего эха.

Как? Почему? … Что?

Сказав «Что?» раз 20 и нажимая Ctrl-c ещё чаще, я попросил помощи у Петра, потому что не был на 100% уверен в том, как Rust порождает процессы, а он — маг Rust. Я знал, что где-то внутри std::process::Command должны быть системные вызовы fork и exec, но не был уверен, что Rust не делает что-нибудь хитроумное с обработчиками сигналов или не имеет стандартных обработчиков сигналов, которые путаются с Ctrl-c. Потому что Ctrl-c должен привести к отправке сигнала прерывания процессам, который должен привести процесс к завершению, но, очевидно, это перестало работать.

Мы стали толкать всевозможные гипотезы, какими бы необычными они ни были:


  • Мы уверены, что оболочка больше не запущена? Да, мы уверены, потому что .output() наверху выполняет return только после завершения выполнения команды.
  • Речь идёт о cd? Запускают ли direnv или asdf или другие инструменты какие-нибудь хуки, которые берут на себя управление терминалом? Нет, оказалось, когда мы запускали просто /usr/bin/env -0; без cd, он также получал контроль над оболочкой.
  • Так это -0, который мы передаём в env? Так не должно быть, потому что это просто форматирование. Но отчаянные времена порождают отчаянные попытки отладки. Мы попробовали, и это не -0.
  • Подождите, это env? Не делает ли он что-нибудь чудное с моим терминалом? Ага.

Так мы изменили command с

let command = format!("/usr/bin/env;");

на

let command = format!("echo lol");

…и угадайте что? Ctrl-c снова заработал.

Что?

Окей, ещё попытка. Что, если сделать и то, и другое?

let command = format!("/usr/bin/env; echo lol");

Это тоже сработало. ЧТО!

Так, секунду… моё нутро что-то мне подсказывает. /usr/bin/env не встроена в оболочку, так? Но встроена echo. Это подсказка?

Давайте попробуем так:

let command = format!("ls");

Старая добрая ls. Наверное, команда, которую я выполнял чаще всего в жизни. Она всегда под рукой, когда нужна, и на каждой машине, к которой я получаю доступ, я сразу же запускаю ls, чтобы убедиться, что она работает. Я доверил бы ls свою жизнь.

И всё же после запуска ls в этой подоболочке Ctrl-c перестал работать. И ты, ls?

Следующая гипотеза: это что-то в Zed? Устанавливаем ли мы обработчики сигналов? Давайте узнаем. Мы скопировали функцию в новый, голый проект Rust, запустили её и… это воспроизвелось. Ctrl-c перестал работать и в этом проекте.

Окей, тогда это Rust? Я переписал функцию на Go, и на Go тоже потерял управление Ctrl-c.

К тому моменту мы потратили на это почти 2 часа, но так и не смогли разобраться. Однако у нас был обходной путь:

let command = format!("/usr/bin/env; exit 0;");

exit встроена во всех различных оболочках, поэтому её безопасно запускать, и она устраняет проблему. Окей, довольно справедливо. Мы забили над этой строкой адский комментарий, чтобы дать знать слеедующему человеку, что exit 0 теперь несущая конструкция, и пошли дальше.

Но головоломка меня зацепила. Я спросил у коллег, нёрдов по оболочке, знают ли они, что происходит, но готового ответа не было ни у кого. Поэтому по утрам я начинал расследование.

Я создал репозиторий, в котором небольшая программа на Rust воспроизводила проблему: она порождала процесс оболочки, ждала его выхода, а затем простаивала 5 секунд, чтобы я мог проверить, работает ли Ctrl-c. Охота продолжалась.

Первое озарение пришло, когда я понял, что не обязательно посылать сигнал через Ctrl-c: я могу использовать команду kill. Увы и ах, гавкнула не обработка сигнала! Когда я использовал kill -INT, поступил сигнал, и процесс остановился. Дело не в том, что мой процесс больше не реагирует на сигналы, а скорее в том, что после запуска процесса оболочки Ctrl-c не подаёт нужных сигналов.

Следующая попытка. Гавкнул ли терминал после запуска оболочки? Итак, кое-что о состоянии терминала. Кто-то в ответах на твит указал мне на stty, которая позволяет устанавливать параметры терминального устройства, такие как скорость передачи данных (да) и другие штуки. Я изменил свою программу, чтобы до и после процесса оболочки она запускала stty -a. Неудачно: без изменений на выходе.

Отчаявшись, я также воспользовался инспектором терминала Ghostty, чтобы посмотреть, не меняется ли какое-то состояние терминала, что приводит к возгаранию Ctrl-C.

После нескольких дней, проведённых в ChatGPT (о котором я писал в прошлый раз), он наконец дал мне подсказку:


Порождённая оболочка наследует управление терминалом (TTY), и поскольку это интерактивная оболочка (флаг -i), она устанавливает себя в качестве лидера группы процессов переднего плана для терминала. Это меняет способ обработки сигналов, особенно SIGINT, генерируемых Ctrl-C.

Ага. Лидер группы процессов переднего плана. Интересно. Хммм… Вот что говорится о группах процессов в книге Advanced Programming in the Unix Environment (APUE), которую я достал сегодня пока писал это.


Группа процессов — это совокупность одного или нескольких процессов, обычно связанных с одним и тем же заданием (управление заданиями рассматривается в разделе 9.8), которые могут принимать сигналы от одного и того же терминала. Каждая группа процессов имеет уникальный идентификатор группы процессов. Идентификаторы групп процессов похожи на идентификаторы процессов: они являются положительными целыми числами и могут храниться в типе данных pid_t. Функция getpgrp возвращает идентификатор группы процессов вызывающего процесса.

Важная часть: «которые могут принимать сигналы от одного и того же терминала».

Много времени прошло с тех пор, как я в последний раз искал что-то в материальной книге. На фото: Продвинутое программирование в среде Unix. Фантастическая книга

У APUE есть ещё подсказки:


Лидер группы процессов может создать группу процессов, создать процессы в группе, а затем завершить работу.

Так вот что происходит? Оболочка порождается, заявляет, что является лидером группы процессов, когда не запускает встроенную команду, выходит, а после не восстанавливает предыдущего лидера группы процессов?

Ощущалось, что я приближаюсь. Поэтому я продолжал спрашивать ChatGPT, как это подтвердить, и это привело меня к tcgetprg:


Функция tcgetpgrp() возвращает идентификатор группы процессов приоритетной группы процессов на терминале, связанном с fd, который должен быть управляющим терминалом вызывающего процесса.

Ок, раз уж разговариваем, похоже, это может нас куда-то привести. Я попросил ChatGPT сгенерировать мне код на Rust для этого вызова tcgetpgrp:

fn get_process_group_id(fd: i32) -> io::Result {
    let pgid = unsafe { libc::tcgetpgrp(fd) };
    if pgid == -1 {
        Err(io::Error::last_os_error())
    } else {
        Ok(pgid)
    }
}

Я воткнул это в мою программу, чтобы она печатала идентификатор группы процессов, связанный с STDIN (дескриптор файла 0), до и после запуска процесса $SHELL. Вот что она напечатала:

process group before: 54530
shell exited with status: exit status: 0
process group after: 54571

Ну привет! Это определённо похоже на орудие убийства. Как я могу подтвердить, что именно это убивает мой Ctrl-c? Есть ли какой-нибудь способ помешать оболочке взять на себя роль лидера группы процессов? ChatGPT сказал, что я мог бы использовать перехватчик pre_exec в std::process::Command, чтобы поместить процесс оболочки в новый, отдельный сеанс процесса, что поместит его в новую группу процессов, что, в свою очередь, означает, что она не сможет стать лидером группы процессов, связанной с STDIN. Вот так:

let cmd = std::process::Command::new("/bin/zsh");
cmd.args(["-i", "-c", "/usr/bin/env"]);

// Ставит перехватчик, который будет выполняться сразу после fork, но перед exec:
unsafe {
    cmd.pre_exec(|| {
        if libc::setsid() == -1 {
            return Err(std::io::Error::last_os_error());
        }
        Ok(())
    });
}

// Выполняет команду
let output = cmd.output().unwrap();

Прямо здесь, посередине — setsid. Он вызывается сразу после того, как мы создаём новый процесс с помощью fork, но до того, как этот процесс будет преобразован в $SHELL.

APUE о том, что происходит, когда процесс вызывает setsid:


  1. Процесс становится лидером этого нового сеанса. […]
  2. Процесс становится лидером группы процессов новой группы процессов. […]
  3. Процесс не имеет управляющего терминала. […] Если перед вызовом setsid у процесса был управляющий терминал, эта ассоциация разрывается.

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

И — бум! Фейерверк! Громкие звуки! «Та-дам!» пузатой мелочи — вот что программа напечатала с помощью хука pre_exec:

process group before: 54530
shell exited with status: exit status: 0
process group after: 54530

И Ctrl-C всё ещё работал!

Орудие убийства — идентификатор группы процессов переднего плана. На этом этапе стало ясно, что происходит: порождённая оболочка берёт на себя управление терминалом, устанавливая идентификатор группы процессов переднего плана, а это означает, что сигнал, полученный в результате Ctrl-C отправляется в процесс оболочки. Но если оболочка последнейзапускает невстроенную команду, она не очищается сама, и её идентификатор процесса остаётся связанным с терминалом и приводит к обрыву всех наших Ctrl-C в пустоте.

После этого Что? возникает следующий вопрос: почему?

Почему ZSH (оболочка, с которой это произошло у меня) не сбрасывает лидера группы процессов переднего плана, когда запускает невстроенную команду?

На моей машине с Linux я запустил strace -f, чтобы посмотреть, какие системные вызовы выполняет мой процесс и, что важнее, его дочерние процессы, включая порождённую оболочку. Что я смог выяснить?

Когда zsh запускается с помощью -c и последняя команда в этой переданной команде является невстроенной, например это ls или env, ZSH [выполняет] execve в этом последнем процессе. Это означает, что для запуска ls он не создаёт дочерний процесс. Нет, вместо этого он превращается в эту [последнюю] команду: в тот момент, когда ls запускается в zsh -c 'echo lol; ls', процесс zsh исчезает и превращается в ls, и сбросить лидера группы процессов переднего плана больше некому.

Но, когда вы запускаете zsh -c '/usr/bin/env; echo lol', т. е. сначала невстроенную, а потом встроенную команды, после ZSH не пропадает. Он выполняет fork и exec с /usr/bin/env, затем echo lol и где-то там сбрасывает лидера группы процессов переднего плана.

А теперь послушайте. Мне бы хотелось здесь продолжить и закончить словами »…и вот почему ZSH делает это именно так!» и чтобы кто-нибудь наконец отправил мне через PayPal 100 долларов с сообщением «спасибо за вашу рассылку», но я должен вас разочаровать.

Я не знаю, как и почему именно ZSH делает то, что делает. Я клонировал репозиторий, скомпилировал его, попытался запустить из исходного кода, но почему-то не удалось, я много [смотрел] man cmake, а ещё папки имеют имена типа Src и Doc и кто, чёрт возьми, делает первую букву имени папки заглавной, а ещё есть ./configure, который вам нужно запустить, а затем убедиться, что он не использует вашу системную библиотеку и… Видите ли, исследование оболочки — дело непростое, и я сдался, извините.

Однако я обнаружил, что ZSH активно устанавливает идентификатор группы процессов для управления заданиями. А ещё он запоминает исходный [идентификатор] и сбрасывает его. Но я сдался, когда увидел эту часть, которая занимается контролем заданий в ZSH, и понял, что мне за это не платят.

Жду Ваших писем с пояснениями.


Автору оригинала ответили в комментариях на lobste.rs и HackerNews: zsh +m решает проблему отключением управления заданиями, а сама проблема, вероятно, вызвана недокументированной оптимизацией.



От переводчика, или Почему терминал гавкнул?

По тексту видим резкие или сленговые слова типа nerd, hell of a comment, poke, plugged и spam, которое в контексте терминала именно более сленговое, а не простое и привычное уже о спаме в почте. В оригинале сокращения текста, подчёркивающие резкость мышления автора, продиктованные эмоциями повторы в оборотах.

Автор склонен употреблять резкости и юмор в языке, а значит, bork здесь допустимо перевести именно с эмоциями, а не просто как сломался, что соответствует слову из, как оказалось, одесского словаря — гавкнуть. Слово это, впрочем, я довольно часто слышал именно в контексте сломавшейся техники и далеко не в Одессе. Но оно довольно редкое, кому-то может показаться буквализмом — плохим переводом.

Более того, в одном из словарей указывается, что bork касается рендеринга, а там уместно уже слово брешь (на экране) и первое, что приходит на ум, — брехать (да, я понимаю, что определять этимологию на слух — самый ненадёжный способ, а значит, и строить слова перевода таким образом настолько же ненадёжно (однако в плане словообразования живого языка так происходит сплошь и рядом, включая одно из значений bork).

Так или иначе, брехать резко отдаёт суржиком и более негативное, чем юморное, тогда как гавкнуть — специфичное, но не настолько просторечное.

Также вместо ботана, ботаника или умника выбрано слово нёрд, потому что есть мнение, что это слово приобрело скорее положительный смысл и имеет оттенок коллективности, что хорошо сочетается с рядом стоящим словом коллеги. Вместе с тем в английских словарях nerd остаётся boring — скучным, поэтому я говорю «есть мнение». С другой стороны, это и есть норма развития языка: сначала язык меняется — и только потом изменение отражается в словарях.

Во всяком случае, обращаясь к массам за помощью, автор явно не хотел хоть как-то и кого-то задеть, а значит, логично считать, что он употребил это слово в самом положительном смысле. Отсюда — нёрд.

© Habrahabr.ru