TurboJedi: три недели спустя

Терминальный просмотрщик журнала systemd от первого коммита до версии 0.13.1: весь код пишет Claude, а автор ставит задачи и читает

Это продолжение статьи «Turbo Vision спустя 36 лет: нормальный интерфейс для journald».

Три недели назад, 23 октября, в репозитории cyanide-burnout/TurboJedi появился первый коммит: «TurboJedi: Turbo Vision viewer of the systemd journal», чуть меньше пяти тысяч строк. Уже на следующий день вышла 0.1: окна журнала с фильтрами, уровнями и колонками, подсветка по правилам grc, переход ко времени. Название расшифровывается как Turbo Journald Interface, J[e]DI.

С тех пор набралось почти сто коммитов и два десятка тегов, последняя версия — 0.13.1. В Source/ сейчас больше десяти тысяч строк C++, на GitHub — четыре десятка звёзд. Каждый релиз собирается в 23 пакета под двенадцать версий дистрибутивов. Ещё четыре pull request ушли в сам Turbo Vision.

TurboJedi 0.13.1: три окна одного журналаTurboJedi 0.13.1: три окна одного журнала

TurboJedi 0.13.1 на демонстрационном журнале. Сверху — весь журнал, окно следует за новыми записями (►). Слева внизу — ошибки и предупреждения: окно на паузе (‖), в нём отмечены строки, а пока оно стоит, пришло ещё 7 записей (+7). Справа — таймлайн тех же уровней, на нём хорошо виден всплеск таймаутов.

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

Эксперимент: целый проект без моих правок в коде

Главное, что надо знать про TurboJedi: это эксперимент, в котором реализация целиком отдана Claude, а архитектура и продуктовые решения остались за мной.

Раньше я тоже работал с агентами, но по-другому. Архитектуру и код я держал в своих руках, а агентам отдавал куски: дописать функцию, исправить ошибку. Код оставался моим, агент работал внутри него.

Здесь схема обратная. Я ставлю задачу целиком: что должно получиться и как это должен видеть пользователь. Код я читаю, но не правлю. Если что-то не так, я не лезу в исходник, а формулирую замечание или новую задачу, и правку делает Claude.

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

Кто что делает

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

Claude пишет код и тестирует его. Тесты по большей части не лежат в репозитории. Это одноразовые харнессы на Python: они запускают TurboJedi в псевдотерминале, рисуют экран через pyte и нажимают клавиши. Сборки идут в docker-образах Debian на отдельной amd64-машине, а то, что касается macOS, проверяется на Mac.

Codex и ChatGPT заняты двумя вещами. Первая — валидация: они смотрят коммиты и комментируют код, фичи и решения. Пример из последних дней: диалог «проверять ли обновления при старте» предложила другая модель, она же подтвердила, что выпускать его надо как 0.13.1, а не копить к 0.14.

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

Правила для агентов лежат в AGENTS.md, а CLAUDE.md просто подключает его одной строкой. Там описано, как делается релиз и как пишутся коммиты: одна строка «Область: что это теперь делает, как это видит пользователь», без трейлеров соавторов. Поэтому история читается как список изменений для пользователя, а не как дневник «fix», «fix2», «wip».

Три недели в релизах

У каждой минорной версии своя тема, а патч-релизы доделывают тему предыдущей. Так написано в AGENTS.md, и с 0.4 это видно по CHANGELOG.md:

Версия

Дата

Тема

Кого благодарим

0.1

24.10

Просмотрщик журнала systemd на Turbo Vision

—

0.2–0.3

24.10

Пакеты: Debian, Ubuntu, Fedora, RHEL, Arch

vlad4kr7, Apoheliy

0.4

25.10

antonchev

0.4.1

25.10

Поля журнала целиком, предел буфера в байтах

opium

0.5

25.10

Таймлайн по Alt-T

TIEugene

0.5.2

26.10

Мышь в консоли Linux через GPM

issue #1 от qrp73

0.6

26.10

Буфер обмена через команду пользователя

—

0.7

27.10

Клоны окон и install.sh

Tuxozaur, konkere

0.8

28.10

Удобство и экономия: inotify вместо опроса

—

0.9

30.10

Отображение: escape-последовательности в сообщениях

—

0.10–0.10.3

04.11

Обновления: проверка релизов, пакеты знают свой дистрибутив

icelord

0.11

05.11

Распространение и удалённые журналы (sshfs, NFS)

—

0.11.1

07.11

Курсор строки

select26

0.12

07.11

Выделения: отметки строк, состояние окна в заголовке

—

0.13

08.11

Навигация и порядок окна

icelord

0.13.1

10.11

Вопрос о проверке обновлений при первом старте

—

Почти все ники, кроме icelord и qrp73, — с Хабра. Благодарности стоят прямо в CHANGELOG.md, а значит, и в заметках релизов на GitHub. Первые дни проект фактически писался по комментариям к первой статье. Самый плотный день — 7 ноября: почти двадцать коммитов и два релиза, 0.11.1 и 0.12.

Ожидание событий: от 50 пробуждений в секунду к PR в Turbo Vision

Turbo Vision по умолчанию ждёт ввода не дольше eventTimeoutMs = 20 мс. Получается, что программа просыпается 50 раз в секунду, даже когда в журнал никто не пишет. Для утилиты, которую держат открытой в tmux часами, это глупо.

В libsystemd есть sd_journal_get_fd() — дескриптор inotify, который становится читаемым, когда журнал меняется. Идея простая: пусть Turbo Vision ждёт и его. В 0.8 так и сделали. Теперь ожидание длится до следующей секунды на часах, а новые записи будят программу сами.

Простая идея упёрлась в две стены.

Первая: Turbo Vision не даёт добавить свой источник событий. У него есть EventWaiter с addSource(), но он лежит приватным полем внутри синглтона Platform. Обходной путь — классический трюк с явной инстанциацией шаблона: при ней проверка доступа к членам не выполняется. Так это и живёт в Source/JournalSource.cpp:

// Turbo Vision keeps its event sources to itself, the rules of access are not checked in an explicit instantiationtemplate struct WaiterAccess{ friend tvision::EventWaiter& GetEventWaiter() { return tvision::Platform::getInstance().*Member; }};template struct WaiterAccess;

Вторая: select() и FD_SETSIZE. Turbo Vision ждал через select(), а он молча пропускает дескрипторы с номером от 1024. Журнал держит по дескриптору на каждый файл и создаёт inotify уже после них. На машине с большим архивом журналов номер может уйти за предел. Пришлось копировать дескриптор через F_DUPFD_CLOEXEC: копия получает наименьший свободный номер и становится читаемой вместе с оригиналом. Если и копия не влезает, окно опрашивает журнал по таймауту.

Хак работает, но на нём не стоит держать проект. Поэтому 29 октября ушли три PR в magiblot/tvision:

  • #244 — evMouseAuto, пока кнопка мыши зажата и ввода нет. Это тоже нашлось в TurboJedi: Turbo Vision повторял зажатую кнопку только вместе с вводом от терминала, а терминал молчит, пока мышь стоит на месте.

  • #245 — poll() вместо select() везде, кроме macOS.

  • #246 — TEventPoll, заменяемая функция ожидания: приложение со своим циклом событий (GLib, libuv, io_uring) может ждать дескрипторы Turbo Vision у себя.

Самое интересное случилось при доработке #245 для macOS. poll() на macOS не работает с терминальными устройствами, поэтому там остаётся select(). Расширить его можно макросом DARWINUNLIMITED_SELECT — тогда набор дескрипторов собирается из нескольких fd_set. Тест с 1100 унаследованными дескрипторами показал: перерисовка после изменения размера работает, но после первого же нажатия клавиши программа уходит в холостой цикл и греет процессор.

Причина нашлась не в Turbo Vision, а в ncurses. На macOS он ждёт ввода своим собственным select() с одним fd_set и не может прочитать tty с номером за 1024. Turbo Vision честно сообщает «есть ввод», ncurses честно отвечает «ничего нет», и так по кругу. Исправление получилось на стороне Turbo Vision: открытый им /dev/tty на macOS переносится ниже FD_SETSIZE, а если места внизу нет, используются stdin и stdout.

Итог прогонов tvedit с 1100 унаследованными дескрипторами:

F3 (диалог)

Перерисовка после resize

CPU после F3

Debian, upstream

нет

нет

0,02 с

Debian, #245 и #246

да

24 мс

0,01 с

macOS, upstream

нет

нет

0,00 с

macOS, только unlimited select

нет

25 мс

2,02 с

macOS, #245 и #246

да

26 мс

0,00 с

А 12 ноября ушёл #250 — публичный класс TEventSource, который делает хак ненужным. Важное уточнение по ходу: я хотел не просто «разбудить цикл, а дальше разберёмся в idle()», а нормальное событие, которое приходит в handleEvent():

class TEventSource{public: TEventSource( int fd ) noexcept; // starts waiting for 'fd', which stays owned by the caller virtual ~TEventSource(); // stops waiting for it // Called when 'fd' is readable. True hands 'event' over to the application // (an 'evNothing' one only ends the wait), False goes on waiting. virtual Boolean getEvent( TEvent &event ) noexcept = 0; const int fd;};

TurboJedi, собранный на этой ветке, показывает новую запись журнала за считанные миллисекунды, как и с хаком, и в простое не тратит CPU. Но в master это попадёт только после того, как PR примут в upstream.

Чужие журналы по sshfs и NFS: SIGBUS вместо данных

Тема 0.11 — журналы других машин, смонтированные по сети. Звучит как «просто укажите каталог», а на деле это три отдельные проблемы, и все они вылезли за один день.

Нет inotify. На сетевой файловой системе и на FUSE изменения не приходят. Про NFS libsystemd знает, про sshfs — нет. Такой журнал приходится читать при каждом опросе.

Отображение в память не видит изменений. libsystemd читает файлы журнала через mmap. FUSE подтягивает новые данные в отображение, только когда заглядывает в атрибуты файла, а NFS — только когда файл читают обычным образом. Поэтому TurboJedi сам смотрит на файлы, в которые идёт запись (а для NFS открывает и читает их), чтобы ядро подтянуло изменения. Ротацию без inotify определяет по набору имён: архивный файл получает @ в имени и больше не меняется.

SIGBUS после переподключения. Когда sshfs переподключается, отображённый файл под открытым журналом «портится», и обращение к странице даёт SIGBUS — то есть падение. Решение: обработчик SIGBUS подставляет на место такой страницы анонимную страницу нулей, а libsystemd читает её как повреждённую запись. Сам systemd так же переживает обрезанный файл журнала.

Ключевое здесь — не подменять чужие ошибки. Нули подставляются, только если адрес принадлежит отображению файла *.journal по /proc/self/maps. Код читает этот файл прямо в обработчике сигнала, поэтому обходится open(), read(), close() и функциями строк — без выделения памяти. Все остальные SIGBUS уходят прежнему обработчику, то есть Turbo Vision, который вернёт терминал в нормальное состояние перед аварийным завершением:

// Only a page of a mapped file of a journal is replaced, any other fault is not ours to hideif ((information->si_code == BUS_ADRERR) && (IsJournalMapping((uintptr_t)information->si_addr)) && (mmap(address, page, PROT_READ, MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0) != MAP_FAILED)){ // The access made again reads zeros, the way systemd itself survives a journal file cut short faults = faults + 1; return;}

После этого «ослепшие» окна открывают журнал заново и продолжают с последней прочитанной строки.

Требования, которые уточнялись на ходу

Самое честное, что можно сказать про эти три недели: почти каждая заметная функция прошла через два-три варианта поведения, иногда за один вечер.

Навигация: от «назад» до отмены перемещений за один день

С 0.1 у окна было «назад туда, где было окно до прыжка». Перед 0.13 это за один день превратилось в полноценную историю, и постановка задачи менялась прямо по ходу:

  1. Сначала — Forward по Alt-Right, как в браузере: вперёд туда, откуда пришёл Back, пока новый прыжок не отрежет путь.

  2. Потом — кольцо из 16 мест. Back и Forward ходят по нему, новый прыжок отбрасывает места впереди, полное кольцо забывает самое старое.

  3. Затем — каждое перемещение курсора считается шагом, клавишами или мышью. Перетаскивание — один шаг, перемещение «в никуда» — ни одного. Глубина 64 шага, основные клавиши — Ctrl-Z и Ctrl-Y.

  4. И наконец переименование: это не «назад/вперёд», а Undo navigation / Redo navigation. Отменяются перемещения окна, но не отметки и не строки.

Последний шаг выглядит косметикой, но это и есть уточнение требования. Пока функция называлась «назад», пользователь ждал браузерной логики. Когда шагом стало любое перемещение курсора, правильная модель — отмена, и название должно было это сказать.

Отметки строк, которым мешает macOS

0.11.1 добавила курсор строки по просьбе select26. Следом встал вопрос выделения. Shift+стрелки — очевидный ответ, но Terminal на macOS забирает Shift себе. Отсюда вместо Shift появилась клавиша M (или Insert): штрих от курсора отмечает строки. Дальше в тот же день поведение уточнялось ещё трижды:

  • отметки строк не подряд: несколько штрихов, повторный M снимает отметку, Ctrl+клик отмечает одну строку;

  • клик ставит курсор, а отметки остаются, сбрасывает их только Esc; штрих, начатый на отмеченной строке, снимает отметки;

  • внутри штриха клик и перетаскивание продолжают его так же, как клавиши.

Окно на паузе, которое не теряет строк

Если окно стоит на паузе, а журнал продолжает расти, буфер окна переполняется. Это правили три раза:

  • сначала строки на экране хотя бы перестали ползти вверх при каждом выброшенном старом;

  • через неделю с лишним полное окно на паузе вообще держит свои строки и отметки, а новые записи только считаются и дочитываются после последней строки, когда окно снова следует за журналом. В строке статуса — FULL;

  • и почти сразу уточнение: окно «держит» не тогда, когда оно полное, а когда строки очередной записи не помещаются целиком. Иначе многострочное сообщение выталкивало старые строки.

Заодно заголовок окна стал показывать состояние: ► следует, ‖ на паузе, ‖ +N — пришло N записей. В 0.13 эти знаки стали кнопками.

Проверка обновлений: три версии одного вопроса

  • 0.10: Help → Check for updates спрашивает GitHub о последнем релизе, только когда пользователь сам выбрал пункт.

  • 0.10.2, по просьбе icelord: проверка при старте раз в день, и включается она в ответе самой проверки. Новый релиз показывается один раз.

  • 0.13.1: включить проверку при старте можно было только из ответа ручной проверки. Теперь при первом старте программа один раз спрашивает Yes/No, Esc считается за No, ответ сохраняется в update.ini. Тех, кто обновился со старой версии без этого файла, тоже спрашивают.

Отдельная история — пакеты, которые знают свой дистрибутив. После обновления системы, скажем, с Debian 12 на 13, install.sh ставит пакет того же релиза, собранный под новую систему, а проверка обновлений об этом сообщает. Для этого C++ и shell должны одинаково называть дистрибутив, и в коде прямо написано: «13.1 is 13, so is 13/sid of a testing Debian».

Мелочи, на которые ушло больше всего времени

Каждая из этих проблем исправляется небольшой правкой, но найти её было сложнее, чем написать:

  • Пустой фильтр в libsystemd. Окно для идентификатора, который ещё ничего не написал, ставилось на конец журнала. После «голого» хвоста libsystemd не находит ничего и тогда, когда записи появляются. Решение: при отсутствии записей фильтра журнал ставится в начало, и первая новая запись приходит как следующая.

  • sudo и локаль. Под sudo локаль пропадает, остаётся «C», которая знает только ASCII. Turbo Vision спрашивает ширину символа у библиотеки C и рисует незнакомые символы как U+FFFD, а сообщения журнала — UTF-8. TurboJedi включает UTF-8 для символов, если в локали его нет.

  • tmux и заголовок окна. Turbo Vision при старте проверяет, не far2l ли это, а tmux принимает эту проверку за установку заголовка. TurboJedi ставит свой заголовок после неё, а на выходе возвращает прежний — и уже после того, как статические объекты Turbo Vision выключат свои расширения.

  • Exit с открытым меню зависал. Строка статуса создаёт команду, диалог её игнорирует, а меню берёт любую команду на закрытие и передаёт дальше. В итоге команда ходила по кругу бесконечно.

  • Щели в таймлайне. Символ «полный блок» в шрифтах с высокими строками не заполняет ячейку целиком. Полные ячейки теперь рисуются фоном.

  • Escape-последовательности в сообщениях. Программы пишут в журнал цвета терминала. Теперь цвета SGR, включая 256 цветов и RGB, показываются под правилами подсветки, а остальные последовательности (OSC, заголовки, ссылки) вырезаются, а не рисуются мусорными глифами.

  • Буфер обмена через команду пользователя. Копирование идёт в команду пользователя на stdin. Команда запускается в своей группе процессов, чтобы таймаут убил всё, что она запустила, а её ранний выход не уронил TurboJedi через SIGPIPE.

Пакеты: двенадцать дистрибутивов и один скрипт

Пакет для Debian был уже в 0.1, а в тот же день 0.2 добавила сборку пакетов для Debian, Ubuntu, Fedora и RHEL через GitHub Actions. Сейчас каждый релиз собирается GitHub Actions в 23 файла:

  • deb: Debian 12 и 13, Ubuntu 22.04, 24.04 и 26.04;

  • rpm: Fedora 44, EL9, EL10, Amazon Linux 2023, openSUSE Tumbleweed и Leap 16;

  • всё это на amd64 и arm64, плюс Arch Linux на x86_64.

Пакеты Debian 12 подходят и для Raspberry Pi OS bookworm.

install.sh ставит пакет последнего релиза под нужный дистрибутив и архитектуру. Он ничего не скачивает, если эта версия уже стоит, а если закрепить более старый релиз, откатывается на него. Пакетных репозиториев (AUR, OBS, COPR, PPA) нет и пока не планируется: релизы, install.sh и встроенная проверка обновлений закрывают задачу.

Ещё одна деталь из 0.11: адрес репозитория в About, --help и проверке обновлений берётся из origin клона, а в коде не зашит ни один хост. Форк, собранный из своего клона, показывает ссылки на себя и проверяет свои релизы.

Как это выглядит изнутри

Несколько наблюдений о работе в такой связке, без рекламы:

  • Поправка — это текст, а не правка кода. Раз я не трогаю исходник, каждое «не так» превращается в новую формулировку задачи. Поэтому уточнения требований видны в истории отдельными коммитами: та же навигация — это несколько формулировок за один день, а не одна большая правка.

  • Тема релиза держит рамку. Правило «у минорной версии одна тема» не даёт превратить версию в свалку. Если идея не про тему, она ждёт следующую.

  • Сообщение коммита — это уже changelog. Коммиты пишутся как «что теперь видит пользователь», поэтому раздел CHANGELOG.md собирается из них почти механически. А заметки релиза GitHub Actions берёт из раздела по тегу.

  • Тест — это то, что видит человек. Большая часть проверок — не unit-тесты, а псевдотерминал, клавиши и экран: появилась ли запись, что написано в заголовке, не греет ли программа процессор в простое. Такие проблемы, как холостой цикл ncurses, повтор мыши или зависание Exit, unit-тестом не поймать.

  • Решение остаётся за человеком. Модель охотно предлагает варианты, другая модель охотно их критикует. Но что будет называться «Undo navigation», нужен ли второй вопрос при старте и как назвать версию, решаю я. Иногда через полчаса после того, как решил по-другому.

Что дальше

Темы у 0.14 пока нет. Была мысль добавить чтение журнала по HTTP, но острой нужды в нём не видно: журналы других машин и так открываются через sshfs, NFS или просто каталог, и этого хватает для всех задач, что встречались до сих пор.

Что уже сделано:

  • PR в Turbo Vision #244, #245, #246 и #250 отправлены и ждут решения сопровождающих.

  • Подмодуль tvision с 0.13 стоит на голове upstream, и make пересобирает его после сдвига подмодуля.

  • JournalSource на TEventSource собран и проверен с Turbo Vision из ветки #250, но в master TurboJedi его нет.

Что планируется, если PR примут:

  • Сдвинуть подмодуль — и изменения войдут в TurboJedi обычным обновлением.

  • Сделать JournalSource наследником TEventSource, без хака и без копирования дескриптора, а чтение журналов перенести из idle() в обычный handleEvent().

Если вы пользуетесь TurboJedi или хотите попробовать — релизы и install.sh здесь. Пишите в комментариях, чего не хватает: как видно по таблице выше, это лучший способ попасть в следующий релиз.

Источник: habr.com

0 0 голоса
Рейтинг новости
1
0
Подписаться
Уведомить о
0 комментариев