[Перевод] Как потерять управление оболочкой… Расследование
Несколько недель назад я хакал языковые сервера в 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
возвращает идентификатор группы процессов вызывающего процесса.
Важная часть: «которые могут принимать сигналы от одного и того же терминала».
У 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
:
- Процесс становится лидером этого нового сеанса. […]
- Процесс становится лидером группы процессов новой группы процессов. […]
- Процесс не имеет управляющего терминала. […] Если перед вызовом
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 — скучным, поэтому я говорю «есть мнение». С другой стороны, это и есть норма развития языка: сначала язык меняется — и только потом изменение отражается в словарях.
Во всяком случае, обращаясь к массам за помощью, автор явно не хотел хоть как-то и кого-то задеть, а значит, логично считать, что он употребил это слово в самом положительном смысле. Отсюда — нёрд.