Уроборос

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

View the Project on GitHub digitable-lol/ouroboros

Языки

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

язык расширения как вставляется запись что нужно на машине
Python .py надстройка @_ouro_log над функцией ничего сверх Python
JavaScript / TypeScript .js .mjs .cjs .jsx .ts .tsx try/finally в теле node
C .c .h __attribute__((cleanup)) gcc/clang
C++ .cpp .cc .cxx .hpp .hh .hxx сторож области видимости (RAII) g++/clang++
Elixir .ex .exs use Ouroboros.Trace, переопределение def elixir
Go .go именованные возвраты и defer go (нужен и для обмазки)
Java .java try/catch/finally в теле JDK (нужен и для обмазки)
C# .cs try/catch/finally в теле .NET SDK (нужен и для обмазки)

libclang для разбора C и C++ и @babel/parser для разбора JavaScript уложены внутрь пакета — доставлять их отдельно не надо. Разбор Go не требует ничего стороннего вовсе: go/parser входит в саму поставку языка.

Разбор C и C++ идёт отдельной, не питоновской программой: она читает исходник, находит границы тел и возвратов и печатает их в JSON, а питон только режет по этим числам. Такая же развязка уже была у JavaScript (node) и Elixir, и точно так же устроен Go: ouroboros/languages/_go/emitter.go печатает границы тел и подписей, а питон режет байты. Программа разбора для C и C++ написана на C, для Go — на Go, и обе собираются один раз на машине при первом обмазывании, поэтому для C, C++ и Go нужное средство сборки требуется уже на обмазывании, а не только на сборке. Для остальных языков компилятор и node по-прежнему нужны только чтобы собрать и запустить обмазанный код.

Список, который печатает сам инструмент:

ouroboros languages
{"languages": ["python", "javascript", "c", "cpp", "elixir", "go", "java", "csharp"]}

Один и тот же вызов на всех восьми

add(2, 3) → 5, снято по отдельности на этой машине. Это и есть проверка того, что схема действительно одна: ключи, порядок и смысл полей совпадают, а различается только то, что различаться должно.

Python

{"p":"in","t":"2026-08-29T08:58:33.966","id":"99ef2d63-a1d9-40af-a086-5c558e778213","ci":-1,"th":"1989681.137722432983552","fn":"add","a":"2, 3","k":""}
{"p":"out","id":"99ef2d63-a1d9-40af-a086-5c558e778213","fn":"add","r":"5","d":2e-06}

JavaScript

{"p":"in","t":"2026-08-29T08:58:34.641","id":"c272c9da-a6ee-4d88-8cfb-ed0968834ba5","ci":-1,"th":"1990605.0","fn":"add","a":"2, 3","k":""}
{"p":"out","id":"c272c9da-a6ee-4d88-8cfb-ed0968834ba5","fn":"add","r":"5","d":0.000053}

C

{"p":"in","t":"2026-08-29T08:58:35.376","id":"f7872c35-a195-4711-b9df-2e5866b48d46","ci":-1,"th":"1990883.1990883","fn":"add","a":"2, 3","k":""}
{"p":"out","id":"f7872c35-a195-4711-b9df-2e5866b48d46","fn":"add","r":"5","d":0.000000}

C++ (метод класса Calc)

{"p":"in","t":"2026-08-29T08:58:36.789","id":"3b25a49a-b150-4936-8a5e-3ab62a0f017c","ci":-1,"th":"1991611.138599134574464","fn":"Calc::add","a":"2, 3","k":""}
{"p":"out","id":"3b25a49a-b150-4936-8a5e-3ab62a0f017c","fn":"Calc::add","r":"5","d":0.000000}

Elixir (функция модуля Calc)

{"p":"in","t":"2026-08-29T08:58:40.106","id":"5dc72a4c-49b4-4170-851f-468a7e7f24c3","ci":-1,"th":"1993355.#PID<0.95.0>","fn":"add","a":"2, 3","k":""}
{"p":"out","id":"5dc72a4c-49b4-4170-851f-468a7e7f24c3","fn":"add","r":"5","d":0.000011}

Go (метод типа Calc)

{"p":"in","t":"2026-08-29T23:51:58.977","id":"db3d3f3a-7adc-427a-b8e6-6bb3afca159e","ci":-1,"th":"2396561.1","fn":"Calc.add","a":"2, 3","k":""}
{"p":"out","id":"db3d3f3a-7adc-427a-b8e6-6bb3afca159e","fn":"Calc.add","r":"5","d":0.000001}

Java (метод класса Prog)

{"p":"in","t":"2026-08-30T15:46:18.232","id":"41d5bdd2-cbb5-442f-b92f-553e2f553fdd","ci":-1,"th":"2695782.3","fn":"Prog.add","a":"2, 3","k":""}
{"p":"out","id":"41d5bdd2-cbb5-442f-b92f-553e2f553fdd","fn":"Prog.add","r":"5","d":0.000006}

C# (метод класса Prog)

{"p":"in","t":"2026-08-30T15:46:44.287","id":"52fc4c08-16c7-4344-b33c-27718921bc05","ci":-1,"th":"2696617.1","fn":"Prog.add","a":"2, 3","k":""}
{"p":"out","id":"52fc4c08-16c7-4344-b33c-27718921bc05","fn":"Prog.add","r":"5","d":0.000243}

Что здесь видно сразу, без чтения исходника:

Одна схема, разные диалекты

Это первое, что стоит понять, и это решение, а не недоделка.

Все восемь пишут в один файл debug.info одну и ту же схему: те же ключи, те же две строки на вызов, тот же смысл каждого поля. Разбор трассы у вас один на все языки.

А вот диалекты нарочно оставлены родными. Из SPEC.md:

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

Проверка tests/test_cross_language.py разбирает трассы двух языков и сравнивает записи, предварительно убрав диалект.

Как пишется полное имя

Снято с прогонов:

язык что в поле fn
Python, метод Box.put
Python, вложенная функция outer.<locals>.inner
C++, метод Calc::add
Go, метод по значению Calc.add
Go, метод по указателю (*Calc).Bump
C add
JavaScript mul
Elixir addбез имени модуля
Java, метод Calc.add
Java, конструктор Calc.Calc
Java, метод безымянного класса Calc.$anon.run
C#, метод Calc.Add
C#, чтение свойства Counter.Value.get
C#, чтение по номеру (this[]) Counter.this[].get
C#, своё сложение (operator +) Vec.operator+

Как изображается значение

Python пишет строку как 'мир', JavaScript — как "мир". Одна и та же длительность у Python выглядит как 1e-06, у JavaScript — как 0.000001. Сравнивать записи разных языков в лоб нельзя: сперва убирают диалект.

Откуда берутся имена доводов

Короткий ответ: ниоткуда. Имён доводов в записи нет ни у одного из восьми языков.

Таблица ниже напечатана прогоном, а не написана: один и тот же вызов add(2, 3) собирается и запускается на каждом из восьми языков, и сюда попадает то, что оказалось в записи. Пересобрать: uv run python scripts/schema_facts.py --measure.

| язык | поле a (по позиции) | поле k (именованные) | |—|—|—| | Python | 2, 3 | пусто | | JavaScript | 2, 3 | пусто | | C | 2, 3 | пусто | | C++ | 2, 3 | пусто | | Elixir | 2, 3 | пусто | | Go | 2, 3 | пусто | | Java | 2, 3 | пусто | | C# | 2, 3 | пусто |

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

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

Что это стоит. У всех восьми языков имя позиционного довода из записи не восстановить. Смотрите подпись в исходнике: поле fn называет функцию, а её подпись лежит рядом в файле. Но средству, которое читает только запись, имена недоступны — это настоящая потеря, а не мелочь оформления.

Именованные доводы Python пишет с именами всегда — они в k, и там имя есть по определению поля.

Ядро, поток и часы

ci (номер ядра) и th (метка потока) стоят в строке входа. Значения ниже взяты дословно из записей, снятых прогонами на Linux; как их повторить — Замеры.

язык ci (ядро) th (поток), как выглядит из чего собран часы для d
Python -1 1175211.128643830919680 <процесс>.<поток> perf_counter
JavaScript -1 1214487.0 <процесс>.<рабочий поток>, у главного он 0 hrtime
C (обычный) -1 1218993.1218993 <процесс>.<нить> clock_gettime(CLOCK_MONOTONIC)
C++ -1 1252160.138039566587776 <процесс>.<нить> steady_clock
Elixir -1 1293945.#PID<0.95.0> <процесс ОС>.<процесс BEAM> monotonic_time
Go -1 2396561.1 <процесс>.<горутина> time.Since (монотонные часы внутри time.Now)
Java -1 3101052.3 <процесс>.<поток JVM> System.nanoTime
C# -1 3103557.1 <процесс>.<управляемый поток> Stopwatch.GetTimestamp

У всех восьми языков в обычной программе ci равен -1, а при разборе становится null. Это не ошибка, а честный ответ «неизвестно»: переносимого способа узнать номер ядра в этих средах нет, и выдумывать его не стали.

Почему -1 у Python. В помощнике стоит попытка позвать os.sched_getcpu(), а при её отсутствии — вернуть -1 (ouroboros/runtime.py:75). Такой функции в CPython нет вовсе — проверено на 3.12.13 и 3.14.4 под Linux. Ни import os; os.sched_getcpu, ни dir(os) её не находят, поэтому ветка с -1 — единственная, которая когда-либо выполняется.

Почему -1 у Elixir. Здесь раньше стоял номер планировщика BEAM, и это число выглядело как номер ядра, не будучи им: планировщики переезжают между ядрами, так что читатель, сравнивавший это поле с ci других языков, сравнивал две разные величины. Сейчас Elixir тоже пишет -1.

Почему -1 у Go. Переносимого способа спросить номер ядра в Go нет: syscall.Gettid есть только под Linux, а номера процессора не даёт и он. Брать вместо этого номер потока операционной системы значило бы положить в поле ci величину другого рода — ровно та ошибка, которую до этого допустил Elixir с номером планировщика.

Единственное место, где ci бывает настоящим номером ядра, — сборка C внутри ядра операционной системы: там ci берётся из cpu_index(curcpu()), а th — из <pid>.<lid> текущего lwp, часы — getnanouptime(9). Это прочитано в заголовке ouroboros/languages/_c/ouroboros_runtime.h (ветка #ifdef _KERNEL); прогоном внутри ядра здесь это не проверялось, в отличие от всей остальной таблицы.

У C нет исключений, поэтому в его записях никогда не бывает поля x — только r. Ещё одна особенность C и C++: они собирают строку JSON сами, не подключая библиотеку, — чтобы сборка для ядра системы не тянула лишнего.

Что каждый язык умеет и чего не умеет

Python. Самый обкатанный из восьми. Надстройка ставится ближе всего к def, то есть внутрь всех прочих надстроек, и записывает саму функцию. Тело не трогается — поэтому несколько return, ранние выходы, вложенные функции и уже написанный try/finally продолжают работать. lambda пропускается. Строка описания модуля и from __future__ import остаются первыми, как того требует язык (обе поломки чинились здесь и закрыты проверками).

JavaScript / TypeScript. Стрелочные функции с коротким телом (x => x + 1) пропускаются — это то же решение, что и пропуск lambda у Python. Асинхронные функции записывают возвращённое обещание, а не то, во что оно разрешится. Помощник ищется рядом с файлом.

Строгий режим сохраняется. Строка ввоза помощника ставится ниже "use strict", поэтому директива остаётся первой и продолжает действовать. Это чинили: раньше ввоз вставлялся выше, директива переставала быть первой и молча отключалась. Проверено прогоном — программа, которая до обмазки бросала ReferenceError на присваивании необъявленной переменной, бросает его и после; как повторить — Замеры.

C. Запись ставится на любой выход — return, goto, конец тела. Вид довода подбирается по разобранному типу (%d, %ld, %p). Указатели, включая const char *, печатаются адресом, а не содержимым — почему, разобрано в Границах. Есть отдельный облегчённый вид записи для горячих и рекурсивных функций — --minimal: без хранения кадра вызова, только имя и глубина. Он рассчитан на сборку для ядра операционной системы.

C++. Значение изображается через operator<<, если он есть. Выход по исключению отличается от обычного через std::uncaught_exceptions. Имя пишется полным — ns::Class::method.

Возврат списком в скобках собирается. return {1, 2, 3}; раньше превращался в return _ouro::capture(__ouro, ({1, 2, 3}));, чего компилятор не принимает. Сейчас такой возврат оставляется как есть: значение не записывается (в поле r будет (no value)), зато файл собирается и запускается. Проверено прогоном — Замеры.

Elixir. use Ouroboros.Trace переопределяет def и defp, поэтому каждая ветвь функции обмазывается отдельно; охранные выражения и значения по умолчанию проходят насквозь, доводы берутся из binding(), raise/throw/exit перехватываются. Порядок сборки важен: модуль записи должен быть собран раньше любого модуля, который его использует.

Go. Запись ставится на любой выход через defer: обычный возврат, panic, проход до конца тела. Возвраты не переписываются вовсе — вместо этого всем возвращаемым значениям в подписи даются имена (__ouro_r0 и далее), и замыкание читает их уже после того, как return их присвоил. Поэтому return f(), где f возвращает несколько значений сразу, работает без единого исключения из правила, тогда как C и JavaScript приходится переписывать каждое место возврата.

Ввоза помощника нет. Помощник — файл того же пакета, а не подключаемая библиотека: Go не умеет ввозить соседний файл. Поэтому над заголовком файла ничего не вставляется, и //go:build вместе с описанием пакета остаются на своих местах, как того требует язык. Расплата в том, что помощник обязан объявлять тот же пакет, что и обмазанный файл: если он останется package main рядом с файлом библиотеки, сборка не пройдёт. Это закрыто проверками test_go.py::test_helper_beside_a_library_package_compiles и парной к ней.

Непойманная паника печатает не то же самое. Чтобы записать вид и текст паники, замыкание вызывает recover(), а затем panic() заново. Код возврата, обычный вывод и всякая пойманная паника от этого не меняются, а вот сообщение непойманной паники в потоке ошибок становится panic: bad [recovered, repanicked] вместо panic: bad. Оно и без обмазки отличалось бы: в след вызовов входят номера строк, а обмазка их сдвигает.

Помощник собирается и старыми Go. Он лежит в чужом дереве и собирается тем Go, какой там есть, поэтому в нём нарочно нет ничего новее версии 1.18 — ни перебора по числу, ни встроенных min/max. Проверено двумя прогонами: на Go 1.26.5 и на Go 1.19.8 из Debian внутри образа packaging/Dockerfile обмазанная программа собралась и записала то же самое. Есть и проверка test_go.py::test_helper_compiles_inside_an_older_module: она собирает обмазанный файл в модуле, где стоит go 1.18.

Ещё три особенности Go, снятые прогонами:

Java. Обмазываются методы и конструкторы, у которых есть тело; отвлечённые, родные и объявления в договоре без тела пропускаются — трогать там нечего. У конструктора запись ставится после вызова super(...) или this(...), который обязан оставаться первым. Замыкания и безымянные классы сами не обмазываются, и их return не считается выходом из внешнего метода. Ввоза помощника нет вовсе: он зовётся полным именем ouroboros.OuroborosRuntime, поэтому заголовок файла остаётся нетронутым.

Номера строк сохраняются. Ни одна врезка не содержит перевода строки, поэтому у обмазанного файла столько же строк, сколько у исходного, и след вызовов у необработанного исключения совпадает построчно. Это проверяется в наборе равенства программой, которая печатает номера строк своих кадров.

Возврат кладётся во временную своего типа. Проще было бы провести его через обобщённый помощник, и сперва так и было — пока на char f() { return 65; } компилятор не сказал «inferred: Integer, upper bound(s): Character». Обобщённый помощник выводит свой тип из довода, а не из метода. Временная, объявленная тем типом, что написан у метода, возвращает выражение в присваивание с нужным типом, где сужение постоянной снова законно.

C#. То же устройство, что у Java, и ещё разворачивание тела-выражения: int M() => a + b; превращается в блок, причём текст самого выражения не переписывается ни на знак. Перебрасывается голым throw;, чтобы у исключения сохранилось место, откуда его бросили.

Пять видов частей оставляются нетронутыми, потому что обмазанный вариант не собрался бы: части с yield (CS1626), возврат по ссылке (CS8150), указатели (CS0306), ссылочные структуры (CS9244) и свойства с телом-выражением. Про каждую такую часть обмазка возвращает предупреждение с причиной — молча они не пропадают. Довод out не попадает в снимок доводов на входе: там он ещё не присвоен (CS0269).

Чужая ссылочная структура из другого файла не видна. Разбор идёт только по написанию, имя в объявление не разрешается. Ссылочные структуры платформы (Span, ReadOnlySpan и подобные) известны по именам, объявленные в обмазываемом файле — берутся из объявления. Объявленная в другом файле того же проекта останется незамеченной, часть с ней будет обмазана и перестанет собираться. Это единственное место во всём инструменте, где обмазка может выдать несобирающийся код.

Работа для ядра операционной системы

Заголовок для C умеет собираться внутри ядра: вместо обычной записи в файл — свой кольцевой буфер, printf(9), getnanouptime(9), уменьшенный кадр вызова и защита от повторного входа. Порождаемый код при этом один и тот же для обоих случаев.

Проверено на NetBSD 11.0_RC4 riscv64: пробный модуль ядра с этим заголовком собирается начисто против настоящих заголовков ядра с полным набором ключей (-ffreestanding -nostdinc -D_KERNEL -Werror -Wsystem-headers). Запуск в пользовательском ядре rump на riscv64 не получился — загрузчик модулей rump не умеет перемещения для riscv64 (panic: kobj_reloc: not supported on this architecture), и это не про наш код. Безопасность при работе — по стеку, повторному входу и объёму — остаётся непроверенной. Подробности — в ARCHITECTURE.md.

Общий ринг на несколько единиц трансляции работает: проверка tests/test_c.py::test_shared_ring_across_two_tus собирает два файла в один кольцевой буфер и получает вложенность, пересекающую границу файла:

=== ouroboros ring dump: 2 records (total seen 2) ===
{"p":"in","dep":0,"ci":0,"fn":"fa"}
{"p":"in","dep":1,"ci":0,"fn":"fb"}
=== ouroboros ring end ===

flang — не поддерживается

flang в инструменте не упоминается ни разу: его нет ни в списке языков, ни в SPEC.md, ни в исходнике. Обмазать файл flang нельзя — расширению не соответствует ни один разбор.

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

Переменные FLANG_WATCH и FLANG_PULSE, если вы про них слышали, — устройство самого flang. К уроборосу они отношения не имеют, и он их не читает.