惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Microsoft Azure Blog
Microsoft Azure Blog
Cloudbric
Cloudbric
I
InfoQ
V
V2EX
博客园_首页
The Register - Security
The Register - Security
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
S
Secure Thoughts
Vercel News
Vercel News
Forbes - Security
Forbes - Security
云风的 BLOG
云风的 BLOG
PCI Perspectives
PCI Perspectives
L
LINUX DO - 最新话题
D
DataBreaches.Net
H
Hacker News: Front Page
Application and Cybersecurity Blog
Application and Cybersecurity Blog
B
Blog RSS Feed
A
About on SuperTechFans
N
News and Events Feed by Topic
Apple Machine Learning Research
Apple Machine Learning Research
Help Net Security
Help Net Security
Attack and Defense Labs
Attack and Defense Labs
N
Netflix TechBlog - Medium
Spread Privacy
Spread Privacy
F
Full Disclosure
Recorded Future
Recorded Future
AWS News Blog
AWS News Blog
博客园 - 【当耐特】
The Cloudflare Blog
T
Threatpost
T
Tor Project blog
Google DeepMind News
Google DeepMind News
C
CXSECURITY Database RSS Feed - CXSecurity.com
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
Recent Announcements
Recent Announcements
M
MIT News - Artificial intelligence
A
Arctic Wolf
C
Check Point Blog
Stack Overflow Blog
Stack Overflow Blog
T
Threat Research - Cisco Blogs
Security Archives - TechRepublic
Security Archives - TechRepublic
Hacker News - Newest:
Hacker News - Newest: "LLM"
WordPress大学
WordPress大学
Cyberwarzone
Cyberwarzone
小众软件
小众软件
C
Cyber Attacks, Cyber Crime and Cyber Security
P
Proofpoint News Feed
Security Latest
Security Latest
The Last Watchdog
The Last Watchdog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Я дал LLM писать unsafe Rust полгода. Miri плакал
vibecodingai · 2026-05-19 · via Все публикации подряд на Хабре

Этот код прошёл мои 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-баг, который у вас дольше всех жил в проде до того, как его поймали. Интересна не сама ошибка, а сколько она протянула и что её в итоге выдало.