PHP FFI to było złe narzędzie: most w procesie potomnym dla Telegram VoIP
Jak wątek GLib w tle uszkadzał stertę Zenda na produkcji, czemu przenieśliśmy ntgcalls do procesu potomnego rozmawiającego JSON-em przez potoki, oraz dwa zakleszczenia i jedno sześciokrotne przyspieszenie w drodze do jednosekundowych połączeń VoIP w Telegramie.
- Autor
- WardenPoint team
- Opublikowano
- 11 cze 2026
- Czytanie ok. min
- 5
Jedna linia w logu — zend_mm_heap corrupted — i martwy worker Horizona w środku pierwszego kanarkowego połączenia na produkcji. W testach dymnych ta sama integracja przechodziła bez zarzutu. Tak zaczęła się historia, w której wyrzuciliśmy «elegancki» binding PHP FFI, napisaliśmy kilkusetlinijkowy proces potomny w C++, dwa razy utknęliśmy w potoku (pipe) i ostatecznie zeszliśmy z sześciu sekund do jednej między «Odbierz» a pierwszym dźwiękiem. WardenPoint wykonuje prawdziwe połączenia VoIP w Telegramie, gdy eskalacja dochodzi do kroku «obudź dyżurnego» — a poniżej szczery dziennik inżynierski tego, jak ta funkcja powstawała naprawdę.
Co właściwie dzwoni, gdy dzwoni Telegram
Połączenie głosowe w Telegramie to dwie niezależne warstwy. Sygnalizacja jedzie po MTProto: phone.requestCall, wymiana kluczy Diffiego-Hellmana, phone.sendCallSignalingData przenoszące kandydatów ICE w obie strony i phone.discardCall na koniec. Tę połowę obsługuje u nas MadelineProto — dojrzały klient MTProto napisany w PHP. Media to już czysty WebRTC: ICE, DTLS, SRTP, kodek Opus. Jedyną poważną biblioteką poza oficjalnymi klientami Telegrama jest tu ntgcalls — stos C++ z czystym C ABI i gotowymi bibliotekami współdzielonymi.
PHP i biblioteka C. Od tego przecież jest FFI, prawda?
Dlaczego PHP nie może «po prostu wywołać» tej biblioteki
Binding FFI przeszedł testy dymne wzorowo: ntg_init(), ntg_create_p2p(), callbacki zarejestrowane, połączenie testowe zestawione. Wdrożyliśmy go za flagą i obserwowaliśmy pierwsze kanarkowe połączenia na produkcji. Worker umarł dwa razy, za każdym razem w innym przebraniu: raz z Maximum call stack size ... reached during compilation, raz właśnie z zend_mm_heap corrupted.
Przyczyna nie jest błędem do załatania — jest architekturą. ntgcalls uruchamia pętlę główną GLib na wątku w tle i z tego wątku wywołuje każdy callback: przychodzące bajty sygnalizacji, zmiany stanu połączenia, koniec strumienia. PHP NTS — wariant, na którym chodzi FPM i każdy worker kolejki — nie jest bezpieczny wątkowo. Gdy obcy wątek wywołuje domknięcie PHP, dotyka liczników referencji zval i alokatora Zenda spoza modelu świata, w którym żyje silnik. Czasami się udaje. Pod obciążeniem sterta idzie w strzępy.
Szukaliśmy furtki: funkcji w rodzaju ntg_main_iteration(), która pozwoliłaby PHP opróżniać pętlę z własnego wątku. Nagłówek C — całe 381 linii, zarówno w v2.2.2, jak i na masterze — nie eksportuje niczego takiego. Pętla jest przybita do wątku w tle celowo; dla Pythona (pytgcalls) to projekt w sam raz, dla PHP NTS — wyrok.
Lekcja pierwsza: FFI to konwencja wywołań, a nie granica izolacji. Jeśli natywna biblioteka ma własne wątki, twój proces też je ma — niezależnie od tego, czy twój runtime to przeżyje.
Osobny proces zamiast wspólnej pamięci
Skoro nie dało się bezpiecznie dzielić przestrzeni adresowej, przestaliśmy ją dzielić. wp-ntgcalls-bridge to mały program w C++ — kilkaset linii plus dokument protokołu — który w całości posiada instancję ntgcalls razem z jej wątkiem GLib. PHP rozmawia z nim najstarszym IPC świata: stdin/stdout, jeden obiekt JSON na linię.
→ {"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"}
Komend jest garść: create_p2p, skip_exchange, connect_p2p, send_signaling, stop. Zdarzeń trzy: signaling_outbound, connection_state, stream_end. Jeden proces mostu na jedno połączenie, startowany przez proc_open, ubijany w finally.
Izolacja zwraca się w trybach awarii. Gdy most dostaje segfaulta — a na początku dostawał — PHP widzi EOF na potoku, oznacza powiadomienie jako nieudane i na tym koniec. Worker z sesją MadelineProto, blokadami w Redisie i stanem kolejki żyje dalej, jakby nic się nie stało.
Historia z frontu nr 1: zakleszczenie na 64 kilobajtach
Pierwsze kanarkowe połączenie po refaktorze: odebrane — i wszystko zamarło. Worker wisi, most żyje, nic się nie rusza.
Analiza po incydencie czyta się jak podręcznikowy przykład zakleszczenia (deadlock). Podczas negocjacji ICE ntgcalls wyrzuca serię 30–50 zdarzeń sygnalizacji wychodzącej, po ~5 KB każde. Nasz writer na stdout trzymał mutex przez cały czas trwania fwrite + fflush. Potok w Linuksie buforuje domyślnie 64 KB — zapełniło go mniej więcej trzynaste zdarzenie. fflush zablokował się, nie wypuszczając mutexa. Główny wątek mostu potrzebował tego samego mutexa, żeby potwierdzić kolejną komendę, więc przestał czytać stdin. PHP pisał komendy dalej, aż zapełnił się także bufor stdin i jego fwrite stanął. Dwa procesy, cztery końce potoków, zero postępu.
Naprawa to dosłownie jedno wywołanie systemowe:
fcntl(STDOUT_FILENO, F_SETPIPE_SZ, 1 << 20); // 64 KB -> 1 MB
Megabajt mieści około 200 zdarzeń z serii — z zapasem ponad każdą realistyczną burzę kandydatów ICE. W writerze zostawiliśmy przy okazji instrumentację opóźnień, schowaną za flagą debug konkretnego klienta: następny problem z backpressure pokaże się jako linia w logu, a nie jako zawieszony worker.
Lekcja druga: bufor potoku w jądrze jest częścią kontraktu twojego API. Jeśli protokół potrafi strzelać seriami, zwymiaruj bufor pod serię albo zbuduj prawdziwy mechanizm backpressure.
Historia z frontu nr 2: 100 ms razy 50 blobów
Zakleszczenie zniknęło, połączenia były stabilne — ale odbiorca czekał około 6 sekund między dotknięciem «Odbierz» a pierwszym dźwiękiem. W rozmowie głosowej to odczucie zepsutego produktu.
Pierwsza, intuicyjna «optymalizacja» — nie podawać audio przed stanem Connected, początek dopchać ciszą — pogorszyła wynik do 9 sekund. Zmierzyliśmy, wycofaliśmy w godzinę i przestaliśmy zgadywać. Zamiast tego: timery na każdą iterację pętli łączenia, timery na każdy blob w przekaźniku sygnalizacji.
Liczby pokazały w stronę przeciwną do sieci. Każdy wychodzący kandydat ICE wyprodukowany przez most leciał do Telegrama synchronicznie: most → PHP → IPC MadelineProto → pełny round-trip MTProto phone.sendSignalingData. Każdy przeskok: ~100 ms. Pięćdziesiąt kandydatów po kolei — pięć sekund opóźnienia wyprodukowanego własnoręcznie wewnątrz naszej własnej pętli odpytywania, podczas gdy sam handshake DTLS spokojnie zamknąłby się w jednej.
ICE nie wymaga kolejności — peer składa kandydatów tak, jak przyjdą. Przekaźnik przeszedł więc na fire-and-forget na pętli zdarzeń Revolt, na której MadelineProto i tak działa:
public function sendOutboundSignaling(string $data): void
{
async(function () use ($data): void {
$this->API->methodCallAsyncRead('phone.sendSignalingData', [
'peer' => $this->inputCallPeer,
'data' => $data,
]);
});
}
Wyniki z produkcyjnych pomiarów, przed i po:
| Metryka | Przed | Po |
|---|---|---|
| Przekazanie jednego bloba | ~100 ms | 1–3 ms |
| Przebieg obsługi zdarzeń | 4–5 s | 30–60 ms |
| Oczekiwanie na Connected | 5–7 s | 0,7–1,9 s |
| «Odbierz» → dźwięk | ~6 s | ~1 s |
Lekcja trzecia: najpierw pomiar, potem optymalizacja. Intuicja obwiniała DTLS na łączu komórkowym. Stoper obwinił naszą własną synchroniczną pętlę.
Co powiedzielibyśmy sobie sprzed kwartału
- Dla natywnych bibliotek z własnymi wątkami izolacja procesem bije FFI. Granica, którą rysujesz, powinna pokrywać się z granicą, która istnieje naprawdę — a biblioteka z własną pętlą zdarzeń to w praktyce osobny program.
- Linie JSON przez potok to wystarczający protokół. Bez gRPC, bez pamięci współdzielonej, bez gniazd, które można wyciec.
proc_open, dwa potoki, ramkowanie znakiem nowej linii — i całość debugowalna zwykłymcat. - Zostawiajcie instrumentację na produkcji. Każda liczba w tym tekście pochodzi z linii logu, które wciąż działają, schowane za flagą debug. Następna regresja zgłosi się sama.
- Fire-and-forget jest poprawny, gdy protokół na to pozwala. ICE toleruje zmianę kolejności; czekanie na potwierdzenie każdej wiadomości było czystym przesądem.
Efekt końcowy: WardenPoint dzwoni do dyżurnego prawdziwym połączeniem VoIP Telegrama, dźwięk startuje około sekundę po odebraniu — od pierwszego słowa — a awaria stosu medialnego kosztuje jedno nieudane powiadomienie zamiast jednego martwego workera. Cena, czyli jedno uruchomienie procesu na połączenie, to najtańsze ubezpieczenie, jakie kupujemy.
Czytaj dalej
Powiązane wpisy

Telegram VoIP dla operacji: powiadamianie omijające tryb «nie przeszkadzać», którego nie znienawidzą odbiorcy
Dlaczego rozmowa Telegram VoIP to najtańszy niezawodny sposób wybudzenia dyżurnego bez opłat operatorów per wiadomość, ile kosztuje w skali (spoiler: zero) i obejścia trybów awaryjnych, o których trzeba pamiętać.

Migracja z PagerDuty: praktyczny plan działania
Praktyczna sekwencja, którą prowadzimy klientów przez wyjście z PagerDuty bez utraty ani jednego alertu — usługi, harmonogramy, polityki eskalacji, integracje oraz okno równoległego nadawania.

Projektowanie dyżurów — wnioski z budowy własnego
Sześć zasad, do których doszliśmy po dwóch latach dyżurowania w 4-osobowym zespole operacyjnym — przekazania pierwszej/drugiej linii, długość zmiany weekendowej, pułapka «follow the sun» i jak ją obejść.