PHP FFI виявився не тим інструментом: міст у дочірньому процесі для Telegram VoIP
Як фоновий потік GLib ламав кучу Zend у проді, чому ми винесли ntgcalls у дочірній процес, що спілкується JSON-рядками через канали, і два взаємні блокування плюс одне шестикратне прискорення на шляху до односекундних VoIP-дзвінків у Telegram.
- Автор
- WardenPoint team
- Опубліковано
- 11 черв. 2026 р.
- Читання ~ хв
- 5
О другій ночі Horizon-воркер на проді помер посеред дзвінка. У лозі — два слова: zend_mm_heap corrupted. Жодного стектрейсу, жодного винятку, який можна впіймати. Купа памʼяті Zend просто перетворилася на сміття, і процес, який тримав сесію MadelineProto, блокування в Redis і стан черги, зник. Так почалася історія про те, чому PHP не може «просто викликати C-бібліотеку» — і що ми збудували замість прямого виклику.
Що відбувається, коли WardenPoint дзвонить у Telegram
WardenPoint робить справжні VoIP-дзвінки в Telegram, коли сповіщення мусить розбудити чергового, а не тихо впасти в стрічку. Під капотом дзвінок складається з двох половин. Сигналінг — це MTProto: phone.requestCall, обмін ключами, phone.sendCallSignalingData для пересилання ICE-кандидатів, phone.discardCall наприкінці. Цю частину веде MadelineProto — зрілий MTProto-клієнт на PHP. Медіа — звичайний WebRTC: ICE, DTLS, SRTP, Opus. Єдина серйозна бібліотека для цього поза офіційними клієнтами Telegram — ntgcalls, C++-стек із чистим C ABI і готовими динамічними бібліотеками.
PHP і C-бібліотека в одному процесі — здавалося б, класична задача для FFI.
Чому FFI провалився: чужий потік у нашому процесі
На стенді все працювало. ntg_init(), ntg_create_p2p(), зворотні виклики (колбеки) зареєстровані, тестовий дзвінок зʼєднався. Ми випустили це за feature-прапорцем і стали дивитися на перші бойові дзвінки. Воркер помер посеред дзвінка. Двічі. То з «Maximum call stack size reached during compilation», то з zend_mm_heap corrupted — два обличчя одного збою.
Причина архітектурна, її не можна «полагодити патчем». ntgcalls запускає GLib main loop на фоновому потоці й викликає кожен зворотний виклик саме з нього: вхідні байти сигналінгу, зміни стану зʼєднання, завершення медіапотоку. А PHP NTS — збірка, на якій працюють FPM і воркери черг, — не потокобезпечний. Коли чужий потік викликає PHP-замикання, він чіпає лічильники посилань zval і алокатор Zend поза моделлю світу рушія. Іноді це сходить з рук. Під навантаженням купа рветься.
Ми шукали запасний вихід: якусь pump-функцію на кшталт ntg_main_iteration(), що дозволила б PHP прокручувати цикл подій із власного потоку. У C-заголовку — всі його 381 рядок, і у версії 2.2.2, і в master — нічого подібного немає. Цикл прибитий до фонового потоку навмисно: для Python це нормальна модель, для PHP NTS — фатальна.
Перший урок: FFI — це домовленість про виклики, а не межа ізоляції. Якщо нативна бібліотека володіє потоками, ваш процес тепер теж багатопотоковий — незалежно від того, чи готовий до цього рантайм.
Міст: окремий процес замість спільної памʼяті
Рішення — перестати ділити адресний простір. Ми написали wp-ntgcalls-bridge, невеликий C++-бінарник на кілька сотень рядків, який повністю володіє екземпляром ntgcalls та його GLib-потоком. PHP спілкується з ним через найстаріший IPC на світі — stdin/stdout, один JSON-обʼєкт на рядок:
→ {"cmd":"skip_exchange","cmd_id":3,"args":{"user_id":1234,"auth_key":"…"}}
← {"event":"async_result","cmd_id":3,"error_code":0}
← {"event":"signaling_outbound","bytes":"<base64 ICE blob>"}
← {"event":"connection_state","state":"Connected"}
Команд жменя: create_p2p, skip_exchange, connect_p2p, send_signaling, stop. Подій три: signaling_outbound, connection_state, stream_end. Один процес моста на один дзвінок: proc_open на старті, прибирання у finally.
Ізоляція окупається саме на збоях. Коли bridge падає — а на початку він падав, — PHP бачить EOF на каналі, позначає сповіщення як failed, і воркер з усім своїм добром (сесія MadelineProto, блокування, черга) виживає. Один невдалий дзвінок замість одного пошкодженого процесу.
Бойова історія перша: взаємне блокування на 64 КБ
Перший бойовий дзвінок після рефакторингу: абонент прийняв виклик — і все завмерло. Воркер висить, bridge живий, нічого не рухається.
Розбір читається як підручник із теми «взаємне блокування» (дедлок). Під час ICE-узгодження ntgcalls видає сплеск із 30–50 вихідних подій сигналінгу, кожна близько 5 КБ. Наш код запису в stdout утримував мʼютекс на час fwrite + fflush. Канал (pipe) у Linux за замовчуванням буферизує 64 КБ — приблизно тринадцята подія його заповнила. fflush заблокувався, не випустивши мʼютекс із рук. Головному потоку моста цей самий мʼютекс був потрібен, щоб підтвердити наступну команду, тож він перестав читати stdin. PHP тим часом писав команди далі, доки не заповнився і буфер stdin, — і його fwrite завис теж. Два процеси, чотири кінці каналів, нуль прогресу.
Виправлення — один системний виклик:
fcntl(STDOUT_FILENO, F_SETPIPE_SZ, 1 << 20); // 64 КБ -> 1 МБ
Мегабайт вміщує близько 200 подій сплеску — із запасом понад будь-який реалістичний ICE-шторм. А в коді запису ми залишили замір затримок за debug-прапорцем, щоб наступна проблема зі зворотним тиском показала себе рядком у лозі, а не замороженим воркером.
Другий урок: розмір буфера каналу в ядрі — частина вашого API-контракту. Якщо протокол може давати сплески, буфер має вміщувати сплеск — або потрібен справжній механізм зворотного тиску.
Бойова історія друга: 100 мс × 50 блобів
Блокування зникло, дзвінки стабільні. Але між натисканням «Прийняти» і першим звуком минало близько шести секунд. Для голосового виклику це відчувається як «зламано».
Перша інтуїтивна «оптимізація» — не пускати звук до стану Connected і підкладати тишу на початку — зробила гірше: девʼять секунд. Відкотили за годину. Після цього ми перестали вгадувати й інструментували кожен крок: тайминги ітерацій у циклі підключення, тайминги кожного блоба в пересиланні сигналінгу.
Числа показали геть не на мережу. Кожен вихідний ICE-кандидат, який видавав bridge, пересилався в Telegram синхронно: bridge → PHP → MadelineProto → phone.sendSignalingData, повний MTProto round-trip — близько 100 мс на блоб. Пʼятдесят кандидатів послідовно — пʼять секунд затримки, яку ми створили собі власноруч, тоді як справжнє DTLS-рукостискання залюбки вклалося б в одну.
ICE не вимагає порядку доставки: інша сторона збирає кандидатів у будь-якій послідовності. Тож пересилання стало fire-and-forget («надіслав і забув») на циклі подій Revolt, який MadelineProto і так крутить:
public function sendOutboundSignaling(string $data): void
{
async(function () use ($data): void {
$this->API->methodCallAsyncRead('phone.sendSignalingData', [
'peer' => $this->inputCallPeer,
'data' => $data,
]);
});
}
Результат у цифрах із продових таймингів:
| Метрика | До | Після |
|---|---|---|
| Пересилання одного блоба | ~100 мс | 1–3 мс |
| Прохід обробки подій | 4–5 с | 30–60 мс |
| Очікування стану Connected | 5–7 с | 0,7–1,9 с |
| Від «Прийняти» до звуку | ~6 с | ~1 с |
Третій урок: спочатку виміряй, потім оптимізуй. Інтуїція звинувачувала DTLS через мобільну мережу. Секундомір звинуватив наш власний синхронний цикл.
Що ми сказали б собі рік тому
- Для нативних бібліотек із власними потоками ізоляція процесом краща за FFI. Бібліотека з власним циклом подій — це фактично окрема програма. Межу варто проводити там, де вона існує насправді.
- JSON-рядки через канал — достатній протокол. Без gRPC, без спільної памʼяті, без сокетів, які можна забути закрити.
proc_open, два канали, рамка «один обʼєкт на рядок». Налагоджується черезcat. - Залишайте інструментацію в проді. Кожне число в цьому тексті взяте з рядків логів, які досі працюють у проді за debug-прапорцем. Наступна регресія оголосить про себе сама.
- Fire-and-forget коректний тоді, коли протокол дозволяє перестановку. ICE толерантний до зміни порядку; чекати підтвердження на кожне повідомлення було чистим марновірством.
Сьогодні WardenPoint піднімає реальний Telegram-дзвінок приблизно за секунду після того, як черговий натиснув «Прийняти», звук іде з першого слова, а падіння медіастеку коштує нам одне сповіщення зі статусом failed замість одного зіпсованого воркера. Один запуск процесу на дзвінок — найдешевша страховка, яку ми купуємо.
Читайте далі
Повʼязані дописи

Telegram VoIP для операцій: сповіщення в обхід «Не турбувати», які не дратують одержувачів
Чому виклик Telegram VoIP — найдешевший надійний спосіб розбудити чергового без оплати оператора за повідомлення, скільки це коштує в масштабі (спойлер: нуль) і які режими відмови треба обійти.

Міграція з PagerDuty: покроковий план
Практична послідовність, якою ми ведемо клієнтів геть від PagerDuty без жодного втраченого алерту — служби, графіки, політики ескалації, інтеграції та вікно паралельного нагляду.

Проєктування чергувань — що ми зрозуміли, поки робили своє
Шість правил, до яких ми дійшли після двох років чергувань командою з 4 інженерів — передачі першої/другої лінії, довжина зміни на вихідних, пастка «follow the sun» і як її обійти.