Я гоняю на нагрузочных тестах Rewind VM, детерминированную виртуальную машину: вычищаю шероховатости и баги и сам проверяю на себе, удобно ли ей пользоваться (тот самый dogfooding). Хотя я её и написал, меня до сих пор поражает, на какие фокусы она способна.
Но при всей этой магии, при том, как легко объявить баг «детерминированно воспроизведённым» и сузить его до одного-единственного шага, самым трудным остаётся прочитать код и понять, почему всё сломалось. Rewind может сказать, что пошло не так, но код за вас она не прочитает.
Именно здесь ИИ может по-настоящему помочь. Агенты уже умеют читать код и нередко находят в нём ошибки. Неудивительно, что если дать им много надёжных данных о том, что пошло не так, они указывают на баг гораздо быстрее, чем при чтении одного только кода. Детерминированные прогоны вместе с ИИ-агентами — очень мощное сочетание.
Находить баги с Rewind легко. rewind check раз за разом прогоняет тест, скрипт или бинарник при разных расписаниях потоков, пока один из прогонов не упадёт, а потом сужает падение до одного шага. Но на руках у вас остаётся номер шага и, возможно, большая кодовая база, с которой вы не знакомы. Трудная часть — прочитать код и понять, почему он упал.

Rewind не прочитает код за вас. Зато она даёт вам или агенту массу сведений о том, что пошло не так. Я натравил Rewind VM на целую кучу инструментов, и, как и следовало ожидать, она нашла баги… очень много багов. И самое приятное, что нашла она их так, что их можно воспроизвести и отладить, — поэтому агенты без труда предложили и исправления. Все найденные баги собраны на отдельной странице.
Кстати: если вы впервые слышите о Rewind, начните с постов a flaky build you only catch once и dafuq is a deterministic VM?
Ниже — небольшой пример бага, который Rewind нашла в git, и того, как я с её помощью добрался до причины и до строки кода, которая её вызвала. Баг живёт в git notes — команде, которая прикрепляет к коммиту заметку, не меняя сам коммит. Суть такая: если одновременно добавить две заметки к двум разным коммитам, одна из них может потеряться. Я отправил патч в апстрим.
Баг
git notes прикрепляет к коммиту заметку, не меняя сам коммит.
Начнём с идеи простого теста: создать две заметки на двух разных коммитах одновременно. (Тест показывает состояние гонки в git notes при параллельном добавлении заметок; его легко собрать как Nix-деривацию через runCommand.)
git notes add -m "note 1" HEAD~0 & git notes add -m "note 2" HEAD~1 & wait # обе команды завершились с кодом 0, значит, обе заметки должны быть на месте git notes show HEAD~0 && git notes show HEAD~1
$ rewind check --all --schedules 32 --property exit-zero .#notes2 schedule 1: exited:1 3167 steps run e5e5a1ecd0195f8f violates exit-zero schedule 2: exited:1 3312 steps run ea6889e56564e374 violates exit-zero ... 15 of 32 other schedules violated a property step 2682 decides it: a request to switch threads there makes the run violate exit-zero
Под Rewind в пятнадцати расписаниях из тридцати двух одна заметка теряется, хотя каждый git notes add завершается успешно. На обычной Linux-машине заметка теряется в 25 запусках из 30, так что баг не специфичен для Rewind.
И что теперь?
Спрашивайте у прогонов
Прогон в Rewind — это полная трасса сборки: каждый процесс, каждый записанный им файл и содержимое любого файла на любом шаге. Её можно перемотать в любую точку. Это очень много данных. Rewind немного упрощает работу с ними: есть команды, которые помогают сузить круг подозреваемых. Три вопроса — три команды:
что в итоге оказалось по-разному? —
rewind diff;кто это записал и что он перед этим прочитал? —
rewind blame;какая строка это записала? —
rewind where.
Ни одна из этих трёх команд ничего не знает ни о git, ни о блокировках, ни о гонках. Они работают с любой программой.
Что оказалось по-разному?
rewind diff --outputs сравнивает файлы, которые оставили после себя два прогона. Это как diffoscope, только на стероидах: сравнивать можно любые два прогона, причём файлы на любом шаге, включая промежуточные. О том, как Rewind помогает сделать сборку побитово воспроизводимой, у меня есть пост подлиннее.
$ rewind diff --outputs 94c59fdf987c4944 ad0de5aec5a0be2d 3 of the 30 files the runs left behind differ: /build/r/.git/logs/refs/notes/commits: 36 of 465 bytes differ, first at byte 351 /build/r/.git/refs/notes/commits: 36 of 41 bytes differ, first at byte 0 /nix/store/014fz1lj7hmk5h3rzcaw0w4576md3bg9-git-notes2: only in the right run
Зацепка вот в чём: .git/refs/notes/commits — это ссылка на последний коммит с заметками, и в двух прогонах она указывает на разные коммиты.
Кто это записал и что он прочитал?
rewind blame принимает два прогона и файл:
$ rewind blame 94c59fdf987c4944 ad0de5aec5a0be2d /build/r/.git/refs/notes/commits commits differs at byte 0. git notes (pid 56) wrote that byte at step 2,751 in run 94c59fdf and at step 2,717 in run ad0de5ae. ... Likely cause: git notes (pid 56) read /build/r/.git/refs/notes/commits before git notes (pid 55) changed it at step 2,721 in run 94c59fdf, and after that change in run ad0de5ae. It read an older version of the file in run 94c59fdf.
В этом примере Rewind дала нам почти неопровержимую улику. Второй git notes add прочитал заметки до того, как первый сохранил свои, а потом записал свою версию поверх: классическая гонка «прочитать — изменить — записать».
Этот самый шаг можно посмотреть в графическом интерфейсе Rewind. На решающем шаге оба процесса git notes add живы (верхняя рамка), а первый сохранился наполовину: lock-файл он уже записал, но ещё не переименовал на место (нижняя рамка).

Какая строка это записала?
Если есть отладочные символы (Nixpkgs их предоставляет), rewind where покажет, в каком месте собственного кода git находился процесс на заданном шаге. Вот pid 56 в момент, когда он записывал ссылку:
$ rewind where 94c59fdf987c4944 2754 --pid 56 --frames 8 process 56 (git), thread 56, at step 2754 #1 rename_tempfile (tempfile.c:347) called from #2 commit_lock_file_to (/build/source/lockfile.h:364) called from #3 commit_lock_file (lockfile.c:358) called from #4 commit_ref (refs/files-backend.c:1889) called from #5 files_transaction_finish (refs/files-backend.c:3381) called from #6 ref_transaction_commit (refs.c:2806) called from #7 refs_update_ref (refs.c:1592) called from #8 commit_notes (notes-utils.c:64)
Дальше можно открыть код на конкретной строке и попытаться понять, почему всё сломалось. В нашем случае notes-utils.c:64 обновляет ссылку, не проверяя под блокировкой, что она всё ещё указывает туда же, куда указывала, когда команда загружала заметки.
Три вопроса, три команды — и прочитать мне пришлось одну строку git.
Кто всё это будет читать?
Падение git notes — это 2936 шагов и 1937 событий в одном прогоне, а rewind check сделал 33 прогона. Разобрать один прогон — уже большая работа, а все тридцать три — нереально.
Недавно знакомый рассказал мне, что ИИ здорово помогает разбирать трафик CAN-шины автомобиля. CAN-шина — это непрерывный поток сообщений от каждого датчика, кнопки, переключателя и лампочки машины. С LLM управляться с этим потоком стало проще.
То же самое я вижу и в Rewind. Прогон — такой же поток событий, и LLM тоже помогают его разбирать и находить баги. Просить LLM расследовать баг с помощью Rewind оказалось очень эффективно. Особенно помогает, что баг сужен до одного шага и конкретных строк исходников: контекст остаётся маленьким и сфокусированным. На этом этапе LLM может прочитать код, понять, что пошло не так, и, скорее всего, предложить исправление.
В LLM изначально заложено много случайности. Я заметил, что в паре с более детерминированными технологиями вроде Nix и Rewind они работают лучше. Сочетание детерминированных прогонов и LLM для поиска и исправления багов в больших кодовых базах вызывает у меня большой оптимизм.
Многие такие баги вносят сами LLM, но теперь они же умеют их и исправлять.