Уроборос

Записывает, как код на самом деле исполнялся: вызовы, доводы, результаты, исключения, длительности

View the Project on GitHub digitable-lol/ouroboros

Границы

Эта страница важнее остальных. Инструмент, у которого границы не названы, опаснее отсутствующего: им начинают пользоваться там, где он врёт.

Главное ограничение

Записи фиксируют, как код себя вёл, а не как он должен себя вести.

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

Отсюда следствие, которое стоит проговорить: выводы, выращенные из записей, наследуют ошибки программы как закон. Было в программе округление не туда — оно станет утверждением «так и надо», и проверяться будет уже оно.

Чего записи не видят по устройству

Того, что не выполнялось. Ветвь, в которую не зашли, записей не даст — и не пожалуется. Записи описывают прогон, а не программу. Это самая недооценённая из границ: отсутствие записей читается как «тут всё спокойно», а значит оно «тут никто не был».

Намерения. Функция вернула -1 — это код ошибки или настоящий ответ? В записи этого нет.

Вызовов через функцию-значение в Go. func(x int) int { ... }, записанная в переменную или переданная доводом, не обмазывается, и её вызовы в записях не появятся вовсе. Решение то же, что и пропуск lambda у Python, но разница для читателя настоящая: у Python вложенная def обмазывается и в записи видна (outer.<locals>.inner), а в Go такой же по смыслу код молчит. Обмазываются только объявленные функции и методы. Отличить «не звали» от «звали, но не записано» по одной записи нельзя — а это ровно та подмена, о которой первый пункт выше.

Смысла в порядке вызовов. Записывается время входа (t), номер вызова (id), поток (th) и длительность (d) — то есть когда и где вызов был, восстановить можно. А вот что он был после другого и потому дал такой ответ — не записывается ничем.

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

Значений длиннее 200 знаков. Длинное значение обрезается коротким изображением значения (у Python — reprlib с maxstring=maxother=200, ouroboros/runtime.py:53). Хвост не восстанавливается. Списки, словари и наборы к тому же обрезаются по числу элементов — первые десять.

Имён позиционных доводов — ни у одного из восьми языков. В поле a везде одни значения: 2, 3. У C, C++ и Elixir подпись при обмазке разобрана и имена известны, и раньше эти три писали в a строку a=2, b=3 — но тогда поле значило у трёх языков одно, а у двух другое, и сверить запись одного языка с записью другого было нельзя. Сейчас имён нет нигде, и по одной только записи их не восстановить. Разбор — на странице Языки.

Номера ядра — ни у одного из восьми языков. Поле ci в обычной программе всегда -1, в разборе — null. У Python потому, что функции os.sched_getcpu в CPython нет вовсе; у остальных — потому, что переносимого способа узнать ядро в их средах нет. Настоящий номер бывает только в сборке C внутри ядра операционной системы. Метка потока th есть везде.

Чего инструмент не умеет делать

Не «пока не умеет», а не умеет — команды такой нет.

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

Про разрежение стоит сказать отдельно, потому что его часто ждут: разрежённой записи — «раз в минуту одна строка с глубиной вызова» — здесь нет. Пишется по две строки на каждый обмазанный вызов, и всё.

Что уезжает в чистовик

Проверено прогоном. finish копирует черновик в чистовик, оставляя позади то, что заново делает машина: .git, debug.info, кэши инструментов, собранные двоичные файлы, объектные файлы и слепки памяти после аварии (ouroboros/sandbox/sync.py:29-82).

Значит:

Слабое место правила, о котором надо знать

Отличить итог работы компилятора от файла, который программу просили сделать, нельзя: и то и другое произведено машиной, и то и другое вернётся, если запустить её снова. Признака, который бы их разделил, у finish нет.

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

Отсюда единственная защита: finish не молчит. Каждый отброшенный файл назван в skipped с причиной, и забрать нужное руками — ваша работа. Если вы не смотрите в этот список, вы узнаете о пропаже позже и труднее.

Разделение черновика и чистовика не защищает от того, что обмазка уйдёт наружу. Смотрите глазами, что уезжает.

Где обмазка меняет поведение программы

Это главная незакрытая слабость инструмента, и обойти её молчанием нельзя.

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

Проверено и починено

Все случаи, которые здесь раньше числились сломанными, исправлены и закрыты проверками. Проверено прогоном на этом дереве.

JavaScript терял строгий режим. Строка ввоза помощника вставлялась выше директивы "use strict", а директива, переставшая быть первой, переставала действовать: программа продолжала работать и работала иначе, без ошибки и без предупреждения. Сейчас на той же программе:

до обмазки:    THREW: ReferenceError
после обмазки: THREW: ReferenceError

C++ не собирался на возврате списком в скобках. return {1, 2, 3}; превращался в return _ouro::capture(__ouro, ({1, 2, 3}));, чего компилятор не принимает. Сейчас такой возврат собирается и запускается.

Python и from __future__. Строка ввоза вставлялась выше from __future__ import annotations, и файл переставал разбираться вовсе (SyntaxError: from __future__ imports must occur at the beginning of the file). При этом wrap-file отвечал "ok": true.

Python и строка описания модуля. Любая строка, поставленная перед описанием модуля, превращает его в обычное строковое выражение, и __doc__ становится None. Молча.

Python, #! и строка кодировки. Ядро читает #! только с нулевого байта, а объявление кодировки по PEP 263 действует только в первых двух строках; ввоз, вставленный выше них, делал запускаемый сценарий незапускаемым, а объявленную кодировку — недействующей.

C, C++ и JavaScript во вложенной папке. Помощник клался в корень черновика, а обмазанный исходник ищет его рядом с собой: #include "ouroboros_runtime.h" и import ... from "./ouroboros_runtime.js" считают путь от того файла, где написаны. Запись в src/main.c давала файл, который не собирается, — при ответе "ok": true. Теперь помощник кладётся рядом с файлом. Проверено на четырёх языках, плоско и вложенно.

Проверено: остаётся так, и это выбор

Четыре вещи ниже не поломки, а осознанная плата. Их не собираются чинить, и вот почему.

Непойманная паника в Go печатает не то же самое. Это единственное место во всех восьми языках, где обмазка меняет вывод программы, поэтому оно стоит здесь первым. Чтобы записать вид и текст паники, замыкание вызывает recover(), а затем panic() заново — иначе поле x осталось бы без вида и без текста, а такую запись читать не по чему. Из-за повторной паники программа, которая паникует и никем не ловится, печатает в поток ошибок

panic: bad [recovered, repanicked]

вместо panic: bad, и в след вызовов добавляется кадр замыкания. Проверено прогоном: код возврата тот же (2), обычный вывод тот же, и всякая паника, пойманная самой программой или вызывающей, приходит тем же значением — то есть меняется ровно текст сообщения о необработанном падении. Он и без обмазки был бы другим: в след вызовов входят номера строк, а обмазка их сдвигает. Убрать это, не потеряв поле x, нельзя: узнать значение паники, не перехватив её, Go не даёт.

C++ не записывает возвращённый объект классового типа — в поле r стоит (no value). Чтобы его записать, возврат пришлось бы провести через помощник, а это отменяет пропуск копирования, который C++17 гарантирует: программа со счётчиком своих конструкторов начинает печатать перемещение, которого без обмазки не было, а тип с удалёнными копированием и перемещением просто перестаёт собираться. Наблюдаемость проиграла прозрачности намеренно: инструмент, меняющий поведение измеряемого, бесполезен. Доводы, длительность и факт исключения записываются как обычно; числа, указатели и ссылки записываются полностью.

Имён доводов нет в записи ни у одного языка. Подробно — в Языках. Коротко: единая схема для восьми языков возможна только если a везде значит одно и то же.

Изображение значения зовёт код самой программы. Чтобы записать довод, надо спросить у него, как он выглядит, — а это вызов написанного вами кода: __repr__ в Python, toJSON в JavaScript, ToString в Java и C#. Если такой метод считает свои вызовы или что-то меняет, обмазанная программа поведёт себя иначе, чем необмазанная. Проверено прогоном на трёх языках сразу — программа печатает, звали ли её изображение:

как есть : вызовов repr в самой программе: 0
обмазано : вызовов repr в самой программе: >0

Починить это нечем: записать значение, не спросив у значения, невозможно. Единственное, что здесь сделано, — бросок внутри такого метода не роняет программу: он ловится, и в запись идёт <Тип toString threw ...>. Поэтому такая программа и не входит в набор равенства: она нарушает главное обещание по устройству, а не по недосмотру.

Плата за безопасность: строки в C и C++

const char * печатается адресом, а не содержимым. Это самое заметное, чего записи на C лишились, и сделано нарочно.

Печатать такой указатель строкой — значит считать, что за ним лежит текст с нулём на конце. Тип этого не обещает. Функция put_one(const char *p), вызванная как put_one(&c) для одного знака, — обычный правильный C; а обмазанная копия читала за c до первого нуля, который окажется где-то дальше в памяти.

Проверено измерителем границ: необмазанная программа чиста, обмазанная даёт stack-buffer-overflow ... READ of size 2 — в C внутри vsnprintf, в C++ внутри strlen. То есть обмазка вносила в чужую программу неопределённое поведение — ровно то, чего инструмент обещает не делать. Отдельно плохо, что это ломало тех, кто гоняет свои проверки под измерителем границ: он показывал бы на обмазку, а не на их ошибку.

Безопасно напечатать содержимое нельзя: типа, который значит «здесь точно строка», в C нет, а ограничение длины не спасает — чтение всё равно уходит за край короткого объекта. Поэтому содержимое не печатается вовсе. Как это вернуть, не потеряв безопасность, записано в FEATURE_REQUESTS.md.

Обмазка Python добавляет кадр стека на каждый вызов. Между вызывающим и вызываемым встаёт обёртка, поэтому рекурсия, которая помещалась до обмазки, может не поместиться после. Замерено на самопишущейся функции:

предел рекурсии глубина без обмазки глубина с обмазкой
200 199 95
1000 999 495

То есть примерно вдвое мельче — обёртка занимает второй кадр на каждый вызов. Пока механизм — украшатель, это неустранимо. Для глубокой рекурсии обмазывайте wrap_functions не саму рекурсивную функцию, а тех, кто её зовёт. Как повторить — Замеры.

Что из этого следует

Общая причина у всех четырёх одна: начало файла — не нейтральное место. В Python, JavaScript и других языках первые строки имеют особый смысл, и вставка перед ними меняет язык, на котором написан остаток файла. Там, где инструмент это учитывает, всё хорошо; где ещё не учитывает — см. выше.

Практический вывод, который стоит выполнять всегда:

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

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

Чем ещё платит Go

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

Получателя метода в доводах нет. В поле a идут только объявленные доводы: у func (c *Calc) Bump(by int) записан by, но не c. Так же ведёт себя C++ с this; Python, наоборот, пишет self первым доводом.

go vet может найти на одну придирку больше. Если через обмазанную функцию проходит по значению структура с замком внутри (sync.Mutex), помощник получает её копию, и copylocks это отмечает. На go build и на go test не влияет: copylocks не входит в набор проверок, который go test гоняет сам — проверено прогоном на модуле с такой функцией, go test проходит и до обмазки, и после.

Отдельно стоит знать, как Go изображает значение: через %v, а он зовёт у значения его собственный String()/Error() и разыменовывает указатель на структуру. Это родное правило печати Go и того же рода, что вызов __repr__ у Python, но следствие есть: помощник читает то, что обмазанная функция могла лишь сохранить, поэтому под go test -race может всплыть состязание за данные, которого в необмазанной программе не было.

Что обмазка меняет всегда

Замеренная граница пользы

Не всё, что можно записать, стоит записывать. На стенде проекта (bench/RESULTS.md) взяли задачу, где ошибка целиком видна по итоговому выводу, и дали агенту инструмент. Он не воспользовался им ни разу из трёх прогонов — и это был правильный выбор: когда ожидаемый вывод лежит рядом, сверить вывод дешевле, чем обмазывать и читать записи.

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

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

Когда инструмент вообще не подходит

Что из этого стоит унести

Две мысли, ради которых эта страница написана.

Записи, снятые с программы с ошибкой, делают ошибку нормой. Счётчик скидки с > там, где по правилу нужно >=, даёт записи, у которых граничный случай просто неверен. Ничто в записях об этом не скажет: они честно показывают, что код сделал. Опора на факты сама по себе истины не даёт — правило должен написать человек, знающий предметную область, а не тот, кто подогнал его под наблюдения.

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