Мы работаем над базой данных EdgeDB и в настоящее время портируем с Python на Rust существенную часть кода, отвечающего за сетевой ввод/вывод. В процессе работы мы узнали много всего интересного.

Отказ, который происходит только на ARM64

Мы  разрабатывали для EdgeDB новую возможность выборки HTTP. В качестве клиентской HTTP-библиотеки мы использовали reqwest. Всё шло гладко: фича работала на локальной машине, проходила тесты на сборочных агентах x86_64 и казалась стабильной. Но мы заметили кое-что странное: тесты то и дело не проходили на сборочных агентах для ARM64.

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

Вот как выглядел вывод системы непрерывной интеграции:

Current runner version: '2.321.0'
Runner name: '<instance-id>'
Runner group name: 'Default'

(... 6 hrs of logs ...)

still running:
test_immediate_connection_drop_streaming (pid=451) for 19874.78s

still running:
test_immediate_connection_drop_streaming (pid=451) for 19875.78s

still running:
test_immediate_connection_drop_streaming (pid=451) for 19876.78s

still running:
test_immediate_connection_drop_streaming (pid=451) for 19877.78s

Shutting down test cluster...

Тут мало что происходит. Создаётся впечатление, как будто из-за взаимной блокировки асинхронная задача не может работать и с нашей точки зрения заблокируется. Но оказалось, что мы ошибались.

Наши исходные соображения

Почему проблема в ARM64? Сначала мы не могли найти этому объяснения. Среди первого мы предположили, что существуют различия между моделями памяти, применяемыми в Intel и ARM64. В Intel принята достаточно строгая модель памяти. Притом, что определённые необычные варианты поведения всё-таки возможны, при записях в память поддерживается общий порядок, который соблюдается во всех процессорах Intel ([1][2][3]). В ARM действует значительно менее строгая модель памяти [4], в которой (среди прочего) порядок следования записей может отличаться с точки зрения разных потоков.

Салли написал по этому поводу квалификационную работу на Ph.D. [5], поэтому мы и пригласили его посмотреть, что здесь происходит.

Отладка на машине для непрерывной интеграции

Наши машины для ночных сборок методом непрерывной интеграции работают на серверах Amazon AWS. Преимущество таких серверов в том, что на них можно смоделировать настоящего неконтейнеризованного пользователя с правами администратора. Притом, что можно подключиться к агентам github через ssh [6], хорошо бы иметь возможность подключаться в качестве полноценного администратора, чтобы получать доступ к dmesg и другим системным логам.

Чтобы выяснить, что происходит, мы (Салли и Мэтт) решили подключиться непосредственно к агенту для ARM64 и посмотреть, что происходит под капотом.

Сначала мы зашли через SSH на машину для непрерывной интеграции, попытались найти этот подвисший процесс и подключиться к нему:

$ aws ssm start-session --region us-west-2 --target i-<instance-id>
$ ps aux | grep "451"
<no output>

Oказалось, это верно! Мы запускаем сборку в контейнере Docker, а у него — собственное пространство имён для процессов:

$ sudo docker exec -it <container-id> /bin/sh
# ps aux | grep "451"
<no output>

Постойте-ка. Здесь этого зависшего процесса тоже нет.

Значит, это была не взаимная блокировка — процесс аварийно завершился.

Как выясняется, наш тестовый агент просто не смог этого зафиксировать. Но ладно, эту ошибку мы исправим как-нибудь потом. Можем посмотреть, остался ли от процесса дамп памяти. Поскольку контейнер Docker — это просто отдельное пространство имён для процессов, дамп памяти передаётся на хост с Docker. Можно попытаться докопаться до него извне контейнера при помощи journalctl:

$ sudo journalctl
systemd-coredump: Process 59530 (python3) of user 1000 dumped core.
                  Stack trace of thread <tid>:
                  ...

Ага! Нашли его. Как и ожидалось, дамп для этого процесса лежит в /var/lib/systemd/coredump/. Обратите внимание: именно из-за разницы в пространствах имён здесь наблюдаются разные идентификаторы процессов. Вне контейнера мы увидим pid 59530, а внутри — 1000.

Мы загрузили дамп памяти в gdb, желая проверить, что же произошло. К сожалению, в ответ нам высыпался ворох ошибок:

$ gdb
(gdb) core-file core.python3.1000.<...>.59530.<...>
warning: Can't open file /lib64/libnss_files-2.17.so during file-backed mapping note processing
warning: Can't open file /lib64/librt-2.17.so during file-backed mapping note processing
warning: Can't open file /lib64/libc-2.17.so during file-backed mapping note processing
warning: Can't open file /lib64/libm-2.17.so during file-backed mapping note processing
warning: Can't open file /lib64/libutil-2.17.so during file-backed mapping note processing
... etc ...
(gdb) bt
#0  0x0000ffff805a3e90 in ?? ()
#1  0x0000ffff806a7000 in ?? ()
Backtrace stopped: not enough registers or memory available to unwind further

Ладно. Это не помогло. Мы не располагаем необходимыми файлами вне контейнера, а наши контейнеры весьма минималистичные, поэтому в них не так просто установить gdb.

Вместо этого нам потребуется скопировать из контейнера все релевантные библиотеки и сообщить gdb, где находятся файлы .so:

# mkdir /container
# docker cp <instance>:/lib /container
# docker cp <instance>:/usr /container
... etc ...
$ gdb
(gdb) set solib-absolute-prefix /container
(gdb) file /container/edgedb/bin/python3
Reading symbols from /container/edgedb/bin/python3...
(No debugging symbols found in /container/edgedb/bin/python3)
(gdb) core-file core.python3.1000.<...>.59530.<...>
(gdb) bt
#0  0x0000ffff805a3e90 in getenv () from /container/lib64/libc.so.6
#1  0x0000ffff8059c174 in __dcigettext () from /container/lib64/libc.so.6

Гораздо лучше!

Но в нашем новом HTTP-коде обратная трассировка выявила не отказ, а нечто неожиданное:

(gdb) bt
#0  0x0000ffff805a3e90 in getenv () from /container/lib64/libc.so.6
#1  0x0000ffff8059c174 in __dcigettext () from /container/lib64/libc.so.6
#2  0x0000ffff805f263c in strerror_r () from /container/lib64/libc.so.6
#3  0x0000ffff805f254c in strerror () from /container/lib64/libc.so.6
#4  0x00000000005bb76c in PyErr_SetFromErrnoWithFilenameObjects ()
#5  0x00000000004e4c14 in ?? ()
#6  0x000000000049f66c in PyObject_VectorcallMethod ()
#7  0x00000000005d21e4 in ?? ()
#8  0x00000000005d213c in ?? ()
#9  0x00000000005d1ed4 in ?? ()
#10 0x00000000004985ec in _PyObject_MakeTpCall ()
#11 0x00000000004a7734 in _PyEval_EvalFrameDefault ()
#12 0x000000000049ccb4 in _PyObject_FastCallDictTstate ()
#13 0x00000000004ebce8 in ?? ()
#14 0x00000000004985ec in _PyObject_MakeTpCall ()
#15 0x00000000004a7734 in _PyEval_EvalFrameDefault ()
#16 0x00000000005bee10 in ?? ()
#17 0x0000ffff7ee1f5dc in ?? () from /container/.../_asyncio.cpython-312-aarch64-linux-gnu.so
#18 0x0000ffff7ee1fd94 in ?? () from /container/.../_asyncio.cpython-312-aarch64-linux-gnu.so

Мы дизассемблировали аварийно завершающуюся функцию getenv. Поскольку мы знали, что для сборки контейнеров применяется GLIBC 2.17, мы также отыскали соответствующий исходный код getenv, чтобы его можно было проследить [7]:

/* ... примечание: переформатировано для краткости ... */
char * getenv (const char *name) {
  size_t len = strlen (name);
  char **ep;
  uint16_t name_start;

  if (__environ == NULL || name[0] == '\0')
    return NULL;

  if (name[1] == '\0') {
    /* Имя переменной состоит всего из одного символа. Следовательно, первые два символа в записи окружения — этот символ и символ '='.  */
    name_start = ('=' << 8) | *(const unsigned char *) name;
    for (ep = __environ; *ep != NULL; ++ep) {
        uint16_t ep_start = (((unsigned char *) *ep)[0]
                           | (((unsigned char *) *ep)[1] << 8));
      if (name_start == ep_start)
        return &(*ep)[2];
    }
  } else {
    name_start = (((const unsigned char *) name)[0]
      | (((const unsigned char *) name)[1] << 8));
    len -= 2;
    name += 2;

    for (ep = __environ; *ep != NULL; ++ep) {
      uint16_t ep_start = (((unsigned char *) *ep)[0]
                           | (((unsigned char *) *ep)[1] << 8));
      if (name_start == ep_start && !strncmp (*ep + 2, name, len)
          && (*ep)[len + 2] == '=')
        return &(*ep)[len + 3];
    }
  }

  return NULL;
}
 (gdb) disassemble getenv
Dump of assembler code for function getenv:
    0x0000ffff805a3de4 <+0>:     stp     x29, x30, [sp, #-64]!
    0x0000ffff805a3de8 <+4>:     mov     x29, sp
    0x0000ffff805a3dec <+8>:     stp     x19, x20, [sp, #16]
    0x0000ffff805a3df0 <+12>:    stp     x21, x22, [sp, #32]
    0x0000ffff805a3df4 <+16>:    stp     x23, x24, [sp, #48]
    0x0000ffff805a3df8 <+20>:    mov     x22, x0
    0x0000ffff805a3dfc <+24>:    bl      0xffff805f2784 <strlen>
    0x0000ffff805a3e00 <+28>:    mov     x24, x0
    0x0000ffff805a3e04 <+32>:    adrp    x0, 0xffff806eb000
    0x0000ffff805a3e08 <+36>:    ldr     x0, [x0, #3704]
    0x0000ffff805a3e0c <+40>:    ldr     x20, [x0]
    0x0000ffff805a3e10 <+44>:    cbz     x20, 0xffff805a3ed8 <getenv+244>
    0x0000ffff805a3e14 <+48>:    ldrb    w1, [x22]
    0x0000ffff805a3e18 <+52>:    cbz     w1, 0xffff805a3ed0 <getenv+236>
    0x0000ffff805a3e1c <+56>:    ldrb    w21, [x22, #1]
    0x0000ffff805a3e20 <+60>:    ldr     x19, [x20]
    0x0000ffff805a3e24 <+64>:    cbnz    w21, 0xffff805a3e70 <getenv+140>
    0x0000ffff805a3e28 <+68>:    mov     w21, #0x3d00                    // #15616
    0x0000ffff805a3e2c <+72>:    orr     w21, w1, w21
    0x0000ffff805a3e30 <+76>:    cbnz    x19, 0xffff805a3e40 <getenv+92>
    0x0000ffff805a3e34 <+80>:    b       0xffff805a3e58 <getenv+116>
    0x0000ffff805a3e38 <+84>:    ldr     x19, [x20, #8]!
    0x0000ffff805a3e3c <+88>:    cbz     x19, 0xffff805a3e58 <getenv+116>
    0x0000ffff805a3e40 <+92>:    ldrb    w1, [x19, #1]
    0x0000ffff805a3e44 <+96>:    ldrb    w0, [x19]
    0x0000ffff805a3e48 <+100>:   orr     w0, w0, w1, lsl #8
    0x0000ffff805a3e4c <+104>:   cmp     w21, w0
    0x0000ffff805a3e50 <+108>:   b.ne    0xffff805a3e38 <getenv+84>  // b.any
    0x0000ffff805a3e54 <+112>:   add     x19, x19, #0x2
    0x0000ffff805a3e58 <+116>:   mov     x0, x19
    0x0000ffff805a3e5c <+120>:   ldp     x21, x22, [sp, #32]
    0x0000ffff805a3e60 <+124>:   ldp     x19, x20, [sp, #16]
    0x0000ffff805a3e64 <+128>:   ldp     x23, x24, [sp, #48]
    0x0000ffff805a3e68 <+132>:   ldp     x29, x30, [sp], #64
    0x0000ffff805a3e6c <+136>:   ret
    0x0000ffff805a3e70 <+140>:   orr     w21, w1, w21, lsl #8
    0x0000ffff805a3e74 <+144>:   sxth    w21, w21
    0x0000ffff805a3e78 <+148>:   sub     x23, x24, #0x2
    0x0000ffff805a3e7c <+152>:   add     x22, x22, #0x2
    0x0000ffff805a3e80 <+156>:   cbnz    x19, 0xffff805a3e90 <getenv+172>
    0x0000ffff805a3e84 <+160>:   b       0xffff805a3e58 <getenv+116>
    0x0000ffff805a3e88 <+164>:   ldr     x19, [x20, #8]!
    0x0000ffff805a3e8c <+168>:   cbz     x19, 0xffff805a3e58 <getenv+116>
 => 0x0000ffff805a3e90 <+172>:   ldrb    w4, [x19, #1]
    0x0000ffff805a3e94 <+176>:   ldrb    w3, [x19]
    0x0000ffff805a3e98 <+180>:   orr     w3, w3, w4, lsl #8
    0x0000ffff805a3e9c <+184>:   cmp     w21, w3, sxth
    0x0000ffff805a3ea0 <+188>:   b.ne    0xffff805a3e88 <getenv+164>  // b.any
    0x0000ffff805a3ea4 <+192>:   add     x0, x19, #0x2
    0x0000ffff805a3ea8 <+196>:   mov     x1, x22
    0x0000ffff805a3eac <+200>:   mov     x2, x23
    0x0000ffff805a3eb0 <+204>:   bl      0xffff805f2a44 <strncmp>
    0x0000ffff805a3eb4 <+208>:   cbnz    w0, 0xffff805a3e88 <getenv+164>
    0x0000ffff805a3eb8 <+212>:   ldrb    w0, [x19, x24]
    0x0000ffff805a3ebc <+216>:   cmp     w0, #0x3d
    0x0000ffff805a3ec0 <+220>:   b.ne    0xffff805a3e88 <getenv+164>  // b.any
    0x0000ffff805a3ec4 <+224>:   add     x24, x24, #0x1
    0x0000ffff805a3ec8 <+228>:   add     x19, x19, x24
    0x0000ffff805a3ecc <+232>:   b       0xffff805a3e58 <getenv+116>
    0x0000ffff805a3ed0 <+236>:   mov     x19, #0x0                       // #0
    0x0000ffff805a3ed4 <+240>:   b       0xffff805a3e58 <getenv+116>
    0x0000ffff805a3ed8 <+244>:   mov     x19, x20
    0x0000ffff805a3edc <+248>:   b       0xffff805a3e58 <getenv+116>
    0x0000ffff805a3eb8 <+212>:   ldrb    w0, [x19, x24]
    0x0000ffff805a3ebc <+216>:   cmp     w0, #0x3d
    0x0000ffff805a3ec0 <+220>:   b.ne    0xffff805a3e88 <getenv+164>  // b.any
    0x0000ffff805a3ec4 <+224>:   add     x24, x24, #0x1
    0x0000ffff805a3ec8 <+228>:   add     x19, x19, x24
    0x0000ffff805a3ecc <+232>:   b       0xffff805a3e58 <getenv+116>
    0x0000ffff805a3ed0 <+236>:   mov     x19, #0x0                       // #0
    0x0000ffff805a3ed4 <+240>:   b       0xffff805a3e58 <getenv+116>
    0x0000ffff805a3ed8 <+244>:   mov     x19, x20
    0x0000ffff805a3edc <+248>:   b       0xffff805a3e58 <getenv+116>
 End of assembler dump.

Уф, значит, аварийное завершение происходит во врем�� загрузки байта и в ходе поиска интересующей нас переменной окружения.

Можно вывести дамп актуального состояния всех регистров:

 (gdb) info reg
...
x19            0x220               544
x20            0x248b5000          613109760
...
sp             0xffffddd93c80      0xffffddd93c80
pc             0xffff805a3e90      0xffff805a3e90

Итак, getenv отказывала, пытаясь загрузить данные из недействительного региона памяти (0x220 – очевидно, такого значения быть не может). Но как?

Что же происходило?

Поначалу нас это озадачило. Отказ происходил глубоко в libc. Мы подозревали, дело может быть в том, что переменная окружения просто повреждена — учитывая, что происходил вызов getenv, но для дальнейшего расследования нам не хватало информации.

Мы принялись проверять блок окружения при помощи gdb.

Напомню: в соответствии со стандартом POSIX [8] environ определяется как char ** и фактически представляет собой список указателей на строки окружения, а конец списка отмечается как NULL-указатель.

 (gdb) x/s ((char**) environ)[0]
0xffffddd95e6a: "GITHUB_STATE=/github/file_commands/save_state_0e5b7bd6-..."
...
(gdb) x/s ((char**) environ)[66]
0xffff6401f0b0: "SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"
(gdb) x/s ((char**) environ)[67]
0xffff6401f8d0: "SSL_CERT_DIR=/etc/ssl/certs"
(gdb) x/s ((char**) environ)[68]
0x0:    <error: Cannot access memory at address 0x0>
<etc>

Но ведь это нонсенс — мы наблюдаем решительно невозможную операцию загрузку из пространства памяти, а окружение ведёт себя так, как будто эта операция совершенно валидна и непротиворечива. Кстати, а почему мы вообще вызываем здесь getenv?

А потом пожаловал Юрий и скинул в комментарии ссылку на один старый пост:

<yury> Кажется, что некоторые операции, относящиеся к вводу/выводу, выливаются в ошибки, а Python пытается при помощи PyErr_SetFromErrnoWithFilenameObjects сконструировать из errno исключение

 

<yury> По-видимому, эта функция отмечается на gettext (заготовка для трансляции?)

       И дальше идёт в getenv

 

<yury> Возможно, именно поэтому getenv не потокобезопасна

       https://rachelbythebay.com/w/2017/01/30/env/

Вот где собака зарыта: setenv и getenv

Функцию setenv небезопасно вызывать в многопоточной среде. Зачастую это представляет проблему, и данный феномен то и дело переоткрывается, когда наш брат-разработчик сталкивается с таинственными отказами функции getenv из libc [9][10][11][12].

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

Почитав дизассемблированный код и соотнеся его с кодом на C, мы определили, что регистр x20 соответствует переменной ep. Это указатель, используемый для обхода массива environ. Но оказалось, что x20 соответствует 0x248b5000, а environ — 0x28655750, почти на 60 мегабайт позже в памяти.

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

 (gdb) x/100g (char**)environ
0x28655750:     0x0000ffffddd95e6a      0x0000ffffddd95ebd
...
0x28655930:     0x0000ffffddd96f34      0x0000ffffddd96f6e
0x28655940:     0x0000ffffddd96fa5      0x0000ffffddd96fc3
0x28655950:     0x0000000024c1f710      0x0000000025213a70
0x28655960:     0x0000ffff6401f0b0      0x0000ffff6401f8d0
0x28655970:     0x0000000000000000      0x0000000000003401
(gdb) x/20g $x20-40
0x248b4fd8:     0x0000ffffddd96f6e      0x0000ffffddd96fa5
0x248b4fe8:     0x0000ffffddd96fc3      0x0000000024c1f710
0x248b4ff8:     0x0000000025213a70      0x0000000000000220
0x248b5008:     0x0000000000000020      0x0000ffff7f5192a8
0x248b5018:     0x0000000000000000      0x000000000a000150
0x248b5028:     0x0000000000000031      0x0000ffff7f5192b8
0x248b5038:     0x0000000000000000      0x000000000a0001c6
0x248b5048:     0x000000000094af78      0x0000000000000030
0x248b5058:     0x0000000000000041      0x0000000000000000
0x248b5068:     0x0000000000000000      0x0000000000000000

Интересно! Значения указателей в двух областях памяти очень похожи! А где они начинают различаться? Это последние записи по адресам 0x0000ffff6401f0b0 и 0x0000ffff6401f8d0: они соответствуют SSL_CERT_FILE=... и SSL_CERT_DIR=...!

Всё это явно подсказывает, что мы верно догадывались насчёт гонки данных, и другой поток перемещал environ в рамках setenv! При рассмотрении setenv казалось, что блок окружения заключён в слишком тесной области памяти и, возможно, имела место повторная аллокация, чтобы в памяти уместились новые переменные [13].

При этом мы всё ещё не выяснили, какой код вызывает setenv. Казалось возможным, что обвал происходит из-за OpenSSL и/или какой-то другой зависимости reqwest, относящейся к TLS (rust-native-tls), но как?

Подключение к openssl_probe

Погуглив эти переменные окружения в связке с rust-native-tls, мы выудили старинную проблему: [14]. А в одном из комментариев скрывалось следующее:

Не уверен насчёт openssl. Создаётся впечатление, как будто сейчас он загружает системные сертификаты при помощи библиотеки openssl-probe, и при этом устанавливаются переменные окружения SSL_CERT_FILE и SSL_CERT_DIR, а после этого в дело вступает SslConnector::builder, вызывающий ctx.set_default_verify_paths, который и заглядывает в эти переменные окружения.

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

Интересно. Итак, openssl-probe устанавливает эти переменные. Причём, само собой, под Linux мы пользуемся серверным интерфейсом rust-native-tls openssl, из которого идут вызовы в эти функции!

Вот совершенно безобидные на вид строки из библиотеки openssl-probe, в которых просто не стоит unsafe [15]:

pub fn try_init_ssl_cert_env_vars() -> bool {
    let ProbeResult { cert_file, cert_dir } = probe();
    // мы не собираемся затирать имеющиеся переменные окружения,
    // поскольку, если они валидные, то probe() вернёт их 
    // в неизменном виде
    if let Some(path) = &cert_file {
        env::set_var(ENV_CERT_FILE, path);
    }
    if let Some(path) = &cert_dir {
        env::set_var(ENV_CERT_DIR, path);
    }

    cert_file.is_some() || cert_dir.is_some()
 }

Именно так мы и нарвались на аварийное завершение. Оно происходило из-за кода на Rust, в котором отсутствуют unsafe, и который патологически взаимодействует с libc где-то в другой точке программы.

В качестве отступления: что же такое RISC?

Притом, что оба мы имеем опыт обратной разработки, Мэтт был растренирован в работе с aarch64, a Салли этого вообще не умел. Поэтому мы некоторое время вместе потупили, рассматривая один из главных циклов в ассемблере. По-видимому, в коде был расчёт на то, что значение x20 изменится, и этот регистр наиболее явственно просился на представление ep, но он, по-видимому, не фигурировал в левой части ни одной из инструкций.

И тут мы заметили любопытный восклицательный знак:

0x0000ffff805a3e88 <+164>:   ldr     x19, [x20, #8]!

Оказывается, это «пре-индексный» режим адресации, действующий по принципу x19 = *(x20 + 8); x20 = x20 + 8 [16].

Такой маленький миленький оператор, но мы достаточно повидали и помним, что нам рассказывали: режимы адресации с автоматическим инкрементом — это наследие старинных машин CISC, таких как VAX. Без них обходились даже более современные машины из категории CISC, например, x86, и уж конечно элегантные и простые архитектуры RISC. Полагаю, как раз тот случай, когда всё новое — хорошо забытое старое.

(Апдейт: ну, на самом деле, не такое уж новое. В ARM это существует с самого начала, RISC, думаю, опоздал примерно на неделю).

Так почему же только на ARM64 и на Linux?

Поскольку данная авария возникает из-за realloc, перемещающей память, а сама эта ситуация провоцируется setenv, и всё это происходит в тот самый момент, когда другой поток вызывает getenv. Этот пазл складывается сразу из множества кусочков. Переменных окружения должно быть ровно столько, чтобы потребовалась повторная аллокация. Отказ ввода/вывода — отдельная проблема, но он подхватывается asyncio, и здесь требуется вызвать getenv, которая, в свою очередь, извлечёт переменную окружения LANGUAGE в самый неподходящий момент.

Значение 0x220 подозрительно напоминает размер старого окружения, выраженный в 64-разрядных словах (0x220 / 8 = 68), и этим значением затирался завершающий NULL блока окружения ещё до того, как он был перемещён. Вероятно, это делалось, чтобы указать функц��и malloc размер свободного блока. Но при этом программа как раз минировалась опасным невалидным указателем, и на нём подрывались жертвы, испытывавшие на себе использование после высвобождения.

Учитывая все эти предусловия, нам ещё повезло, что удалось в основном воспроизвести данный отказ на отдельно взятой платформе.

Исправление

В итоге мы решили переезжать с серверного интерфейса rust-native-tls/openssl от reqwest  на rustls под Linux. Исходно мы полагали, что, пользуясь нативным TLS-бекэндом, мы обойдёмся без одновременного применения двух TLS-движков в процессе портирования кода с Python на Rust. Столкнувшись с этой проблемой, мы решили, что в краткосрочной перспективе работать одновременно с двумя движками вполне нормально.

Был и другой вариант: на первый раз вызывать try_init_ssl_cert_env_vars, удерживая в Python глобальную блокировку интерпретатора (та самая GIL, которой вас пугали). В Rust предусмотрена внутренняя блокировка, предотвращающая гонки между фрагментами кода Rust, одновременно читающими и пишущими окружение. Но эта блокировка не мешает коду из других языков напрямую использовать libc. Удерживая GIL, мы, как минимум, страховались от гонки с нашими Python-потоками.

В проекте Rust эта проблема уже зафиксирована, и в версии 2024 планировалось сделать небезопасными функции, устанавливающие окружение [17]. В проекте glibc также (совсем недавно) усилили потокобезопасность в getenv, избегая использования realloc и просачивания в более старые окружения [18].