Модель - Stable Diffusion v1-5
Модель - Stable Diffusion v1-5

Все началось с того, что я в очередной раз немного поменял структуры БД, и в некоторых SQL-запросах добавилась новая колонка. Нормальная ситуация - взять и легким движением руки сломать половину unit test’ов, потому что БДшные моки ожидают определенный текст запроса.

Хрупкость тестов раздражала меня давно, но в тот раз я был не в настроении, и решил, что хватит, пора что-то с этим сделать. Ясное дело, если я меняю запрос в функции, это ломает тесты для этой функции, но поправить пару кейсов — не проблема, надо сделать так, чтобы при это не ломались все тесты бизнес-логики, которая эту функцию вызывает. Самое очевидное — чтобы бизнес-логика вместо вызова функции с запросом дергала мок. Но как это реализовать? Передавать функции по работе с БД как параметры, через dependency injection? Таких функций много, десятки. Обернуть их в один объект? Вариант, но это уже будет сильно напоминать божественный объект. Ну и не очень хочется перелопачивать всю кодовую базу. А если попробовать как-то на лету подменить функцию моком?

Я тогда не знал такого термина, как monkey patching, и думал, что иду по непроторенной дороге. Если бы я сразу нашел gomonkey, то наверно на этом бы и остановился, или пробовал бы его и забросил (потому что мне не нравится его API). Но мне помогло мое невежество. Можно сказать, повезло.

Я стал думать, как бы реализовать свою задумку. Самое простое — сделать мок ровно с такой же сигнатурой, что и оригинальная функция, и по адресу оригинала записать JMP на мок. Раз сигнатуры совпадают, что читать параметры и возвращать значения эти функции будут одинаково, как этого требует ABI. Конечно, прямая запись машинной инструкции делает этот инструмент архитектурно-зависимым, так что я решил ограничится x86_64 и Arm64, потому что они, во-первых, наиболее распространенные, во-вторых, они мне доступны. Само собой, никакая современная OS не позволит просто так менять исполняемую страницу памяти, значит, придется менять права доступа к памяти, а это сразу добавляет еще и зависимость от операционки. Я решил начать с Linux’а и Винды, опять же просто потому, что они у меня есть. И это была моя вторая удача, потому что если бы я начал с macOS на Apple Silicon, то на нем бы с позором и закончил.

Я провел несколько экспериментов на Linux/x86-64, убедился, что концепция рабочая и можно менять права на страницу памяти и выполнять свой код, и приступил к реализации.

Мне было важно сделать простой API. Во-первых, это красиво. А во-вторых, удобно. Не буду утомлять читателей теми вариантами, которые я попробовал, но в результате я пришел вот к такому:

Override(TestingContext(t), original, Once, func(foo int) error {
    Expectation().CheckArgs(a)
    return ErrInvalid
})(42)

Тут функция original заменяется на мок, который принимает такие же аргументы и возвращает то же самое, при этом мок проверяет, что переданный ему аргумент foo имеет значение 42. В процессе тестирования выяснилось (вполне логично), что inline-функцию перекрыть таким образом нельзя, так что при тестировании обязательно указывать опцию компилятора -l, а еще не помешает -N, чтобы запретить оптимизацию вообще, так что правильная команда для тестов выглядит так: go test -gcflags="all=-l -N" <путь>

Надо сказать, что недавно завезенные в Go дженерики сильно помогли — функция Override задекларирована как func Override[T any](ctx context.Context, org T, count int, mock T) T, что позволяет на этапе компиляции убедиться, что org и mock — функции с одинаковой сигнатурой. Дополнительно Override возвращает функцию такого же типа, что позволяет сразу указать ожидаемые значения аргументов, опять же с compile-time проверкой. До этого я немного скептически относился с урезанным дженерикам в Go, но тут они меня порадовали. Хотя без небольшой ложечки дегтя все же не обошлось — сначала я предполагал, что Override будет методом объекта, но оказалось, что для методов дженерики не поддерживаются. Поэтому мне пришлось отказаться от объекта, и использовать глобальную переменную (знаю, что некошерно, но не хотелось отказываться от дженериков, они тут очень полезны), тем более что все равно такой объект, по-хорошему, может быть только один, иначе начались бы такие конфликты с конкурентными подменами одной и той же функции, что голове больно даже думать про это.

Кстати, несмотря на то, что в методы не довезли дженерики (надеюсь, что они где-то в дороге еще), это не помешало перекрывать методы с помощью Override. Более того, можно перекрывать функции и методы из других пакетов, и даже из стандартной библиотеки — приятные побочные эффекты того, что Go любит статическую линковку, и все, что надо, сидит в твоем же бинарнике, и не надо как-то специально перекрывать функции из динамических библиотек. Поэтому вполне возможен такой код:

Override(ctx, (*os.File).Read, Once, func(f *os.File, b []byte) (int, error) {
    Expectation()
    copy(b, []byte("foo"))
    return 3, nil
})

В результате при вызове Read для любого объекта типа os.File сработает мок. Кстати, интересный момент — на самом деле сигнатура метода всегда включает сам объект (method receiver) как первый параметр. Тут еще важно отметить, что мок сработает только один раз, поскольку при вызове я указал Once (что просто являеся константой со значением 1 — мне так кажется симпатичнее, и код читается как проза: "Override [function] Read once [with mock] func …"), и в тот момент, когда внутри мока будет вызван Expectation, он восстановит перекрытый Read в исходное состояние, то есть при желании после Expectation можно смело вызывать настоящий f.Read.

Too good to be true? Так и есть, есть свой недостаток. Поскольку тут делается просто JMP на мок-функцию, она выполняется в стек-фрейме оригинальной функции, то есть совсем в другом скоупе. Это значит, что если написать что-то типа

bar := 42
Override(TestingContext(t), foo, Once, func() int {
    Expectation()
    return bar
})

, то это скомпилируется, но возникнет неопределенное поведение (которого в Go быть не должно, но так как я активно использую unsafe, эта гарантия не действует), поскольку указатель стека у нас остался от оригинальной функции, и относительный адрес переменной foo указывает непонятно куда. Поэтому первым параметром для Override’а стал контекст, но это настолько привычная вешь для Go-программистов, что не должна вызывать вопросов, и если нужно что-то передать из тест-кейса в мок, то это можно сделать через контекст. Кстати, именно поэтому и появилась функция TestingContext, которая заворачивает t в контекст, чтобы t был доступен внутри мока (для не-Go программистов: t это такой тестовый handle, через который можно зарепортить ошибку, когда реальное значение отличается от ожидаемого).

Настал черед Винды. Удачно, что я все же оставил заводской раздел с Виндой — раз в месяц я гружусь в него, чтобы проверить, нет ли обновлений BIOS’а и прочего, а тут еще и реально пригодился. В результате все оказалось на удивление просто, Go’шный пакет golang.org/x/sys/windows с недавних пор реализует системый вызов VirtualProtect, поэтому все сразу заработало. Окрыленный успехом, я решил, что и Linux/Arm я сейчас быстро одолею.

Linux / Arm64

Сдул пыль со старенькой Raspberry Pi 3, поставил туда свежий Raspbian, немного почитал про ARM’овские инструкции, нашел подходящую и … какая-то фигня. То работает, то нет. Самое противное, когда проблема нерегулярная. Притом когда идешь по шагам под дебаггером — все как надо, запускаешь обычное исполнение — как повезет. Стал добавлять отладочную печать — в какой-то момент стало стабильно работать. Обрадовался, удалил отладочную печать — опять та же фигня. Если что-то долго не получается, пора перестать трясти дерево и начать думать. Такое ощущение, что это как-то связано с тем, сколько времени занимает обработка. Как будто что-то где-то не успевает обновится. Ну не может же быть, что кэш инструкций глючит? Однако, как учил нас великий мистер Холмс, если отбросить все невозможное, то оставшееся, каким невероятным бы оно ни было, и есть решение. Я нашел GCC’шный built-in, который сбрасывет кэш и … no shit, Sherlock! Кто бы мог подумать, что Arm не умеет в инвалидацию кэша! Кстати, позднее я нашел статью Apple , в которой, помимо прочего, написано:

Always call sys_icache_invalidate(_:_:) before you execute the machine instructions on a recently updated memory page. On Apple silicon, the instruction caches aren’t coherent with data caches, and unexpected results might occur if you execute instructions without invalidating the caches

Подозреваю, что общее у всех Arm’ов. Ну, родовитым британцам положено иметь наследственную болезнь.

macOS на Apple silicon

Оставался только macOS. Я договорился с другом, который пустил меня на свой Mac с M2 процом, я сказал, что буквально на один вечер, все должно быть просто, раз я собрал уже собрал все грабли с Arm64. Ну да, ну да. Настоящих граблей я еще не видел. Оказалось, что такая, казалось бы, элементарная задача, как разрешить запись на страницу памяти, в macOS на Apple silicon практически нерешаема. Точнее, нерешаема напрямую. Маленький экскурс в организация памяти в macOS-процессах: как это принято в UNIX/Linux, исполняемый код живет в сегменте памяти TEXT с правами read и execute. Чтобы что-то поменять в таком сегменте, надо для нужного куска памяти добавить write, но в macOS (как я понял, это фича ядра Mach) есть такая штука - максимально возможные права для сегмента, которые нельзя повысить (только понизить), и нельзя превысить, а у сегмента TEXT в максимальных правах нет write, значит, и установить его нельзя. Притом для macOS на x86-64 это решаемо некоторыми некрасивыми извращениями (путем изменения тестового бинарника), а с M1/2/3 — никак. Как написано в ранее упоминавшейся Apple’овской статье:

Apple silicon enables memory protection for all apps, regardless of whether they adopt the Hardened Runtime. Intel-based Mac computers enable memory protection only for apps that adopt the Hardened Runtime

Я перерыл весь Интернет, наизусть выучил несколько обcуждений на StackOverflow, перелопатил кучу issues на GitHub, которые хоть как-то напоминали мою проблему (тут-то я и напоролся на gomonkey и узнал, что то, что я пытаюсь реализовать, называется monkey patching, и не я первый это придумал - опять кто-то был раньше). И что авторы gomonkey эту проблему не решили. Ну и вроде все, на этом можно остановится, и ограничить поддержку только Linux’ом и Виндой. Но я уже зашел слишком далеко, чтобы так просто бросить. Ловушка невозвратных затрат в чистом виде.

Пару дней меня эта проблема грызла, а потом я придумал небольшой эксперимент — что, если грохнуть TEXT и создать его снова по тому же адресу, но уже с нужными мне максимальными правами? Конечно, если я грохну ту память, которую в даный момент исполняет процессор, ничего хорошего не выйдет, поэтому я решил скопировать сегмент TEXT, сделать копию исполняемой, передать на нее управление, и уже оттуда пересоздать TEXT, благо копия уже есть. Нормальная бредовая идея, которая сразу приводит к крэшу. Почему? Ага, крешится при попытке по относительному адресу прочитать данные из DATA сегмента, который идет сразу за TEXT, ну значит надо его тоже скопировать, ну и DATA_CONST заодно. И оно работает!

Но нерегулярно. Периодически вылетает. Где-то я уже это видел. Но инвалидация кэша не помогает, тем более что крэшится при пересоздании сегмента TEXT, когда кэш вообще не при чем. Тут меня, наверно, подвело то, что я слишком долго писал на C и все никак не привыкну, что «умный» компилятор еще от себя добавляет всякого рантайма, типа сборщика мусора. Где-то через день до меня дошло, что крэш может происходить не в моем треде, а в каком-то другом. Проверяю - да, так и есть. То есть сборщик мусора в другом треде исполняет себе спокойно код, почитывая инструкции из TEXT, а тут вдруг бац! и нет больше TEXT’а. Запрет сборщика через debug.SetGCPercent(-1) не помог, потому что его тред все равно регулярно просыпается только для того, чтобы узнать, что его услуги никому не нужны. Тогда я стал средствами ядра macOS останавливать все остальные треды — крэшится стало реже, но все равно проблема осталась. Я вспомил, что Go может выполнять несколько горутин в одном треде, и тогда я добавил вызов runtime.LockOSThread, чтобы Go runtime не пытался исполнять в главном треде ничего дополнительного. И вот тогда все заработало!

Кстати, на macOS 14.4 работает тоже, хотя они там что-то поменяли с защитой памяти, что вызвало проблемы для Java.

Конечно, приятно, что у меня получилось то, что не смогли авторы gomonkey. Но, как выяснилось, для ребят из Valgrind macOS на Apple Silicon тоже оказался крепким орешком. Согласно этому комменту, они только 2 недели назад смогли первый раз нормально запуститься на M1, а у меня уже 3 недели как все работает :) Конечно, я не пытаюсь сравнивать сложность маленького инструмента для unit-тестов и такого супер-продукта, как Valgrind, который спасал меня очень много раз, и сэкономил мне несколько лет жизни (а возможно, что и сохранил). Если бы я знал, что у Valgrind’а проблемы с Apple Silicon, то наверно даже и не стал бы пытаться — второй раз мне помогло невежество.

Выводы

  • Не обязательно адаптировать кодовую базу для удобства тестирования — можно адаптировать тесты, и это кажется более правильным и логичным. Я стал использовать этот тул для unit test’ов своего рабочего проекта, и смог значительно снизить хрупкость тестов и увеличить тестовое покрытие

  • При желании всегда можно найти, чем упороться

  • У кого-то в Купертино паранойа (я не говорю, что это плохо), но даже и в этом случае можно что-то придумать

  • в некоторых случаях незнание помогает не боятся задачи. Прямо как в известном анекдоте

  • Ловушка невозвратных затрат не всегда ловушка. Ищите и обрящете!

Ну и линк на проект, само собой. И, как водится, подписывайтесь на канал, жмите колокольчик, ставьте звездочки репе.

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


  1. vkomp
    19.03.2024 06:52
    +2

    Все началось с того, что я в очередной раз немного поменял...

    Лучшее начало статьи - сохраню себе в памятку! очень открывательная строка. Уверен, что с этого начинаются все увлекательные приключения!.. Пардонюсь за оффтопик, тесты еще надкусываю, тут две статьи подряд погрызть.


  1. xakep666
    19.03.2024 06:52

    Чтобы не мучаться с перезаписью кода в памяти уже работающего приложения, я попробовал другой метод: сделать копию бинарника, в котором тело (даже не всё, хватило самого начала) "оригинальной" функции поменять на JMP в ту, на которую подменяем и запустить уже его с дополнительной переменной. Это делается существенно проще, однако теряется возможность вызвать оригинал. Даже библиотеку написал под это.


    1. qrdl Автор
      19.03.2024 06:52

      Для моей задачи это не годится. Во-первых, это лишает возможности тестировать в один шаг, например, прямо из VS Code - надо сначала вызвать go test -c, пропатчить бинарник, и потом его запустить. Во-вторых, тогда надо сразу все пропатчить, и тогда нет возможности контролировать правильный порядок вызова моков.


      1. xakep666
        19.03.2024 06:52

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


  1. AndreW_W
    19.03.2024 06:52

    Почему бы не сделать интеграционные тесты вместо моков БД? Это немного дольше в плане прогона тестов. Однако вы не будете ловить таких проблем при миграциях и сэкономите много человеческого ресурса, т.к. не придется делать/менять моки. Можно запустить докер контейнер с нужной БД, если нет желания ставить в систему. CI докер-контейнеры тоже умеет запускать


    1. qrdl Автор
      19.03.2024 06:52

      Интеграционные тесты - не замена unit-тестам, это разные инструменты. Unit-тесты позволяют быстро убедиться, что новая функциональность ничего не сломала, они отрабатывают быстро и не требуют никакой особой среды, у меня это уже на уровне рефлексов - поменял что-то, запустил unit-тесты. И с помощью unit-тестов намного легче тестировать какие-то ненормальные ситуации, которые при нормальной эксплуатации случаться не должны. По поводу ресурса - для интеграционного тестирования тоже надо писать кейсы, и не факт, что они будут проще моков, особенно когда надо проверить какие-то вещи с параллельной обработкой