Сетевой диск, смонтированный по SMB, выглядит простым: открыл файл, прочитал, закрыл. Под этой простотой идут цепочки команд, склеенные в один пакет ради скорости, и именно в таких склейках клиент ядра Linux годами хранил несколько неприятных мест. Релиз 7.2.9 от 3 октября 2026 года закрывает целую группу таких мест в клиенте SMB, а заодно приносит правки в eBPF. Одновременно вышли обновления шести веток долгой поддержки: 6.18.55, 6.12.112, 6.6.158, 6.1.189, 5.15.222 и 5.10.271.
Ниже разобрано, как ломался составной запрос, куда уходили кредиты и почему сервер мог держать открытый handle за клиентом, который о нём уже забыл. В конце собран тестовый стенд, на котором поведение клиента можно сравнить до и после обновления.
Составной запрос SMB, его кредиты, записи MID и серверный handle как единая цепочка жизненного цикла
SMB2 и SMB3 умеют склеивать несколько команд в один составной запрос. Типичная связка состоит из CREATE, QUERY_INFO и CLOSE: открыть объект, спросить о нём и закрыть, всё за один обмен с сервером. Команды помечаются как связанные, и каждая следующая использует идентификатор файла, который вернул предыдущий CREATE, поэтому клиенту не нужно ждать ответа, чтобы подставить handle. Так работают, например, получение атрибутов файла и удаление. Выигрыш понятен: вместо трёх кругов по сети получается один.
Расплата за скорость - учёт. У каждой команды есть три бухгалтерские сущности. Первая - кредиты: SMB2 ограничивает число запросов в полёте, клиент тратит кредиты на каждый запрос и получает новые в ответах сервера. Вторая - запись MID, то есть идентификатор сообщения вместе со структурой ожидания. Она стоит в очереди pending_mid_q, пока ответ не пришёл, и несёт функцию обратного вызова. Третья - серверный handle, то есть открытый командой CREATE файл или каталог, который сервер держит в своей таблице, пока его не закроют. Счётчик in_flight показывает, сколько запросов клиента сейчас находится в пути.
Все три баланса обязаны сходиться при любом исходе: сколько кредитов списано, столько вернулось, in_flight вернулся к прежнему числу, а каждый открытый handle закрыт. С нормальным ходом проблем не бывает. Трудности начинались там, где исход нетипичен: ошибка отправки, обрыв соединения, прерванное ожидание.
НОРМАЛЬНЫЙ ХОД
1. кредиты списаны, in_flight вырос на число команд в цепочке
2. MID1 (CREATE), MID2 (QUERY_INFO), MID3 (CLOSE) стоят в pending_mid_q
3. на каждый ответ вызывается callback: кредиты возвращены, in_flight уменьшен
4. CLOSE закрыл handle на сервере, цепочка завершена
ОТКАЗ ПРИ ОТПРАВКЕ (до исправления)
1. отправка не удалась -> запущено переподключение
2. MID остались в pending_mid_q, а сервер уже разблокирован
3. cifs_abort_connection() обходит очередь, callback возвращает кредиты
4. ветка ошибки compound_send_recv() возвращает те же кредиты ещё раз
5. in_flight уменьшен дважды -> предупреждение в smb2_add_credits()
ОТКАЗ ПРИ ОЖИДАНИИ (до исправления)
1. CREATE выполнен, на сервере открыт handle
2. ожидание следующего ответа прервано
3. MID1 освобождён без handle_cancelled_mid(), FID не передан вызывающему
4. handle остаётся открытым на сервере, и никто о нём не знает
Двойной возврат кредитов при ошибке отправки составного запроса и предупреждение в smb2_add_credits
Первое исправление относится к самой отправке. Когда составной запрос не удаётся отправить, функция smb_send_rqst() запускает переподключение. Но compound_send_recv() к этому моменту ещё держит свои MID в pending_mid_q и снимает блокировку с сервера, не убрав их из очереди. Во время переподключения cifs_abort_connection() честно обходит очередь и вызывает callback каждой записи. Ответа нет, поэтому callback возвращает кредиты и уменьшает in_flight. А затем ветка ошибки в compound_send_recv() возвращает те же самые кредиты второй раз.
Так поступает кассир, который выдал покупателю сдачу дважды: каждая операция по отдельности выглядела честной, а касса не сходится. На цифрах картина такая: цепочка из трёх команд списала по одному кредиту на каждую, то есть три. Callbacks вернули три, ветка ошибки вернула ещё три, и в итоге вернулось шесть из трёх. Счётчик in_flight ушёл ниже реального значения, ядро сработало предупреждением в smb2_add_credits(), а соединение дальше жило с неверными цифрами в учёте.
Автоматический фаззер ядра поймал эту ошибку во время согласования диалекта SMB2_negotiate, когда отправка в сокет не удалась. Исправление использует приём, который уже работал в соседнем коде: функция cifs_call_async() давно вызывает delete_mid() до снятия блокировки при ошибке отправки. Для составных цепочек сделано то же самое, а массив cancelled_mid[] помечается так, чтобы завершающая ветка не удаляла записи повторно. Правка маленькая, зато закрывает целый класс расхождений в учёте.
Серверные handle, оставшиеся открытыми после прерванного ожидания, и закрытие успешно созданных объектов
Вторая история тише, но неприятнее. Функция compound_send_recv() ждёт ответы по порядку. Допустим, ответ на CREATE уже пришёл, и на сервере открыт handle. Затем ожидание следующего ответа прерывается сигналом, либо очередная запись MID не проходит синхронизацию ответа. Ранняя запись освобождается без вызова handle_cancelled_mid(), а идентификатор файла так и не копируется вызывающему коду. Сервер считает объект открытым, клиент о нём ничего не знает.
Исправление помечает уже завершённые ранние записи как отменённые и держит их буферы ответов присоединёнными, пока идёт синхронизация. Благодаря этому путь освобождения может заглянуть в успешный ответ CREATE и поставить в очередь SMB2_close() после более позднего сбоя. Есть и тонкость учёта: удалённое открытие засчитывается только после выделения работы по закрытию и до её постановки в очередь, потому что вызывающая сторона счётчик num_remote_opens ещё не обновила. Цепочка CREATE плюс CLOSE, которую использует smb2_unlink(), специально помечена, чтобы её не закрыли дважды.
К той же семье относятся ещё две правки. Функция open_cached_dir() отправляет CREATE и QUERY_INFO составным запросом и теперь сохраняет FID успешного CREATE, даже если следующая команда вернула ошибку, чтобы общая очистка выполнила SMB2_close(). Функция SMB2_open() засчитывала удачный CREATE как удалённое открытие до разбора контекстов, и при отказе разбора выходила, не закрыв handle. Теперь после такой ошибки она закрывает объект и выравнивает счётчик.
Чем это грозит на практике? Открытый handle держит на сервере режим совместного доступа, и другие клиенты могут получать отказ при удалении или переименовании файла. Если на таких серверах работают резервное копирование или сборочные задачи, симптом выглядит случайным: сегодня файл удалился, завтра нет.
Разбор контекстов CREATE, границы записей и смещения данных, которые раньше проверялись не полностью
Ответ на CREATE может нести цепочку контекстов: небольших записей с дополнительными сведениями вроде идентификатора файла, аренды (lease) или данных POSIX. Каждая запись имеет поле Next, смещения имени и данных и длину. Функция smb2_parse_contexts() проверяла всю область целиком, но не ограничивала каждую запись значением Next перед передачей обработчику. Искажённая цепочка могла показать обработчику байты за пределами его записи.
Разборщики аренды SMB2 и SMB3 делали похожее допущение: читали LeaseState и LeaseFlags по привычным смещениям, а не по DataOffset, и принимали слишком короткий DataLength. Теперь каждая запись ограничена своим Next, смещения до заголовка отвергаются, неверные цепочки отбрасываются, а аренда разбирается от DataOffset с проверкой длины по размеру структуры первой или второй версии. Если размер не совпал, разбор аренды пропускается, а открытие файла не падает.
В том же ряду стоят две правки поменьше. Обработчик контекста POSIX читал поля nlink, reparse_tag и mode до проверки, что они вообще есть. По описанию, на обычном пути открытия он не достигается, потому что туда передаётся пустой указатель, но проверка длины всё равно добавлена, а мягкое поведение при ошибке сохранено. Последняя правка этого ряда касается функции smb2_compound_op(): она теряла ошибку разбора контекстов при финальном присваивании результата, и искажённый ответ мог выглядеть как успех для вызывающего кода.
Общий смысл правок один: клиент больше не доверяет формату ответа сервера. Это важно для подключений к сторонним хранилищам, которыми клиент не управляет. Часть патчей помечена как подготовленная с участием языковых моделей, но каждый прошёл ревью мейнтейнера и проверку на тестовых системах. Рядом лежит исправление итерации по суперблокам: из-за него поиск при DFS мог пропустить подходящий суперблок и вернуть -EINVAL.
Изменения eBPF в 7.2.9, где проверки границ и пакетных указателей решают исход безопасности
В списке изменений есть и группа правок eBPF: проверки границ для dynptr поверх skb, работа пакетных указателей в подпрограммах, которые меняют пакет, обнуление элементов per-CPU hash и ряд ошибок JIT на MIPS. Перечень относится к ветке 7.2.9, и в других ветках каждый пункт надо проверять отдельно.
Что за этим стоит? Dynptr - динамический указатель, безопасная обёртка над областью данных, которая проверяет границы при каждом обращении. Вариант поверх skb работает с данными сетевого пакета. Пакетный указатель - прямой указатель на данные пакета, который верификатор уже проверил относительно конца буфера. Правило у верификатора строгое: любая операция, способная изменить или перераспределить буфер пакета, обязана сделать ранее проверенные указатели недействительными. Иначе программа продолжит читать и писать по адресу, который уже указывает на освобождённую память. Подпрограммы усложняют дело, потому что верификатору нужно знать, меняет ли вызов пакет, ещё до анализа конкретных состояний.
Обнуление per-CPU hash решает родственную задачу: значение нового элемента не должно нести остатки предыдущего. Ошибки JIT на MIPS затрагивают только сборки для этой архитектуры, то есть в основном встраиваемые устройства и маршрутизаторы. Для узлов, где работают сетевые eBPF-программы фильтрации и наблюдения, вывод практический: после перехода на новое ядро стоит проверить загрузку программ и их поведение под нагрузкой.
Тестовый стенд для воспроизведения обрыва соединения и сравнения клиента до и после обновления
Для проверки хватит двух виртуальных машин: файлового сервера на Samba и клиента с ядром, которое нужно сравнить. На производственных ресурсах такие опыты запускать нельзя, потому что они намеренно рвут соединение. Сервер настраивается так:
sudo useradd -M -s /usr/sbin/nologin tester
sudo smbpasswd -a tester
sudo mkdir -p /srv/smbtest && sudo chown tester /srv/smbtest
sudo systemctl restart smbd
В smb.conf достаточно простого ресурса:
[test]
path = /srv/smbtest
read only = no
valid users = tester
На клиенте ресурс монтируется командой, где диалект указан явно:
sudo mount -t cifs //192.168.56.10/test /mnt/smbtest -o username=tester,vers=3.1.1
Для первого сценария, ошибки отправки, нужны два терминала. В первом создаётся нагрузка из метаданных, то есть как раз составных запросов:
cd /mnt/smbtest
for i in $(seq 1 3000); do
touch f_$((i % 40))
stat f_$((i % 40)) > /dev/null 2>&1
rm -f f_$((i % 40))
done
Во втором терминале соединение рвётся с короткими паузами, а в третьем идёт наблюдение за журналом ядра:
while true; do
sleep 0.2
sudo ss -K dst 192.168.56.10 dport = :445 > /dev/null 2>&1
done
sudo dmesg -w | grep -iE 'smb2_add_credits|cifs'
Команда ss с ключом -K работает, если ядро собрано с поддержкой принудительного закрытия сокетов. Для второго сценария, прерванного ожидания, достаточно искусственной задержки и серии быстро прерываемых запросов:
sudo tc qdisc add dev eth0 root netem delay 150ms
for i in $(seq 1 500); do
timeout -s INT 0.2 stat /mnt/smbtest/f_$((i % 40)) > /dev/null 2>&1
done
sudo tc qdisc del dev eth0 root
После серии нужно сравнить два показателя. На клиенте смотрится число файлов, открытых на сервере, в /proc/fs/cifs/Stats, а на сервере число записей в выводе smbstatus:
grep -i 'open' /proc/fs/cifs/Stats
sudo smbstatus -L | grep -c '/srv/smbtest'
Формат строк зависит от сборки ядра, поэтому первый раз стоит просто прочитать файл глазами. На ядре без исправлений возможны предупреждения в smb2_add_credits() и рост числа открытых на сервере объектов относительно того, что видит клиент. На 7.2.9 предупреждений быть не должно, а числа после серии должны сходиться. Честная оговорка: воспроизведение вероятностное, потому что нужно попасть в короткое окно между отправкой и ответом. Серию лучше повторить несколько раз и сравнить ряды, а не одну попытку.
Проверка каждой ветки долгой поддержки отдельно и порядок выкатки обновления
Версия ядра в строке uname мало что говорит о наличии конкретной правки. Дистрибутивы переносят исправления в свои пакеты, а в ветках долгой поддержки набор патчей отличается от 7.2.9. Перечень из журнала изменений 7.2.9 нельзя автоматически считать списком для 6.12 или 5.10. Проверка делается по темам коммитов, например в клоне стабильного репозитория:
git log --oneline --grep='compound mids' v6.12..v6.12.112 -- fs/smb/client fs/cifs
Для пакетных ядер дистрибутива помогает журнал изменений пакета:
apt changelog linux-image-$(uname -r) | grep -i 'smb: client'
rpm -q --changelog kernel | grep -i 'smb: client'
Порядок работ укладывается в шесть шагов:
- Выписать версии ядра на всех клиентах и серверах, где ресурсы подключаются по CIFS, и отметить ветки 7.2, 6.18, 6.12, 6.6, 6.1, 5.15 и 5.10;
- Для каждой ветки найти по темам коммитов, вошли ли исправления составных запросов, а не переносить перечень из 7.2.9;
- Собрать тестовый стенд из файлового сервера и клиента в виртуальных машинах и прогнать обрывы соединения и прерывания ожидания на старом ядре;
- Сохранить показатели до обновления: предупреждения в журнале ядра, счётчик открытых на сервере файлов и число записей в smbstatus;
- Обновить тестовый клиент, повторить прогон и сравнить показатели, а затем выкатывать обновление остальным клиентам волнами;
- Проверить загрузку eBPF-программ на узлах, где они используются, и включить просмотр журналов стабильных веток в регулярный план.
Кредиты, записи MID и серверные handle - это три бухгалтерские книги одной сделки, и ядро обязано закрывать все три при любом исходе. Показательно, что почти все исправления этого релиза живут на путях отказа, а не успеха. Надёжность сетевой файловой системы редко проверяется в ясную погоду. Её проверяют там, где соединение рвётся на полуслове, и именно туда стоит заглянуть до того, как это сделает случай.