В плагине C++ исключение может пройти через вызванный Rust callback. Какой ABI должен быть указан на границе, чтобы такой unwind не пересекал запрещённый контракт?
unsafe extern "C" {
fn start(cb: extern "C" fn());
fn cxx_operation();
}
extern "C" fn callback() {
unsafe { cxx_operation() }
}
fn main() {
unsafe { start(callback) }
}
Если исключение C++ действительно должно проходить через Rust-кадры, границу нужно объявить с ABI extern "C-unwind", а не extern "C". В примере это относится к функции, через которую исключение входит в Rust или выходит из него, и к callback, если unwind проходит через его вызов. Если исключения не должны пересекать границу, безопаснее перехватить их на стороне C++ и вернуть обычный код ошибки.
Обычный ABI C исторически описывает совместимый вызов функций, но не обещает возможность unwinding через границу. Для FFI это важно, потому что раскрутка стека должна быть согласована между вызывающей и вызываемой сторонами.
ABI C-unwind выделяет границы, через которые unwind разрешён контрактом. Это не делает обработку чужих исключений автоматически безопасной и не превращает C++-исключение в Rust-панику.
Если C++ исключение пересечёт Rust-функцию, объявленную с обычным extern "C", нарушается ABI-контракт. Последствие — неопределённое поведение либо аварийное завершение, в зависимости от конкретной границы и реализации; полагаться на автоматическое корректное прохождение исключения нельзя.
Даже при использовании C-unwind Rust-код должен быть корректен во время раскрутки: нельзя оставлять нарушенные инварианты, удерживать ресурсы, освобождение которых зависит от обычного пути возврата, или рассчитывать, что Rust автоматически поймает исключение C++.
Исправленный вариант должен явно разрешать unwind на соответствующих объявлениях:
extern "C-unwind" задаёт ABI-вариант, допускающий прохождение unwinding через эту границу. Это отличается от unsafe: unsafe требует проверки корректности вызова и типов, а C-unwind описывает допустимое поведение при раскрутке стека.
ABI нужно выбирать для каждой границы, через которую реально проходит unwind. Если start вызывает callback, а C++ исключение выходит из cxx_operation через callback и затем через start, обе соответствующие границы должны иметь совместимый unwind-контракт.
Практически предпочтительный вариант — не пропускать исключения через FFI вообще. C++-обёртка может перехватить исключение, записать диагностическую информацию и вернуть код ошибки; Rust тогда получает обычный результат Result или код состояния. Это проще тестировать и не связывает управление ресурсами с совместимостью механизмов unwinding разных языков.
C-unwind не гарантирует, что Rust сможет выполнить catch_unwind над исключением C++. Он лишь задаёт разрешённый ABI-контракт для прохождения unwind; преобразование исключения, его типобезопасная обработка и сохранение инвариантов остаются задачей интеграционного слоя.
Плагин C++ вызывает callback Rust, а callback обращается к C++ API, которое может выбросить исключение. В первом варианте всё объявлено как extern "C". Он прост, но при выходе исключения через Rust-кадр нарушает контракт ABI.
Во втором варианте используется extern "C-unwind" на всех проходящих границах. Это соответствует сценарию unwinding, но требует убедиться, что используемые платформенный ABI, рантайм и все промежуточные функции действительно поддерживают такой путь. Кроме того, Rust-код не должен считать, что C++ исключение можно безопасно обработать как Rust-панику.
В выбранном решении C++-внешний слой перехватывает исключения и возвращает код ошибки, а Rust callback остаётся extern "C". Такой вариант обычно лучше: граница имеет простой контракт, ресурсы освобождаются обычным способом, а ошибки представлены явно. C-unwind оставляют только для архитектуры, где прохождение исключения через границу действительно является обязательным и полностью поддержано всеми участниками.
Достаточно ли заменить extern "C" на extern "C-unwind" только у функции callback?
Нет, нужно изменить каждую ABI-границу, через которую проходит unwind. Если исключение покидает C++-функцию, входит в Rust callback, а затем проходит наружу через вызывающую FFI-функцию, все эти переходы должны иметь совместимый unwind-контракт. Изменение только одного объявления оставляет другую границу запрещённой.
Означает ли C-unwind, что Rust может поймать C++ исключение через catch_unwind?
Нет. catch_unwind предназначен для Rust-паник и не является универсальным адаптером для исключений C++. C-unwind разрешает пересечение ABI-границы, но не задаёт преобразование чужого объекта исключения в panic или тип Rust-ошибки.
Почему перехват исключения до границы часто лучше, чем разрешённый unwind?
При явном перехвате C++ сохраняется простой FFI-контракт: функция возвращает значение или код ошибки, а не передаёт управление через чужие стековые кадры. Это упрощает управление ресурсами, диагностику и поддержку разных компиляторов и библиотек. Цена — необходимость заранее определить формат ошибок и потеря возможности автоматически продолжить раскрутку через Rust.