Этот код прошёл мои code review, ревью второго коллеги, тесты, CI с clippy и десять дней работал в проде:

let mut out = Vec::with_capacity(estimated_size);
let written = unsafe {
    decompress(input.as_ptr(), input.len(), out.as_mut_ptr(), out.capacity())
};
unsafe { out.set_len(written) };

Один сегфолт на каждые 800 тысяч запросов внутри C-библиотеки. Расследование заняло три дня.

Этот код написал не джуниор. Этот код написал Claude Sonnet, я одобрил на ревью, второй ревьюер одобрил тоже. Корень я разберу в конце статьи, когда станет понятно, почему именно так пишет почти каждая модель и почему почти каждый ревьюер это пропускает.

Дальше по делу: что именно ломается, как это выглядит под miri, и как я в итоге начал ловить такие штуки до прода, а не после.

Сетап и цифры

Полгода я давал LLM писать unsafe-блоки в боевых проектах. Получилось примерно 180 unsafe-блоков в шести репозиториях: FFI-обёртки вокруг C-библиотек (zstd, libsodium, кастомный аудио-кодек), две lock-free структуры, два примитивных аллокатора, парсер бинарного формата с указательной арифметикой, немного no_std для встроенной железки. Каждый сгенерированный блок я прогонял через miri, cargo-careful, ThreadSanitizer там, где это имело смысл, Loom для конкурентных структур и просто пристально читал глазами.

Распределение по моделям получилось примерно такое.

Модель

Корректно с первого раза

Падает под miri

Падает под Loom/TSan

Claude Sonnet

38%

44%

18%

GPT-4 class

33%

49%

18%

Gemini Pro

29%

51%

20%

Qwen/DeepSeek (локально)

22%

58%

20%

Среднее

34%

47%

19%

Для сравнения, в безопасном Rust из первой части доля «сразу правильного» кода была около 70%. Падение в два раза не объясняется ни шумом, ни выбором модели.

Объяснение, на котором я остановился, такое. В обучающей выборке тонна старого C и тонна safe Rust из туториалов. Unsafe Rust туда попадает в основном из двух мест: учебных примеров уровня «вот как работает mem::transmute» и нетривиальных кусков std/tokio/crossbeam, где инварианты соблюдены, но почти никогда не разобраны словами рядом с кодом. Комментарии вида // SAFETY: в открытом коде встречаются настолько редко, что модель буквально не знает об их существовании как жанра. Поэтому она пишет unsafe в стиле учебника, где инварианты подразумеваются, и они же первыми ломаются на реальном коде.

Дальше разбор того, что ломается стабильно. Категории, минимальные примеры, фиксы, иногда вывод miri или TSan. Только то, что я лично собирал в логах.

Aliasing rules и Stacked Borrows

Самая частая ошибка в сгенерированном unsafe не имеет ничего общего с указателями напрямую. Она про то, что в Rust одновременно нельзя иметь &mut T и любой другой доступ к той же памяти, даже через сырой указатель, если этот указатель был выведен из ссылки. Stacked Borrows и Tree Borrows ловят такое моментально, при кодогенерации это не учитывается вообще.

Классический пример, который я видел в трёх разных проектах в разных вариациях:

fn split_at_mut_naive<T>(slice: &mut [T], mid: usize) -> (&mut [T], &mut [T]) {
    let len = slice.len();
    let ptr = slice.as_mut_ptr();
    unsafe {
        let left = std::slice::from_raw_parts_mut(ptr, mid);
        let right = std::slice::from_raw_parts_mut(ptr.add(mid), len - mid);
        (left, right)
    }
}

Компилируется, тесты зелёные, на практике в 99% случаев работает. Под miri выходит вот это:

error: Undefined Behavior: trying to retag from <2847> for Unique permission
  at alloc1234[0x0..0x20], but that tag does not exist in the borrow stack
 --> src/util.rs:5:20
  |
5 |         let right = std::slice::from_raw_parts_mut(ptr.add(mid), len - mid);
  |                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |                     this error occurs as part of retag at alloc1234[0x10..0x20]

Мы создали два &mut [T], перекрывающих один и тот же исходный borrow. Правильная версия использует split_at_mut из стандартной библиотеки. Если уж писать вручную, то нужно сначала превратить ссылку в указатель и больше к исходной ссылке не возвращаться до конца unsafe-блока, а ещё лучше использовать slice::from_raw_parts_mut от двух непересекающихся указателей, полученных через корректную провенанс-цепочку.

Модель почти всегда выдаёт первый вариант. Когда я говорю «это нарушает aliasing rules», в половине случаев получаю в ответ unsafe { &mut *ptr } обёрнутый ещё в один слой, что ничего не меняет, а в другой половине предложение использовать UnsafeCell, что тоже мимо.

Интересный подкласс: модель переводит &mut T в *mut T через as_mut_ptr, а потом где-то посреди unsafe-блока случайно использует исходный &mut T или даже &T. Stacked Borrows такое ловит сразу: внешний borrow «протух», когда мы создали указатель и через него писали. На обычных тестах это незаметно, потому что физически память не меняется.

Провенанс и арифметика через usize

Вторая по частоте категория. Указатель кастуется в usize, делается арифметика, кастуется обратно. На стабильном Rust это формально работает, но с приходом strict provenance и экспериментальной модели памяти такие указатели теряют провенанс и считаются wildcard. Под miri с флагом -Zmiri-strict-provenance это UB, и в будущем может стать UB без флага.

Типичный сгенерированный код:

let base = buffer.as_ptr() as usize;
let offset = compute_offset(index);
let target = (base + offset) as *const u8;
unsafe { target.read() }

Правильный вариант сохраняет провенанс через wrapping_byte_add на самом указателе:

let target = buffer.as_ptr().wrapping_byte_add(compute_offset(index));
unsafe { target.read() }

Разница в том, что во втором случае указатель никогда не превращается в число, и оптимизатор знает: target указывает в тот же allocated object, что и buffer. В первом случае компилятор имеет право считать, что target может указывать куда угодно.

Корень в датасете: в обучающей выборке полно старого C-подобного кода, где такое нормально. На прямой запрос «используй strict provenance» большинство моделей выдаёт правильный вариант, но в свободной генерации возвращаются к привычному.

Ещё один характерный кейс: ptr as usize + offset для логирования или для использования в качестве ключа в HashMap. Само по себе это не UB. Но если потом этот usize кастуется обратно в указатель и через него читаются данные, провенанс уже потерян. Я ловил такое в проекте, где модель решила «закэшировать» указатели в HashMap<usize, ...> и переиспользовать их через несколько строк. На x86_64 работало месяц, потом миграция на arm64, и всплыли странные segfault’ы.

Layout: alloc и dealloc по разным типам

Когда я просил написать примитивный bump-аллокатор или обёртку над alloc::alloc, в семи случаях из десяти dealloc вызывался с Layout, не совпадающим с тем, который передавался в alloc. Чаще всего alignment берётся от другого типа или размер считается через mem::size_of_val от уже невалидной ссылки.

Пример из реального ревью:

unsafe fn free_node<T>(ptr: *mut Node<T>) {
    std::ptr::drop_in_place(ptr);
    let layout = std::alloc::Layout::new::<T>();
    std::alloc::dealloc(ptr as *mut u8, layout);
}

Ловушка: layout считается для T, а аллоцировался блок под Node<T>. Это мгновенное UB в dealloc, контракт требует передать ровно тот же layout, что был использован при alloc. Модель не заметила разницу, потому что в обучающих примерах часто аллоцируют сам T, а тут уровень обёртки выше.

Фикс тривиален: Layout::new::<Node<T>>(). Но без miri или внимательного чтения это не видно, тесты проходят, потому что многие аллокаторы прощают такое до определённого момента, а потом случается segfault в продакшене.

Отдельный подвид: arena-аллокатор, в котором layout для T посчитан аккуратно, но забыт padding и alignment при размещении нескольких объектов подряд. Получается смещение, где align_of::<T> не соблюдается, и на arm64, где невыровненный доступ дороже, это уже не «работает 99% времени», а стабильно ломается на каждом втором запросе.

ManuallyDrop и тихий double free

Когда unsafe-код перемещает значения через ptr::read, ptr::write или mem::transmute, легко забыть, что Rust по-прежнему считает оригинал валидным и в какой-то момент вызовет его деструктор. Если внутри был String или Box, получаем double free.

Конкретный кейс, который я отлавливал в FFI-обёртке:

unsafe fn into_raw_parts(s: String) -> (*mut u8, usize, usize) {
    let ptr = s.as_ptr() as *mut u8;
    let len = s.len();
    let cap = s.capacity();
    (ptr, len, cap)
}

Выглядит безобидно. Проблема в том, что s уходит из функции по значению и в конце вызывается её деструктор, который освобождает буфер. Указатель, который мы вернули, теперь висячий. Если потом кто-то соберёт String::from_raw_parts обратно, получаем double free.

Правильный вариант использует String::into_raw_parts (на nightly) либо явный ManuallyDrop:

unsafe fn into_raw_parts(s: String) -> (*mut u8, usize, usize) {
    let mut s = std::mem::ManuallyDrop::new(s);
    (s.as_mut_ptr(), s.len(), s.capacity())
}

Классика, которую миллион раз обсуждали в Rustonomicon. Сгенерированный код всё равно идёт по первому варианту, особенно когда FFI-сигнатура спрятана за несколько слоёв.

FFI-колбэки: мутация через & из чужого потока

Когда я писал обёртку вокруг C-библиотеки с асинхронными колбэками, на выходе получилось примерно такое:

struct State {
    count: usize,
    last_error: i32,
}

extern "C" fn on_event(user_data: *mut c_void, event: *const Event) {
    let state = unsafe { &*(user_data as *const State) };
    unsafe {
        let s = state as *const State as *mut State;
        (*s).count += 1;
        if (*event).code != 0 {
            (*s).last_error = (*event).code;
        }
    }
}

Поля модифицировались через сырой указатель, полученный из &State. Внутри обычные поля без Mutex, без AtomicUsize, без UnsafeCell. Колбэк дёргается из C-потока, отличного от того, что создал State. Объяснение модели: «иначе не получится передать в C через extern "C" fn».

По нормам Rust иметь &T и при этом мутировать T это UB всегда, кроме случая, когда внутри UnsafeCell. Никакие unsafe-блоки этого не отменяют, потому что aliasing-модель есть часть контракта, на которую опирается оптимизатор. Сначала пропадают инкременты, потом значения читаются из регистров, потом начинается настоящий ад.

Правильная сигнатура: Arc<Mutex<State>> либо отдельные AtomicUsize и AtomicI32, либо честный UnsafeCell<State> с явной синхронизацией через FFI-флаги, если действительно нужно избежать мьютекса. Сгенерированный код использует голый usize и спокойно ставит unsafe-блок вокруг присваивания, считая, что unsafe закрывает любые претензии.

Send и Sync, проставленные руками

Ещё одна категория, которая стабильно ломается: ручная реализация Send и Sync для типов с сырыми указателями.

struct MyHandle(*mut ffi::Context);
unsafe impl Send for MyHandle {}
unsafe impl Sync for MyHandle {}

Дальше handle кладут в Arc<MyHandle> и передают между потоками. Если в документации к ffi::Context написано «not thread-safe», такое обещание давать нельзя. Через две недели в проде начнутся редкие падения, которые ThreadSanitizer бы поймал за секунду:

WARNING: ThreadSanitizer: data race (pid=12873)
  Write of size 8 at 0x7b0800001a40 by thread T2:
    #0 ffi::context_update <null> (libffi_ctx.so+0x3a21f)
    #1 my_crate::worker::tick my_crate/src/worker.rs:84
  Previous write of size 8 at 0x7b0800001a40 by thread T1:
    #0 ffi::context_update <null> (libffi_ctx.so+0x3a21f)
    #1 my_crate::worker::tick my_crate/src/worker.rs:84
SUMMARY: ThreadSanitizer: data race in ffi::context_update

Правило, которое модели не выучивают: unsafe impl Send/Sync это обещание программиста компилятору, что инварианты соблюдены. Если внутри сидит указатель на структуру, к которой документация требует one-thread-at-a-time, лучше явно обернуть в Mutex или сделать тип !Send через PhantomData<*const ()>.

Uninit-память и MaybeUninit

Отдельная тема, работа с неинициализированной памятью. До сих пор регулярно вижу mem::uninitialized() (давно deprecated) или, что хуже, mem::zeroed() для типов, у которых нулевое представление есть UB (например, &T, Box<T>, NonNull<T>).

Типичный сгенерированный фрагмент:

let mut buf: [u8; 1024] = unsafe { std::mem::uninitialized() };
read_into(&mut buf);

Правильный вариант через MaybeUninit:

let mut buf = std::mem::MaybeUninit::<[u8; 1024]>::uninit();
unsafe {
    read_into_uninit(buf.as_mut_ptr() as *mut u8, 1024);
    let buf = buf.assume_init();
}

Для [u8; N] разница на современных компиляторах почти невидима, но для типов с нишами, валидностью или bool внутри mem::uninitialized гарантированное UB, потому что само создание значения уже нарушает инварианты типа.

Подмножество той же проблемы: Vec::with_capacity(n), потом vec.set_len(n) и запись в vec.as_mut_ptr(). Между with_capacity и set_len элементы в логической части вектора считаются инициализированными. Если что-то падает по пути, деструктор Vec побежит по неинициализированным T и попытается их дропнуть. Правильный паттерн через spare_capacity_mut и MaybeUninit идёт в код только если попросить явно.

Pin и самоссылки

Когда я просил реализовать самоссылающуюся структуру вручную (интрузивный список, future, который держит ссылку на свой буфер), на выходе получался код без Pin, с движением значений через mem::replace и с предположением, что адреса полей не поменяются. Никаких PhantomPinned, никаких Pin<&mut Self>, иногда даже Box вместо Pin<Box<_>>.

Конкретный кейс, future с буфером и ссылкой на этот буфер для парсера:

struct ParsedFuture {
    buf: Vec<u8>,
    parser: Parser<'static>,
}

Время жизни 'static стоит, потому что иначе компилятор не пропускает структуру, в которой поле ссылается на другое поле. По сути это «обманули borrow checker, теперь сами разбирайтесь». Если такой future подвинется в памяти (положили в Box, потом в Vec, потом подвинули при перевыделении), parser будет указывать на старый адрес и стрелять.

Правильный путь сложнее: либо Pin<Box<Self>> с PhantomPinned, либо ouroboros, self_cell, yoke для разных сценариев, либо переделать API так, чтобы парсер строился по запросу, а не хранился в структуре. В первой части я разбирал, во что превращается async fn под капотом и почему Pin нужен именно из-за самоссылок, тот же механизм работает и здесь.

Pin<&mut T> в сигнатуре poll модели использовать умеют. А вот спроектировать тип так, чтобы инвариант pin-проекции реально соблюдался, нет. В итоге получаются гибриды, которые компилируются, но при первом же mem::swap ломают всё.

Неожиданная находка: контекст ухудшает unsafe

Этого я не ожидал. Качество сгенерированного unsafe заметно падает, когда контекст забит safe-кодом текущего проекта. Если открыть отдельную сессию без проекта и попросить «напиши unsafe split_at_mut вручную», модель в среднем справляется неплохо. Если та же модель видит вокруг 500 строк безопасного бизнес-кода и в нём попросить вставить тот же unsafe-блок, шанс получить нарушение aliasing rules заметно выше.

Гипотеза: модель калибрует уверенность по окружающему коду. Если вокруг всё «обычный Rust», она пишет unsafe в том же темпе и стиле, без переключения в режим «здесь думаю медленно». Я научился просить отдельную сессию специально для unsafe-частей, без основного кода в контексте. Это даёт заметно более чистый код, который потом проще встроить руками.

Подтвердить эту гипотезу строго я не могу, у меня нет доступа ни к весам, ни к внутренней температуре. Но симптом стабильно воспроизводится на трёх разных моделях, и одно только разделение сессий снижает долю miri-падений у меня в логах процентов на десять.

Постмортем: как я ловил тот сегфолт три дня

Возвращаюсь к коду из начала статьи.

let mut out = Vec::with_capacity(estimated_size);
let written = unsafe {
    decompress(input.as_ptr(), input.len(), out.as_mut_ptr(), out.capacity())
};
unsafe { out.set_len(written) };

День первый. Алерт по сегфолтам в одном из подов. Частота примерно один на 800 тысяч запросов, всегда на больших входах. Включил core dumps, дождался, прогнал через addr2line. Стек упирается в memcpy глубоко внутри C-библиотеки, Rust-фреймов в стеке нет, потому что падение прилетает из чужого .so уже после того, как мы вернули управление в unsafe-блок. Часов шесть на то, чтобы понять, что искать причину в Rust-коде, а не в библиотеке.

День второй. Подозрение на расхождение estimated_size с реальным размером выхода. Достал из логов пять входов, на которых ловились падения. Локально под обычным cargo test они отрабатывали чисто. Запустил тот же набор под miri через cargo +nightly miri test, miri мгновенно показал «memory access out of bounds» в момент set_len, потому что C-функция писала за пределы capacity. AddressSanitizer на тех же входах подтвердил, кадры стека уже с символами.

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

Что я вынес. Тестов на happy path не хватает категорически, особенно когда unsafe-блок прячется за тонкой обёрткой. Если бы miri или ASan стояли в CI на репрезентативном наборе входов, проблема всплыла бы до релиза. Десять дней мы платили за их отсутствие.

Промпты, которые работают

Три формулировки из моего живого конфига.

Для самого блока: «Напиши unsafe-блок, делающий X. Перед блоком вставь комментарий // SAFETY:, в котором перечисли все инварианты, на которые ты опираешься. Если хоть один инвариант непонятно как гарантировать вызывающему, не пиши код, а скажи об этом». На сформулированных инвариантах сразу видно, где модель привирает.

Для проверки: «Перед тем как принять этот код, представь, что он запущен под miri с флагами -Zmiri-strict-provenance -Zmiri-tag-gc=0. Какие ошибки miri выдаст? Ответь честно». Примерно в половине случаев модель сама находит свою же ошибку.

Для FFI: «Опиши контракт C-функции, которую ты вызываешь: что должно быть валидно на входе, кто владеет памятью на выходе, может ли функция дёргаться из другого потока, какие коды возврата возможны. Только после этого пиши обёртку». Убирает почти весь класс «писали в чужой буфер» и «передали указатель в поток, где он невалиден».

Инструменты, без которых я больше не работаю

miri через cargo +nightly miri test, желательно с -Zmiri-strict-provenance для будущей совместимости. Для конкурентных структур Loom: переписать unit-тесты на loom::sync и прогнать, он переберёт все интересные перестановки потоков. Для FFI и реального рантайма ThreadSanitizer через RUSTFLAGS="-Z sanitizer=thread" плюс AddressSanitizer для ловли out-of-bounds. cargo-careful для отдельных запусков с дополнительными проверками стандартной библиотеки. Для верификации критических кусков KANI или Prusti, если задача укладывается в их размер.

Без этого те 47% miri-падений я бы никогда не увидел. Happy-path тест на корректном коде и на коде с UB ведёт себя одинаково, до поры до времени.

Что бы помогло в обучении моделей

Тезис простой. У моделей в обучающей выборке тонна старого C, тонна safe Rust из туториалов и почти нет качественного unsafe Rust с разобранными инвариантами. Любая модель, которой в системный промпт явно положили «перед unsafe пиши SAFETY», ведёт себя заметно аккуратнее. Знания у моделей есть, они просто по умолчанию не активируются.

Что бы помогло конкретно. Датасет из реальных unsafe-блоков с полными SAFETY-комментариями, в идеале тройками «плохой код плюс вывод miri плюс исправленный код». Файнтюн на разборах PR в std, miri, tokio, crossbeam, где обсуждаются именно инварианты. Отдельный режим у модели, который вокруг unsafe-блока повышает осторожность и снижает темп генерации.

Пока этого нет, ответственность за безопасность сгенерированного unsafe полностью на человеке. И этот человек должен знать модель памяти Rust лучше, чем модель её знает.

LLM в unsafe сейчас опаснее стажёра

Стажёр в unsafe боится. Он пишет один маленький блок, перечитывает его пять раз, идёт в чат к старшему. LLM не боится. Она генерирует 200 строк уверенного unsafe за секунду, добавляет правдоподобные комментарии, и если её не остановить, оно поедет в прод. У стажёра включён инстинкт самосохранения, у модели нет.

Если в первой части я говорил, что LLM ускоряют написание безопасного Rust в полтора-два раза, то в unsafe-части честная оценка обратная. Время на написание блока модель экономит, но время на ревью, прогон под санитайзерами и поиск редких UB-багов в проде компенсирует с лихвой. Я не отказался от LLM в unsafe, я перестал доверять им без miri в CI и без отдельного раунда ревью именно на инварианты. И завёл правило: unsafe-задачи в отдельной сессии без основного кода в контексте.

Пишу про Rust в тг,LLM, ии и вайбкодинг если интересно залетайте.

Жду замечаний, кейсов и контрпримеров в комментариях, спасибо за прочтение.

P.S. Расскажите про unsafe-баг, который у вас дольше всех жил в проде до того, как его поймали. Интересна не сама ошибка, а сколько она протянула и что её в итоге выдало.