Мы работаем над базой данных 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].

Комментарии (3)


  1. efnez
    30.01.2025 22:04

    Перевод ужасный, но всё равно спасибо.


  1. dersoverflow
    30.01.2025 22:04

    Функцию setenv небезопасно вызывать в многопоточной среде. Зачастую это представляет проблему, и данный феномен то и дело переоткрывается

    гмм... гуглим setenv и видим:

    MT-Unsafe

    POSIX.1 does not require setenv() or unsetenv() to be reentrant.

    функцию setenv небезопасно вызывать в многопоточной среде? да ладно!


  1. kos_s
    30.01.2025 22:04

    Rust не серебряная пуля? Удивительно.
    Более интересно, есть для Rust среда исполнения, в которой он был бы полностью изолирован от наследия libc / posix?