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<HashMap<String, String>> {
    // Получает $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<libc::pid_t> {
    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 — скучным, поэтому я говорю "есть мнение". С другой стороны, это и есть норма развития языка: сначала язык меняется — и только потом изменение отражается в словарях.


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

Комментарии (8)


  1. slonopotamus
    16.03.2024 22:16
    +3

    Неплохо было бы пояснить в предисловии, что такое вообще этот ваш их Zed.


    1. stranger777 Автор
      16.03.2024 22:16
      +1

      Была такая мысль, но расследование подразумевает погружение с первых строк, текст до ката и так отъел много места в ленте, а речь вообще про zsh — программу другого рода.

      Zed — производительный, командный редактор кода с открытыми исходниками и поддержкой запросов к Copilot или GPT-4 по Ctrl+Enter, от авторов Atom и Tree-sitter. Позиционирует себя как редактор для кодинга на скорости мысли. Интересное:

      • Встроено что-то вроде Slack.

      • Двумерное окно рендерится 3D-движком, что, по заявлению авторов, даёт эффекты, как в видео-игре: быструю, плавную и надёжную передачу пикселей.

      • Поддерживает Vim в смысле редактирования.

      И пока всё это чудо в стабильных релизах только на Маке. То есть пока очень не массовый и предисловие имело мало смысла.


  1. vadimr
    16.03.2024 22:16
    +2

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

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

    Хотя о чём я, ему ж ChatGPT даёт советы по программированию.


  1. orcy
    16.03.2024 22:16

    В какой-то момент пропадают иллюзии что все можно организовать простыми и логичными решениями. Терминалы и командная строка вроде со стороны такой простой unix-way, а сколько у него под капотом сложностей и концепций чтобы пользователю казалось что все просто.

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

    Нужное пояснение, гавкающий терминал читается конечно непривычно.


    1. stranger777 Автор
      16.03.2024 22:16

      Нужное пояснение, гавкающий терминал читается конечно непривычно.

      Как редактор я бы это либо не пропустил, либо заменил бы на вот это самое сломался с короткой сноской об оригинале, в зависимости от требований к тексту. Но я пишу на Хабр, где языковые дискуссии приветствуются, поэтому сделал так :)


      1. amishaa
        16.03.2024 22:16

        Так русское слово гавкнулся: https://ru.wiktionary.org/wiki/гавкнуться


        1. stranger777 Автор
          16.03.2024 22:16

          Если вы заглянете по ссылке за спойлером (или присмотритесь), то увидите, что там словарная статья для гавкнул(ся), то есть верны обе формы. Выбрал короткую, учитывая резкость текста, да и сам слышал только короткую


          1. amishaa
            16.03.2024 22:16
            +1

            Забавно. В "Большом толковом словаре русского языка" есть только возвратная форма: https://gramota.ru/poisk?query=гавкнуться&mode=slovari&dicts[]=42