ГлавнаяБлогКак зависнуть навсегда: баг с таймаутом в smolagents и его исправление
Python

Как зависнуть навсегда: баг с таймаутом в smolagents и его исправление

Разбираем баг в smolagents: выражение 10**10**8 вешает процесс из-за GIL. Узнайте, как мы нашли и исправили проблему с помощью оценки битовой длины.

Al
Редакция Algolitalgolit.ru
8 мин чтения19 июля 2026 г.

Баг, который ничего не возвращает

Представьте: вы запускаете агента на smolagents, он вычисляет 10 ** 10 ** 8 — и процесс зависает навсегда. Ни ошибки, ни таймаута, ни события в Sentry. Просто тишина. Это реальный баг из issue #2473, который мы нашли и исправили. В этой статье я покажу, почему таймаут не сработал, как мы это починили и какой урок извлекли.

Почему таймаут обманывает

smolagents использует поточный таймаут: рабочий поток выполняет код, главный поток ждёт в future.result(timeout=2). Обычно это работает, потому что CPython переключает потоки между инструкциями байт-кода. Но 10 ** 10 ** 8 — не обычное выражение. CPython вычисляет большие целые числа (**, <<, *) одним C-вызовом, который удерживает GIL от начала до конца. Нет границы байт-кода — нет переключения потоков — нет таймаута. Результат содержит около 400 миллионов бит, вычисление занимает минуты или часы.

Мы запустили код с таймаутом 2 секунды и faulthandler на 20 секунд. Таймаут не сработал. Процесс завис до внешнего убийства. Вот стек потоков:

Thread 0x16d1f3000 (worker):
  File "local_python_executor.py", line 753 in evaluate_binop   # вычисление степени

Thread 0x1f00d5e80 (main):
  File "threading.py", line 999 in start
  File "concurrent/futures/thread.py", line 180 in submit       # никогда не вернулся

Главный поток застрял внутри ThreadPoolExecutor.submit и даже не дошёл до future.result. Таймер 2 секунды никогда не запускался. Существующая защита MAX_OPERATIONS (10 миллионов AST-операций) не помогает — это всего несколько узлов AST, вся стоимость внутри одного.

Мониторинг тишины

Этот баг — противоположность шумным сбоям. Мы направили Sentry на симуляцию агента с непропатченной версией (1.26.0) — и получили пустой проект. Ни событий, ни транзакций. Процесс завис, SDK не успел отправить данные. Для таких багов нужен супервизор. Мы добавили небольшой процесс-наблюдатель, который даёт рабочему потоку 25 секунд, затем убивает его и отправляет отчёт в Sentry с дампом стека:

watchdog: starting worker (before) with 25s budget
worker: smolagents 1.26.0
worker: step 1 executing 'result = 10 ** 10 ** 8'...
watchdog: worker FROZE, killed it, reporting to Sentry

В результате в Sentry появился issue с полной историей: окружение before, обработанное супервизором, с замороженным фреймом evaluate_binop в прикреплённом стеке. Анализ Seer от Sentry независимо пришёл к тому же выводу: сигналы и прерывания потоков требуют GIL, а одна C-операция с большими целыми никогда его не отпускает.

Исправление: не начинать то, что нельзя прервать

Прервать вычисление из Python невозможно. Но можно оценить ущерб за O(1). Перед выполнением **, << или * над целыми числами исполнитель теперь оценивает битовую длину результата:

if op == "**":
    estimated_bits = left.bit_length() * right  # верхняя граница
elif op == "<<":
    estimated_bits = left.bit_length() + right
elif op == "*":
    estimated_bits = left.bit_length() + right.bit_length()

Если оценка превышает 1 миллион бит (около 300 тысяч цифр, всё ещё щедро), выбрасывается информативный InterpreterError:

Операция '**' дала бы целое число около 400000000 бит, что превышает
максимум в 1000000 бит. Используйте меньшие операнды или
pow(base, exp, mod) для модульного возведения.

Это сообщение важно: агент передаёт его модели, и модель может на него отреагировать — использовать pow(base, exp, mod), который быстрый и легитимный. Агент восстанавливается на следующем шаге вместо зависания хоста.

После патча защита отклоняет выражение за 0.0 секунд, ошибка попадает в Sentry как нормальный issue, и второй шаг выполняется успешно:

watchdog: starting worker (after) with 25s budget
worker: smolagents 1.27.0.dev0
worker: step 1 error surfaced to the model: InterpreterError: ...
worker: step 2 executing '2 + 2'...
worker: step 2 ok
worker: DONE
watchdog: worker exited with code 0

Робот проверил мой робо-фикс

Через минуту после открытия PR ревьюер OpenAI Codex нашёл реальную дыру: моя проверка type(x) is int пропускала bool и подклассы int. True << 10**9 и class BigInt(int) всё ещё доходили до C-вызовов. Исправил на isinstance, добавил регрессионные тесты. ИИ нашёл баг, ИИ исправил, ИИ проверил — я только направлял.

Цифры

  • Зависание воспроизводилось за 20+ секунд (могло бы длиться часы), требовалось внешнее убийство
  • Исправление отклоняет то же выражение за 0.0 секунд
  • 9 взрывных паттернов заблокированы: **, <<, цепочки *, все составные формы, pow(a, b), варианты с bool и подклассами int
  • 10 легитимных операций проверены: 100! через *=, pow(7, 2**64, 97), float pow, 1 ** 10**9
  • 19 новых тестов, весь тестовый файл 416 пройден, ruff чист

Тесты на заблокированные паттерны зависают навсегда на непропатченной версии — я проверял честно, с резервным убийством.

Практический вывод

Самые страшные баги — не те, что будят вас в 3 часа ночи, а те, что гарантируют, что вас никогда не разбудят. Инструментируйте тишину. Добавьте супервизор для зависаний, оценивайте битовую длину перед опасными операциями и используйте Sentry для мониторинга отсутствия событий. Ваш будущий «я» скажет спасибо.

Ссылки: Issue #2473, PR #2551.

#smolagents#баги#GIL#таймаут#Python
Al
Редакция Algolit

Пишем про алгоритмы, подготовку к собеседованиям и карьеру в IT — так, чтобы было понятно и полезно.

Хочешь закрепить знания на практике?

Решай задачи на Algolit — интерактивная платформа для обучения

Начать бесплатно →