Уроборос

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

View the Project on GitHub digitable-lol/ouroboros

Начало работы

Эта страница — про порядок работы: какие команды есть, в каком порядке их зовут и что проверить в конце. Поставить инструмент — Установка.

Если вы пришли с конкретной задачей, начните с неё:

Два способа работать

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

wrap-file  →  запуск как обычно  →  trace

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

create  →  write  →  execute  →  trace  →  finish

Оба способа делают одно и то же с исходником; разница в том, кто хранит файлы.

Команды

Всего их семнадцать. Первые семь нужны почти всегда, остальные — по случаю.

Дописать запись о вызовах

команда что делает
wrap-file <путь> весь файл на месте
wrap-file <путь> --stdout то же, но напечатать, а файл не трогать
wrap-functions <путь> <имя>… только названные функции, остальное не тронуто
wrap-snippet -l <язык> код со стандартного ввода → на стандартный вывод
ouroboros wrap-functions stats.py average
{"ok": true, "path": "stats.py", "language": "python", "functions_requested": ["average"], "functions_wrapped": 1, "runtime_header": "ouroboros_runtime.py"}

functions_requested против functions_wrapped — здесь и видно, нашлось ли то, что вы назвали. Если попросили три имени, а обмазалась одна, значит два имени в файле не нашлись, и молча это не пройдёт.

Так выглядит результат — тронута ровно одна функция:

"""Средняя длительность запросов из журнала."""
from ouroboros_runtime import log as _ouro_log


def parse_line(line):
    name, _, ms = line.partition(" ")
    return name, int(ms)


@_ouro_log
def average(values):
    return sum(values) / len(values)


def report(lines):
    pairs = [parse_line(l) for l in lines]
    return average([ms for _, ms in pairs])

Проверить, что получится, не трогая файл, — --stdout:

echo 'int add(int a, int b) { return a + b; }' | ouroboros wrap-snippet -l c
#include "ouroboros_runtime.h"
int add(int a, int b) {
	struct _ouro_call __ouro __attribute__((cleanup(_ouro_emit)));
	int __ouro_result;
	_ouro_enter(&__ouro, "add", "%d, %d", a, b);
 return  (__ouro_result = (a + b), _ouro_set_result(&__ouro, "%d", __ouro_result), __ouro_result); }

Способ обмазки у каждого языка свой: у Python — надстройка над функцией, у C — __attribute__((cleanup)), у C++ — сторож области видимости, у JavaScript — try/finally, у Elixir — переопределение def, у Go — defer и именованные возвраты, у Java и C# — try/catch/finally. Записи при этом одинаковые. Языки, design/example.md.

Прочитать записи

команда что делает
trace <файл> сами записи, с отбором
trace-stats <файл> сводка: сколько раз звали, сколько бросили, сколько шло

Отбор у обеих одинаковый:

довод что отбирает
--function, -f по имени функции
--contains, -c по доводам или по тому, что вернули
--outcome result\|raised\|unknown вернули / бросили / непонятно
--min-duration <секунды> только медленные вызовы
--thread <поток> один поток из общей записи
--regex считать -f и -c образцами поиска
--tail N, -n N только последние N
--limit, --cursor читать частями (next_cursor из прошлой страницы)

Готовые ответы на два самых частых вопроса:

ouroboros trace debug.info --outcome raised        # что бросило и с чем
ouroboros trace debug.info --min-duration 0.1      # что шло дольше 0,1 с

А «где висит» смотрят не отбором: в ответе всегда есть поле in_flight — вызовы, у которых есть строка входа и нет строки выхода.

Работа в черновике

команда что делает
create <путь> заводит <путь>/черновик/ с историей изменений
write <путь> <файл> дописывает записи до сохранения, содержимое со стандартного ввода
execute <путь> -- <команда> запускает в черновике, сам подставляет путь к debug.info
finish <путь> переносит черновик в соседний <путь>/чистовик/
ouroboros create /srv/tmp/разбор
{"ok": true, "base": "/srv/tmp/разбор", "draft": "/srv/tmp/разбор/черновик", "clean": "/srv/tmp/разбор/чистовик"}
ouroboros execute /srv/tmp/разбор -- python3 stats.py

Кроме записей о вызовах, execute дописывает в тот же файл одну строку о самой команде — что запускалось, с каким кодом возврата и каким выводом:

{"p":"exec","cmd":["python3","stats.py"],"rc":0,"out":"20.0\n","err":""}

Читалка эту строку пропускает: p у неё не in и не out, значит это не вызов. В счёт испорченных строк она тоже не идёт.

Каждая операция write оставляет отдельную запись в истории:

ouroboros: write stats.py (+3 wrapped)
ouroboros: init draft

Остальное — C и C++ через clangd

lint, symbols, doc-symbols, refs, callers, describe. Они нужны, когда надо выбрать, что именно обмазывать в большом дереве на C: найти функцию по имени, посмотреть, кто её зовёт, и уже потом назвать её в wrap-functions. Требуют clang-tidy и clangd на машине.

Что происходит с кодом, который не разбирается

Он не сохраняется. Не «сохраняется как есть», не «сохраняется наполовину» — не сохраняется:

printf 'def broken(:\n    return 1\n' | ouroboros write /srv/tmp/разбор bad.py
[python] corrupted source in bad.py: invalid syntax (<unknown>, line 1)

Код возврата 1, файла в черновике нет, записи в истории нет. Разбор делает родной для языка разборщик — ast у Python, libclang у C и C++, @babel/parser у JavaScript, Code.string_to_quoted у Elixir, go/parser у Go, компилятор из JDK у Java, Roslyn у C#, — так что отдельного проверяльщика кода не нужно: не разобралось — значит испорчено (ouroboros/languages/base.py:18).

Что finish переносит, а что оставляет

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

Дословный ответ на том же проекте, что и выше:

{
  "ok": true,
  "clean": "…/чистовик",
  "synced": [".gitignore", "ouroboros_runtime.py", "stats.py"],
  "skipped": [],
  "instrumentation_removed": false,
  "note": "The copy is instrumented, exactly like the draft: …"
}

В черновике на этот момент лежал ещё и __pycache__/ouroboros_runtime.cpython-313.pyc, оставленный запуском, — в чистовик он не поехал.

Отсюда три следствия, о которых стоит знать заранее:

  1. Запись о вызовах при переносе не снимается, и поле instrumentation_removed: false говорит об этом прямо. В чистовике лежит тот же обмазанный файл, что и в черновике. Обратной операции у инструмента нет и быть не может: write_file обмазывает до сохранения, поэтому исходного текста автора нет ни в черновике, ни в истории изменений. Снимают обмазку из своей системы контроля версий или руками.
  2. Помощник ouroboros_runtime.py едет вместе с кодом — и правильно, без него обмазанный файл не запустится.
  3. Что не поехало, названо в skipped. Если собрать программу прямо в черновике, двоичный файл останется там, а не уедет в чистовик. Правило не умеет отличить итог работы компилятора от файла, который программу просили сделать: и то и другое произведено машиной. Поэтому оно отбрасывает и называет отброшенное — посмотрите список и заберите руками то, что вам было нужно.

Тот же проект, но программа собрана в черновике, и рядом положена картинка:

{
  "synced": [".gitignore", "notes.csv", "ouroboros_runtime.h", "prog.c", "run"],
  "skipped": [
    {"path": "prog", "reason": "looks built (compiled-format signature, or a NUL byte in the first 8 KiB) and has no source extension"},
    {"path": "prog.o", "reason": ".o: build output, remade by rebuilding"},
    {"path": "real.png", "reason": "looks built (compiled-format signature, or a NUL byte in the first 8 KiB) and has no source extension"}
  ]
}

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

Две вещи, на которых спотыкаются в первый раз

Горячий файл. wrap-file на файле с миллионом вызовов в секунду топит нужные записи в шуме и заметно замедляет программу. Для таких файлов есть wrap-functions — обмазываются только названные функции. Для C есть ещё --minimal: облегчённая запись без хранения кадра вызова, для горячих и рекурсивных функций.

Повторный запуск ничего не портит. Обмазка идемпотентна: wrap-file на уже обмазанном файле не добавит вторую надстройку. А вот debug.info только дописывается — если хотите чистую запись, удалите файл перед прогоном.

Что проверить, прежде чем считать работу сделанной

Последние два пункта — не формальность. Почему именно так: Границы.