Nim 2.2.12: исправлена уязвимость в HTTP-сервере, переделан аллокатор и закрыты долги из 2019 года

Nim 2.2.12: исправлена уязвимость в HTTP-сервере, переделан аллокатор и закрыты долги из 2019 года

Nim — мой фаворит среди современных языков программирования. Причина простая: он выглядит как Python, а работает как C.

Код читается почти как псевдокод: отступы вместо фигурных скобок, никакой обязательной церемонии вокруг обычной функции. При этом компилятор переводит программу в C, C++ или JavaScript, передаёт её системному компилятору, и на выходе получается самодостаточный бинарник. Без виртуальной машины и без рантайма, который надо тащить рядом с программой. На Nim пишут прошивки для микроконтроллеров, веб-сервисы и сам компилятор Nim.

Но интереснее всего в нём устроена работа с памятью. В большинстве языков выбор сделан за вас: либо сборщик мусора с непредсказуемыми паузами, либо ручное управление со всеми его последствиями. В Nim модель выбирается ключом компилятора, и с версии 2.0 по умолчанию включена та, что называется ORC.

Это не сборщик мусора в привычном смысле. Компилятор сам расставляет по коду освобождение памяти там, где объект перестаёт быть нужным. Получается подсчёт ссылок, только без ручного труда и без пауз stop-the-world: память освобождается в предсказуемый момент, а не когда сборщик решит остановить программу. Небольшой отдельный сборщик добирает то, чего подсчёт ссылок не умеет, — циклические структуры, где объекты ссылаются друг на друга по кругу. Отсюда и название: О — кольцо, RC — reference counting, подсчёт ссылок.

За удобство приходится платить. Расстановка освобождений — работа компилятора, а ошибка в ней даёт не сообщение о проблеме, а падение программы далеко от причины. Примерно половина исправлений в этом релизе — как раз про это.

Восьмого сентября 2026 года вышла версия 2.2.12. Это патч-релиз: ни одной новой конструкции языка, ни одного нового модуля стандартной библиотеки. 142 коммита за четыре месяца, прошедшие с версии 2.2.10, и около полусотни закрытых issue.

Звучит скучно. Список исправлений — нет. В этом релизе закрыли удалённый отказ в обслуживании в стандартном асинхронном HTTP-сервере: сервер убивал один кривой запрос длиной в двенадцать символов. Переделали внутреннее устройство аллокатора для многопоточных программ. Молча портили текст в японских кодировках — починили. И закрыли несколько багов, которым по пять-семь лет.

Патч-релизы — это то место, где язык на самом деле становится пригодным для работы. Ниже — разбор того, что изменилось по сравнению с 2.2.10, с кодом и объяснениями.

Как читать номер версии Nim

В Nim чётная вторая цифра означает стабильную ветку: 2.0, 2.2. Нечётная — ветку разработки. Сейчас в devel собирается будущая 2.4, а ветка version-2-2 живёт отдельно и получает бэкпорты исправлений.

С третьей цифрой та же логика. Релизами становятся чётные номера: 2.2.0, 2.2.2, …, 2.2.10, 2.2.12. Сразу после выхода 2.2.10 версия в ветке стала 2.2.11 — это рабочее состояние между релизами, которое никому не отгружают. Первым коммитом в нашем диапазоне идёт bump NimVersion to 2.2.11, последним — bump NimVersion to 2.2.12. Всё, что между ними, и есть содержание релиза.

Так что 2.2.12 — шестой патч ветки 2.2. Новых возможностей языка тут по определению быть не должно, и почти все они действительно остались в devel.

Одна строка, которая роняла асинхронный сервер

Начнём с того, ради чего стоит обновиться, даже если всё остальное вам не нужно.

Модуль std/asynchttpserver разбирает строку запроса и выделяет из неё версию протокола. Вот как это выглядело в 2.2.10:

proc parseProtocol(protocol: string): tuple[orig: string, major, minor: int] =
  var i = protocol.skipIgnoreCase("HTTP/")
  if i != 5:
    raise newException(ValueError, "Invalid request protocol. Got: " & protocol)
  result.orig = protocol
  i.inc protocol.parseSaturatedNatural(result.major, i)
  i.inc                                              # пропустить точку
  i.inc protocol.parseSaturatedNatural(result.minor, i)

Проверка есть только на префикс HTTP/. Дальше код читает мажорную версию, безусловно перешагивает через точку и читает минорную. Строка HTTP/1.1 разбирается правильно. А строка HTTP/1 — нет: после чтения единицы индекс уже равен длине строки, i.inc уводит его за конец, и следующий вызов получает диапазон, которого не существует.

Дальше срабатывает проверка границ, и программа падает с IndexDefect. Это дефект, а не обычное исключение: в Nim 2 его не ловит ни except CatchableError, ни голый except. Процесс сервера просто умирает.

Итог: любой человек, способный открыть TCP-соединение, отправляет GET / HTTP/1 и гасит сервер. Ни авторизации, ни подготовки, ни специальных инструментов.

Исправление — одна строка:

  if i < protocol.len: inc i                         # пропустить точку

Если вы держите что-то на std/asynchttpserver в открытом интернете, обновляйтесь не откладывая.

Аллокатор: чужие потоки, утечки и выравнивание

Самая большая по объёму правка релиза — файл lib/system/alloc.nim, встроенный аллокатор Nim. Он раздаёт память чанками (chunk — блок страниц, из которого нарезаются мелкие ячейки), и каждый чанк помнит, какому потоку принадлежит.

Раньше чанк хранил прямой указатель на структуру региона памяти:

  BaseChunk {.pure, inheritable.} = object
    prevSize: int
    size: int
    owner: ptr MemRegion

Теперь в многопоточных сборках с ARC/ORC вместо этого хранится указатель на отдельный дескриптор — ptr RegionHandle. Смысл в том, что поток может завершиться, а память, которую он раздал другим потокам, — остаться. Прямой указатель на регион мёртвого потока превращается в мину; дескриптор переживает поток и позволяет корректно передать чанки дальше.

Список освобождённых чужими потоками ячеек (sharedFreeLists) вынесли в отдельный тип. В комментарии к коду прямо оговорено, что выбор раскладки завязан на hasThreadSupport and defined(gcDestructors), а не на наличие локального региона: раскладка чанка — это ABI, и она должна совпадать между программой, собранной с --useNimRtl, и самой библиотекой рантайма.

В том же круге правок:

  • закрыта утечка больших чанков в аллокаторе;
  • закрыта утечка в постраничном аллокаторе под Emscripten;
  • rawAlloc специализирован по выравниванию, чтобы не платить за общий случай;
  • добавлены тесты на передачу пула между потоками, гонку при передаче и работу с пулом чужого потока.

Отдельная правка касается setupForeignThreadGc и tearDownForeignThreadGc. Раньше при --mm:arc это были пустые шаблоны, которые молча компилировались всегда. Теперь они пусты только там, где действительно не нужны, а при эмулируемых потоковых переменных дают внятную ошибку компиляции вместо тихого ничегонеделания.

Быстрый путь в atomicArc

Режим --mm:atomicArc — это подсчёт ссылок атомарными операциями, для программ, где объекты ходят между потоками. Каждое уничтожение ссылки означало атомарную операцию «прочитать-изменить-записать», а она дорогая.

В 2.2.12 появился быстрый путь:

      if (atomicLoadN(addr cell.rc, ATOMIC_ACQUIRE) and not rcMask) == 0:
        result = true
      elif atomicDec(cell.rc, rcIncrement) == -rcIncrement:
        result = true

Логика такая. Счётчик ссылок может быть увеличен только из другой существующей ссылки, а её вклад уже был бы виден в счётчике. Значит, если атомарное чтение вернуло ноль, других владельцев объекта нет — и решать нечего, объект можно уничтожать без дорогой операции.

Чтение обязано быть с семантикой acquire: счётчик мог обнулиться потому, что другой поток успел уменьшить его первым, и мы должны увидеть все его записи до того, как начнём разрушать объект. Медленный путь при этом остался самопроверяющимся — он освобождает память по значению, которое вернула сама атомарная операция, а не по отдельному чтению.

Оптимизация верна только для atomicArc, потому что у него нет сборщика циклов. ORC меняет счётчик из участника, который сам не держит ссылки, и там такой трюк сломался бы. Если что-то пойдёт не так, быстрый путь отключается ключом -d:nimNoAtomicArcFastPath.

ARC/ORC: падения, двойные разрушения и слишком ранний sink

Самая массовая категория исправлений и та самая плата за автоматическое управление памятью, о которой шла речь во вступлении.

Что закрыли:

  • SIGSEGV при повторном использовании {.dirty.}-шаблона вместе с выделением последовательностей. Правку пришлось делать в два захода, и она изменила поведение по умолчанию — об этом ниже;
  • двойной вызов деструктора при {.cursor.};
  • distinct ptr, у которого владение забирали слишком рано: единственным дальнейшим использованием было приведение типа, и компилятор считал переменную мёртвой;
  • ложную ошибку «sink requires a copy because it’s not the last read» — она возникала и в refc, и в ARC/ORC;
  • del(seq) при удалении нулевого элемента из последовательности длиной один: код присваивал элемент самому себе и вызывал =destroy;
  • =destroy теперь принимает непараметризованный обобщённый тип;
  • deepCopy больше нельзя применить к некопируемому типу;
  • отсутствующий eqcopy при копировании объекта-варианта с полем-ссылкой;
  • в refc — обход стека по устаревшим байтам в объектах-вариантах после reset, из-за чего GC_fullCollect падал;
  • обращение к освобождённой памяти в одном из сценариев с курсорами;
  • падение под -d:useMalloc или -d:useSysAssert в связке «предварительное объявление типа плюс distinct плюс distinctBase».

Ни одно из этих исправлений не видно в коде программы, зато их наличие определяет, будет ли программа падать на проде.

Регрессии производительности

Отдельный сюжет релиза — возврат производительности, потерянной при переходе на новые версии.

Оптимизация возвращаемого значения (RVO) позволяет конструировать результат функции прямо в памяти вызывающей стороны, без копирования. В 2.2.x эта оптимизация переставала срабатывать в нескольких сценариях: при возврате значения через условные выражения и указатели, при выражениях try/except, при переходе с refc на ORC. Программа начинала копировать структуры и тратить стек там, где раньше не тратила. Закрыли три отдельных issue про это.

Ещё одна правка касается проверок дефектов. При --mm:refc --exceptions:goto компилятор вставлял проверку возможного дефекта даже туда, где дефект невозможен. Теперь он умеет выводить, что процедура никогда не бросает, и проверку не вставляет.

И маленькая, но приятная правка в sequtils. Шаблон mapIt заполняет каждый элемент результата, но перед этим последовательность обнулялась целиком:

template newSeqForOverwrite(T: typedesc; len: int): untyped =
  ## Выделяет последовательность фиксированной длины, все элементы которой
  ## будут присвоены по индексу.
  when supportsCopyMem(T) and declared(newSeqUninit):
    newSeqUninit[T](len)
  else:
    newSeq[T](len)

Раньше этот приём использовался только в newSeqWith. Теперь его вынесли в общий шаблон, и mapIt для простых типов больше не платит за лишний проход по памяти.

Стандартная библиотека

Оператор ? в std/uri перестал терять параметры

Это изменение поведения, и его стоит заметить. Оператор ? добавляет параметры запроса к URI. Раньше он не добавлял, а заменял:

import std/uri

let u = parseUri("https://example.com/search?lang=ru") ? {"q": "nim"}
echo $u
# 2.2.10: https://example.com/search?q=nim
# 2.2.12: https://example.com/search?lang=ru&q=nim

Исходная строка запроса просто исчезала. Issue об этом висел с мая 2022 года. Теперь параметры дописываются через &, а пустой список параметров ничего не портит.

Если ваш код полагался на старое поведение как на способ очистить запрос, его придётся поправить.

Кодировки: тихая порча японского текста

Функция convert в std/encodings работает через iconv. Она выделяет буфер под результат, исходя из длины входа. Если результат не помещается, iconv возвращает E2BIG, и старый код увеличивал буфер и продолжал конвертацию с того места, где остановился.

Для кодировок без состояния это работает. Для кодировок с состоянием — нет. В ISO-2022-JP последовательность ESC $ B переключает поток в двухбайтовый режим JIS X 0208, а ESC ( B возвращает в ASCII. Состояние конвертера при коротком выводе не обязано сохраниться, и хвост текста выходил сырыми байтами. Без ошибки, без исключения — просто неверный результат, который к тому же длиннее исходника.

Теперь при нехватке места конвертер сбрасывается и вся конвертация переделывается заново в буфер вдвое большего размера:

          discard iconv(c, nil, nil, nil, nil)
          result = newString(len(result) * 2 + 16)
          inLen = csize_t len(s)
          outLen = csize_t len(result)
          src = cstring(s)
          dst = cstring(result)

В тесты добавили строки на ISO-2022-JP с несколькими переключениями режима внутри и проверку, что кодировки без состояния при росте буфера работают по-прежнему.

Utf16Char стал беззнаковым

В модуле std/widestrs тип Utf16Char был объявлен как distinct int16. Кодовая единица UTF-16 — величина от нуля до 65535, знаковый тип для неё просто неверен: всё, что выше 0x7FFF, включая суррогаты, оказывалось отрицательным.

type
  Utf16Char* = distinct uint16

Заодно переписали декодирование. Раньше высокий суррогат в конце строки читал следующую позицию за терминирующим нулём. Теперь одинокие суррогаты — и высокий без пары, и низкий сам по себе — заменяются на символ замены, а сама логика декодирования вынесена в итераторы: отдельный для строки с завершающим нулём, отдельный для openArray с известной длиной.

PEG-выражения

PEG (parsing expression grammar, грамматика разбирающих выражений) — способ описать разбор текста правилами, более предсказуемый родственник регулярных выражений. В Nim он живёт в модуле std/pegs, и этот модуль получил четыре исправления.

Первое про Unicode. Голые идентификаторные терминалы, то есть слова грамматики, записанные без кавычек, теперь читаются как UTF-8. Регистронезависимое сопоставление неанглийских слов вроде \i café работает без обрамления кавычками.

Остальные три — про то, что сопоставитель падал целиком там, где должен был сообщить о неверной грамматике:

  • повторение выражения, способного сопоставиться с пустым входом, например ('a'?)*, валилось с AssertionDefect. Теперь сопоставление корректно завершается на совпадении нулевой длины;
  • неизвестное встроенное экранирование внутри класса символов, например [^\n], давало IndexDefect. Теперь модуль сообщает о неверной грамматике исключением EInvalidPeg;
  • пустой захват {} без предыдущего захвата вылезал за нижнюю границу массива захватов. Теперь это безобидная пустая операция.

Прочее по библиотеке

  • std/jsonutils: fromJson при конвертации в array или seq бросает исключение, если в JSON не массив. Раньше молча не срабатывал;
  • std/symlinks: на Windows expandSymlink научился разбирать точки повторного разбора — и символические ссылки, и junction-точки. Реализация читает буфер разбора напрямую и приводит семантику к посиксовой: один переход, без рекурсивного разворачивания;
  • std/xmltree: макрос-конструктор больше не портит кавычки в выводе;
  • в модуле sets часть возвращаемых значений помечена lent, что убирает лишние копии;
  • memfiles получил запасную реализацию для Nintendo Switch;
  • std/asyncdispatch теперь обрабатывает колбэки раньше таймеров.

Компилятор и язык

У typeof появился параметр modifierMode

Единственное заметное добавление к языку в этом релизе. Раньше typeof обращался с модификаторами типа по фиксированному правилу: sink оставлял, var и lent убирал. Теперь поведением можно управлять:

type
  TypeOfModifiers* = enum
    CompatibleTypeModifiers,  ## как раньше: sink оставить, var и lent убрать
    RemoveTypeModifiers,      ## убрать все модификаторы
    KeepTypeModifiers         ## оставить все модификаторы

Применение выглядит так:

proc varParam(x: var int;
              y: typeof(x, modifierMode = RemoveTypeModifiers);
              z: typeof(x, modifierMode = KeepTypeModifiers)) = discard

doAssert varParam is proc (x: var int; y: int; z: var int) {.nimcall.}

Значение по умолчанию — CompatibleTypeModifiers, так что старый код не меняется.

Совместимость типов процедур теперь смотрит на бэкенд

Раньше совместимость типов параметров и результата проверялась только на уровне исходного кода. Из-за этого возникала ошибка C-компилятора вида «incompatible function pointer types» там, где Nim считал uint и csize_t разными типами, а C — одним и тем же.

Теперь проверка учитывает представление типа в бэкенде. Это может изменить поведение существующего кода, поэтому добавили ключ --legacy:procParamTypeBackendAliases, возвращающий старые правила.

Второй новый ключ совместимости

Исправление того самого SIGSEGV с {.dirty.}-шаблонами сделало ошибкой ситуацию, когда шаблон подставляет определение символа, а потом это определение попадает в код несколько раз. Формально это переопределение, и раньше оно тихо принималось.

По умолчанию теперь ошибка. Ключ --legacy:injectedSymbolRedefinition возвращает старое поведение — в комментарии компилятора оно прямо названо неверным.

Итого два новых ключа совместимости в патч-релизе:

КлючЧто возвращает
--legacy:procParamTypeBackendAliasesстарые правила совместимости типов процедур, без учёта псевдонимов C-типов
--legacy:injectedSymbolRedefinitionразрешение шаблону подставлять определение символа несколько раз

Остальное в компиляторе

  • предупреждение ImplicitRangeConversion научилось распознавать константы и больше не ругается на них зря;
  • вложенные блоки {.cast(gcsafe).}: выход из внутреннего блока больше не отменяет внешний для последующих операторов;
  • предупреждение ResultUsed приведено в соответствие с документацией;
  • при --panics:on проверка nimErr_ больше не пропускается после вызова замыкания;
  • typeof(result) больше не считается использованием result до инициализации в типах с {.requiresInit.};
  • исправлено совпадение хешей имён для вложенных кортежей с одинаковой развёрнутой структурой — раньше это давало ошибку генерации C-кода;
  • --import:std/foo больше не теряет префикс std/;
  • исправлены выведение эффектов исключений, работа {.forbids.} с типами замыканий и несколько сценариев с обобщёнными процедурами внутри обобщённых процедур;
  • новые концепты перестали приводить несовместимые типы друг к другу.

JavaScript-бэкенд

Здесь закрыли ошибку 2020 года. Параметр var openArray, получающий срез через toOpenArray, в JS писал в копию, а не в исходные данные:

proc fill(x: var openArray[int]) =
  for i in 0 ..< x.len: x[i] = i

var data = [10, 20, 30, 40]
fill(data.toOpenArray(1, 2))
echo data
# C:                 [10, 0, 1, 40]
# JS до 2.2.12:      [10, 20, 30, 40]
# JS начиная с 2.2.12: [10, 0, 1, 40]

Один и тот же код давал разный результат на разных бэкендах, причём молча. Теперь однородные числовые массивы режутся через subarray типизированных массивов JavaScript, а последовательности и нечисловые массивы — через представление вида {base, off, len}. Правка покрывает и обратную запись, и передачу такого среза дальше, и повторное разрезание, и оператор @ для превращения в последовательность.

Кроме того, JS-бэкенд научился выводить курсоры и опускать nimCopy там, где значение заведомо не меняется, и получил исправление регрессии, из-за которой генерируемый dockhack.js был невалиден.

Платформы

ИзменениеЧто это значит
wasm64 (Memory64) как полноценная цельпоявился процессор wasm64 с 64-битными целыми; символ wasm теперь определён и для wasm32, и для wasm64
iOS: --app:lib даёт libfoo.dylibраньше получался libfoo.so, что противоречит соглашениям Darwin
Haiku: определения kqueueпривязка libbsd перенесена на уровень модуля
Nintendo Switch: запасная реализация memfilesмодуль перестал быть неприменимым на этой платформе
Xtensa (ESP): встроенные функции для longдля микроконтроллеров Espressif
arm-none-eabi: сужено использование проверяемых операций над longпроверяемая арифметика больше не тянется туда, где её нет
OpenBSD: самодописывание строки в refcиспользовало перекрывающийся copyMem и падало
Windows: терминальные обёртки переехали в winleanвнутренняя перестановка

Что убрали и что объявили устаревшим

Из системного модуля полностью удалили GC_setStrategy вместе с перечислением GC_Strategy:

    GC_Strategy* = enum
      gcThroughput, gcResponsiveness, gcOptimizeTime, gcOptimizeSpace

Это была пустая процедура во всех реализациях сборщика: она принимала стратегию и ничего с ней не делала. Код, который её вызывал, теперь не соберётся — зато перестанет создавать иллюзию управления.

Горячая перезагрузка кода (--hotCodeReloading) объявлена устаревшей: при включении компилятор выдаёт предупреждение со ссылкой на RFC. И зафиксировано, что asyncthreadpool принципиально несовместим с --mm:orc.

Долги: баги, которым по несколько лет

Отдельная приятная часть релиза — issue, закрытые спустя годы:

IssueЗаведёнО чём
#11797июль 2019типы через importc, например cint, не работали как псевдонимы и ломали генерацию C-кода
#15952ноябрь 2020toOpenArray с var openArray не работал в JS
#18367июнь 2021quote внутри блока static давал «Illegal storage access»
#19240декабрь 2021раздел руководства про делегирование bind был бессмысленным
#19782май 2022оператор ? в std/uri не дописывал параметры
#20078июль 2022nimpretty --indent:3 мог внести синтаксические ошибки
#22791октябрь 2023ложное предупреждение ProveField во вложенных объектах-вариантах
#22936ноябрь 2023наследование обобщённых типов давало ложное несоответствие типов
#22950ноябрь 2023невнятное сообщение об ошибке при нарушении эффектов в cast

Самому старому из них шесть лет. Это нормальный ритм для языка, который развивают в основном добровольцы: ошибка в генерации C-кода для псевдонимов типов ждала своего человека с 2019 года.

Инструменты

  • nimsuggest перестал падать на запросах типа для символов без пригодного типа, например для процедур, возвращающих void, и для символов модулей. Это тот самый компонент, через который работают плагины к редакторам, так что падение означало неработающие подсказки;
  • nimpretty с ключом --indent больше не ломает участки, где отступы нужно сохранять как есть;
  • Nimble обновлён до версии 0.24.1;
  • в генераторе документации директива .. include:: научилась понимать :start-after: и :end-before: в режиме :literal:, а также аргумент :code:;
  • рекомендованная версия choosenim — 0.8.16.

Стоит ли обновляться

Да, и обновление почти наверняка пройдёт без правок в коде. Это патч-релиз: язык не менялся, модули не переезжали, ключи компилятора не переименовывались.

Три вещи, на которые стоит посмотреть перед обновлением:

  • если вы пользуетесь оператором ? из std/uri и полагались на то, что он заменяет строку запроса, поведение изменилось;
  • если у вас есть {.dirty.}-шаблоны, подставляющие определения символов, сборка может упасть с ошибкой переопределения — временное решение --legacy:injectedSymbolRedefinition;
  • если код передаёт процедурные типы через границу C и раньше это компилировалось, новые правила совместимости могут это заметить, а --legacy:procParamTypeBackendAliases вернёт старое поведение.

Обновление через choosenim:

$ choosenim update self
$ choosenim update stable

Итого

Nim 2.2.12 — релиз без новостей и поэтому он важный. В нём нет ничего такого, о чём захочется рассказать в докладе, но зато закрыт удалённый отказ в обслуживании в стандартном веб-сервере, приведён в порядок аллокатор для многопоточных программ, возвращена потерянная оптимизация возвращаемого значения и починена тихая порча текста в кодировках с состоянием.

Такие релизы показывают состояние языка честнее, чем анонсы новых возможностей. Список из полусотни исправлений, где половина — про расстановку деструкторов и обход стека, говорит, что автоматическое управление памятью в Nim всё ещё дозревает. А закрытые баги 2019 и 2020 годов говорят, что дозревает медленно, но не останавливаясь.

Полный список изменений — в объявлении о релизе и в сравнении веток на GitHub.

Предыдущий
Наверх