Терминальный просмотрщик журнала 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 на демонстрационном журнале. Сверху — весь журнал, окно следует за новыми записями (►). Слева внизу — ошибки и предупреждения: окно на паузе (‖), в нём отмечены строки, а пока оно стоит, пришло ещё 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 это за один день превратилось в полноценную историю, и постановка задачи менялась прямо по ходу:
-
Сначала — Forward по Alt-Right, как в браузере: вперёд туда, откуда пришёл Back, пока новый прыжок не отрежет путь.
-
Потом — кольцо из 16 мест. Back и Forward ходят по нему, новый прыжок отбрасывает места впереди, полное кольцо забывает самое старое.
-
Затем — каждое перемещение курсора считается шагом, клавишами или мышью. Перетаскивание — один шаг, перемещение «в никуда» — ни одного. Глубина 64 шага, основные клавиши — Ctrl-Z и Ctrl-Y.
-
И наконец переименование: это не «назад/вперёд», а 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, но вmasterTurboJedi его нет.
Что планируется, если PR примут:
-
Сдвинуть подмодуль — и изменения войдут в TurboJedi обычным обновлением.
-
Сделать
JournalSourceнаследникомTEventSource, без хака и без копирования дескриптора, а чтение журналов перенести изidle()в обычныйhandleEvent().
Если вы пользуетесь TurboJedi или хотите попробовать — релизы и install.sh здесь. Пишите в комментариях, чего не хватает: как видно по таблице выше, это лучший способ попасть в следующий релиз.
Источник: habr.com