English · Русский
Границы
Инструмент, у которого границы не названы, опаснее отсутствующего: им начинают пользоваться там, где он врёт.
Главное ограничение
Записи фиксируют, как код себя вёл, а не как он должен себя вести.
Ошибка, работавшая годами, выглядит в записях ровно так же, как правильное поведение. Ничто в записи не помечает вызов как неверный — там просто написано, что позвали с такими доводами и вышло такое.
Отсюда следствие, которое стоит проговорить: выводы, выращенные из записей, наследуют ошибки программы как закон. Было в программе округление не туда — оно станет утверждением «так и надо», и проверяться будет уже оно.
Чего записи не видят по устройству
Того, что не выполнялось. Ветвь, в которую не зашли, записей не даст — и не пожалуется. Записи описывают прогон, а не программу. Это самая недооценённая из границ: отсутствие записей читается как «тут всё спокойно», а значит оно «тут никто не был».
Намерения. Функция вернула -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говорит это прямо полемinstrumentation_removed: false; - помощник (
ouroboros_runtime.pyи его собратья) едет тоже, и правильно: без него обмазанный код не запустится; - что не поехало, названо в поле
skipped, с причиной по каждому файлу.
Слабое место правила, о котором надо знать
Отличить итог работы компилятора от файла, который программу просили сделать, нельзя: и то и другое произведено машиной, и то и другое вернётся, если запустить её снова. Признака, который бы их разделил, у finish нет.
Поэтому правило отбрасывает по виду содержимого — и картинка, которую нарисовала ваша же программа, будет отброшена вместе с собранной программой. Замер на 246 исходниках этого хранилища даёт ноль ошибочных отказов, но это про исходники, а не про данные.
Отсюда единственная защита: finish не молчит. Каждый отброшенный файл назван в skipped с причиной, и забрать нужное руками — ваша работа. Если вы не смотрите в этот список, вы узнаете о пропаже позже и труднее.
Разделение черновика и чистовика не защищает от того, что обмазка уйдёт наружу. Смотрите глазами, что уезжает.
Где обмазка меняет поведение программы
Это главная незакрытая слабость инструмента.
Обмазка — не наблюдение со стороны, а правка исходника. Обычно правка безобидна: на примерах из этой документации программа возвращает то же и бросает то же. Но безобидна она не всегда, и случаи ниже проверены прогоном здесь.
Проверено и починено
Все случаи, которые здесь раньше числились сломанными, исправлены и закрыты проверками. Проверено прогоном на этом дереве.
JavaScript терял строгий режим. Строка ввоза помощника вставлялась выше директивы "use strict", а директива, переставшая быть первой, переставала действовать: программа продолжала работать и работала иначе, без ошибки и без предупреждения. Сейчас на той же программе:
до обмазки: THREW: ReferenceError
после обмазки: THREW: ReferenceErrorC++ не собирался на возврате списком в скобках. 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 может всплыть состязание за данные, которого в необмазанной программе не было.
Что обмазка меняет всегда
- время. См. выше про
d. - отслеживание стека. В нём появляются кадры помощника (
wrapper), между вашими. Видно прямо в выводе из README. - объём записи на диск. Две строки на вызов. Замерено на одной и той же функции
add(a, b), 20 002 вызова: от 238 байт на вызов у JavaScript до 270 у C++ (Замеры). Короткий вид записи для C (--minimal) даёт 38 байт на вызов, но пишет только строку входа. - содержимое файла. Обмазанный файл остаётся обмазанным, пока вы его не вернёте: обратной команды нет, и
finishеё не делает.
Замеренная граница пользы
Не всё, что можно записать, стоит записывать. На стенде проекта (bench/RESULTS.md) взяли задачу, где ошибка целиком видна по итоговому выводу, и дали агенту инструмент. Он не воспользовался им ни разу из трёх прогонов — и это был правильный выбор: когда ожидаемый вывод лежит рядом, сверить вывод дешевле, чем обмазывать и читать записи.
Отсюда честная граница: записи о вызовах окупаются там, где ошибка не видна по итоговому выводу и по отслеживанию стека. Прежде чем тратиться, спросите себя, локализуется ли ваша ошибка без них. Если да — не тратьтесь.
Обратное — то, ради чего инструмент задуман: большие системы с непрозрачным промежуточным состоянием, где неправильное значение рождается в середине цепочки и до итогового вывода не доходит. Этого стенд не измерял, и говорить, что там он выигрывает, оснований пока нет.
Когда инструмент вообще не подходит
- Нужно узнать, что код должен делать. Спросите человека или спецификацию.
- Нужно доказать, что код правильный. Записи этого не могут по устройству.
- Ошибка видна по выводу или по отслеживанию стека. Дешевле посмотреть туда.
- Программа недетерминирована по времени и порядку, а вы хотите повторяемости. Записи получите, повторить их не сможете.
- Горячий путь, где важна каждая микросекунда, и обмазать надо всё. Две записи на вызов — не бесплатно.
Что из этого стоит унести
Две мысли, ради которых эта страница написана.
Записи, снятые с программы с ошибкой, делают ошибку нормой. Счётчик скидки с > там, где по правилу нужно >=, даёт записи, у которых граничный случай просто неверен. Ничто в записях об этом не скажет: они честно показывают, что код сделал. Опора на факты сама по себе истины не даёт — правило должен написать человек, знающий предметную область, а не тот, кто подогнал его под наблюдения.
Нагрузка и есть выборка. Всё, что вы увидите в записях, — это то, что происходило на том потоке вызовов, который вы прогнали. Ветвь, куда не зашли, даст ноль записей и ноль предупреждений. Больше записей с неправильного потока не помогает.