Записывает, как код на самом деле исполнялся: вызовы, доводы, результаты, исключения, длительности
Восемь языков. Схема записи у всех одна, а способ вставки и мелкие подробности — разные. Всё в таблицах ниже снято с настоящих прогонов на этой машине, а не выведено из описания.
| язык | расширения | как вставляется запись | что нужно на машине |
|---|---|---|---|
| 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}
Что здесь видно сразу, без чтения исходника:
a совпадает у всех восьми — "2, 3", одни значения, без имён. Так было не
всегда: C, C++ и Elixir писали сюда "a=2, b=3", и поле значило у трёх языков
одно, а у двух другое. Разбор — ниже.ci у всех -1 — «неизвестно». Настоящий номер ядра бывает только в
сборке C внутри ядра операционной системы.th у всех из двух частей — процесс и поток, — но вторая часть у каждого
своя: у Python номер потока, у C номер нити, у Elixir номер процесса BEAM
(#PID<0.95.0>), у JavaScript номер рабочего потока (у главного — 0),
у Go номер горутины, у Java номер потока JVM, у C# номер управляемого потока.fn расходится, и это правильно. C++ пишет Calc::add — полным именем с
классом. Go пишет Calc.add — так же с типом, но через точку, как называет
метод сама исполняющая среда Go. Elixir пишет просто add, без имени модуля,
хотя функция тоже лежит в модуле Calc. Каждый язык называет функцию так, как
принято в нём.d печатается по-разному при близких значениях: Python 2e-06,
JavaScript 0.000053. Это разница печати чисел, а не единиц. У C и C++
длительность округлена до микросекунды, поэтому здесь она 0.000000.r у всех "5" — строкой, как и задумано: в записи лежит изображение
значения, а не само значение.Это первое, что стоит понять, и это решение, а не недоделка.
Все восемь пишут в один файл 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, снятые прогонами:
func(x int) int { ... }, записанная в
переменную или переданная как довод, пропускается — то же решение, что и
пропуск lambda у Python.a идут только объявленные
доводы, без c из func (c *Calc) Bump(...), — как у C++, где this тоже не
записывается. Python, наоборот, пишет self первым доводом.go vet находит на одну придирку больше, если через обмазанную функцию
проходит значение с замком внутри (sync.Mutex): помощнику оно передаётся по
значению. На go build и на go test это не влияет — проверка copylocks в
набор, который go test гоняет сам, не входит; проверено прогоном.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 в инструменте не упоминается ни разу: его нет ни в списке языков, ни в SPEC.md, ни в исходнике. Обмазать файл flang нельзя — расширению не соответствует ни один разбор.
Замысел поддержать flang у проекта есть, и он не про обмазку: у flang через одну функцию исполняющей машины проходит каждый вызов, способный к рекурсии, так что записи можно брать оттуда, ничего не переписывая. Но это план работ, а не свойство инструмента, и описывать его здесь как возможность было бы неправдой.
Переменные FLANG_WATCH и FLANG_PULSE, если вы про них слышали, — устройство
самого flang. К уроборосу они отношения не имеют, и он их не читает.