Записывает, как код на самом деле исполнялся: вызовы, доводы, результаты, исключения, длительности
Эта страница важнее остальных. Инструмент, у которого границы не названы, опаснее отсутствующего: им начинают пользоваться там, где он врёт.
Записи фиксируют, как код себя вёл, а не как он должен себя вести.
Ошибка, работавшая годами, выглядит в записях ровно так же, как правильное поведение. Ничто в записи не помечает вызов как неверный — там просто написано, что позвали с такими доводами и вышло такое.
Отсюда следствие, которое стоит проговорить: выводы, выращенные из записей, наследуют ошибки программы как закон. Было в программе округление не туда — оно станет утверждением «так и надо», и проверяться будет уже оно.
Того, что не выполнялось. Ветвь, в которую не зашли, записей не даст — и не пожалуется. Записи описывают прогон, а не программу. Это самая недооценённая из границ: отсутствие записей читается как «тут всё спокойно», а значит оно «тут никто не был».
Намерения. Функция вернула -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: 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 ...>. Поэтому
такая программа и не входит в набор равенства: она нарушает главное обещание по
устройству, а не по недосмотру.
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 стоит дороже прочих языков, стоят выше: непойманная паника печатает другое — «остаётся так, и это выбор», — а функции-значения не обмазываются вовсе — «чего записи не видят по устройству». Здесь то, что помельче; всё проверено прогонами, и ни одно не меняет ни кода возврата, ни обычного вывода.
Получателя метода в доводах нет. В поле 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) взяли задачу, где ошибка целиком видна по итоговому выводу, и дали агенту инструмент. Он не воспользовался им ни разу из трёх прогонов — и это был правильный выбор: когда ожидаемый вывод лежит рядом, сверить вывод дешевле, чем обмазывать и читать записи.
Отсюда честная граница: записи о вызовах окупаются там, где ошибка не видна по итоговому выводу и по отслеживанию стека. Прежде чем тратиться, спросите себя, локализуется ли ваша ошибка без них. Если да — не тратьтесь.
Обратное — то, ради чего инструмент задуман: большие системы с непрозрачным промежуточным состоянием, где неправильное значение рождается в середине цепочки и до итогового вывода не доходит. Этого стенд не измерял, и говорить, что там он выигрывает, оснований пока нет.
Две мысли, ради которых эта страница написана.
Записи, снятые с программы с ошибкой, делают ошибку нормой. Счётчик скидки с
> там, где по правилу нужно >=, даёт записи, у которых граничный случай
просто неверен. Ничто в записях об этом не скажет: они честно показывают, что
код сделал. Опора на факты сама по себе истины не даёт — правило должен написать
человек, знающий предметную область, а не тот, кто подогнал его под наблюдения.
Нагрузка и есть выборка. Всё, что вы увидите в записях, — это то, что происходило на том потоке вызовов, который вы прогнали. Ветвь, куда не зашли, даст ноль записей и ноль предупреждений. Больше записей с неправильного потока не помогает.