Server-Sent Events (SSE)
OxPHP diffuse des données en temps réel vers les clients via le protocole Server-Sent Events, avec une contre-pression intégrée. Définissez Content-Type: text/event-stream dans votre script PHP et appelez oxphp_stream_flush(). OxPHP se charge du reste.
Fonctionnement
Une réponse en streaming suit un cycle de vie prévisible :
- Définir les en-têtes. Votre script PHP définit
Content-Type: text/event-streamviaheader()et écrit des lignes au format SSE avececho. - Le premier flush ouvre le flux. Le premier appel à
oxphp_stream_flush()envoie les en-têtes HTTP au client et bascule en mode streaming. La connexion cliente reste ouverte. - Chaque flush envoie un chunk. Chaque appel suivant à
oxphp_stream_flush()vide la sortie mise en tampon sous forme d'un nouveau chunk, le livrant immédiatement au client. - La contre-pression s'active lorsque le tampon se remplit. OxPHP maintient un tampon interne pouvant contenir jusqu'à 64 chunks entre le worker PHP et le client. Lorsque le tampon est plein — parce qu'un client lent n'a pas consommé les chunks précédents —
oxphp_stream_flush()se bloque jusqu'à ce que de la place se libère. Cela évite une croissance illimitée de la mémoire. - Nettoyage à la sortie ou à la déconnexion. Lorsque le script PHP se termine, OxPHP ferme la connexion de manière gracieuse. Si le client se déconnecte en cours de flux, le script l'apprend au premier flush qui suit le moment où OxPHP constate la fermeture de la connexion. OxPHP lève le drapeau
connection_aborted()de PHP et, sauf si le script a appeléignore_user_abort(true), arrête le script à l'intérieur de ce flush : l'appel ne revient pas, et c'est ainsi que PHP arrête un script sous PHP-FPM lorsque sa sortie ne peut plus être écrite. Un script qui a appeléignore_user_abort(true)voit son flush revenir et continue de s'exécuter, et c'est la vérification deconnection_aborted()qui met fin à sa boucle (voir Détecter les déconnexions client).
Gardez des charges utiles d'événements réduites pour maintenir un débit régulier. Les charges utiles volumineuses peuvent remplir rapidement le tampon de 64 chunks, ce qui provoque un blocage de PHP à chaque flush.
Exemples
Flux SSE de base
<?php
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('Connection: keep-alive');
for ($i = 0; $i < 100; $i++) {
$data = json_encode(['counter' => $i, 'time' => microtime(true)]);
echo "id: {$i}\n";
echo "event: tick\n";
echo "data: {$data}\n\n";
oxphp_stream_flush();
sleep(1);
// Send a comment heartbeat every 15 seconds to keep proxies from closing idle connections
if ($i % 15 === 0) {
echo ": heartbeat\n\n";
oxphp_stream_flush();
}
}Vérifier l'état du streaming
Utilisez oxphp_is_streaming() pour vérifier si la requête courante est déjà en mode streaming. C'est utile dans un middleware ou des gestionnaires de requêtes partagés :
<?php
if (!oxphp_is_streaming()) {
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
}
echo "data: {\"status\": \"connected\"}\n\n";
oxphp_stream_flush();Détecter les déconnexions client
Un flux qui a envoyé ses en-têtes n'apprend le départ de son client qu'au moment où il effectue un flush. Par défaut, c'est à ce flush que le script s'arrête : oxphp_stream_flush() (ou flush()) ne revient pas, si bien qu'une condition while (!connection_aborted()) n'est plus jamais évaluée, et tous les blocs finally à l'intérieur desquels se trouvait le flush sont sautés. PHP-FPM arrête de la même façon un script dont la sortie ne peut plus être écrite.
Ce qui s'exécute encore, c'est la fin de la requête : les callbacks de register_shutdown_function(), dans lesquels connection_aborted() renvoie 1, et les destructeurs des objets qui disparaissent avec la requête. Contrairement à PHP-FPM, qui jette tout ce qu'ils écrivent, OxPHP arrête un callback à la première sortie qu'il envoie et saute les callbacks enregistrés après lui. Les destructeurs s'exécutent tout de même, et le premier d'entre eux à envoyer une sortie est arrêté de la même façon, les destructeurs prévus après lui étant sautés. Une sortie retenue dans un tampon de sortie (ouvert avec ob_start(), ou celui qu'ouvre output_buffering dans php.ini) n'est envoyée qu'au moment où ce tampon est vidé. Gardez ce nettoyage exempt de toute sortie, ou appelez ignore_user_abort(true) comme ci-dessous.
Pour faire le nettoyage dans le script lui-même, dans un bloc finally ou après la boucle, appelez ignore_user_abort(true) et laissez connection_aborted() mettre fin à la boucle. Le flush qui constate le départ du client revient alors, connection_aborted() renvoie 1 à partir de ce moment, et la boucle sort par sa condition :
<?php
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
// A stream outlives max_execution_time, which is 30 s unless the PHP configuration sets another value
set_time_limit(0);
// Keep running when the client leaves, so that the loop below sees it and ends
ignore_user_abort(true);
$db = new PDO(/* ... */);
try {
while (!connection_aborted()) {
echo "data: " . json_encode(['ts' => time()]) . "\n\n";
oxphp_stream_flush();
sleep(1);
}
} finally {
$db = null; // also runs when the loop ends on a disconnect
}Avec cet appel, la vérification de connection_aborted() est la seule chose qui met fin à la boucle : le script continue de s'exécuter après la déconnexion du client, ce qu'il écrit est jeté, et connection_aborted() signale la déconnexion. C'est le même contrat que sous n'importe quelle autre SAPI. Une telle boucle a besoin de sa propre borne : un flux qui a demandé à survivre à son client, et qui ni ne vérifie connection_aborted() ni ne se termine de lui-même, occupe son worker jusqu'à l'arrêt du serveur.
L'appel ne s'applique qu'à la requête qui le fait. La valeur avec laquelle une requête démarre compte pourtant de la même manière : ignore_user_abort = On dans php.ini, ou un ignore_user_abort(true) dans le bootstrap d'un script de worker (le code qui précède sa boucle de requêtes, par lequel commencent certains exemples de worker), constitue la base dont part chaque requête. Chaque flux servi dans ce cadre se comporte donc comme s'il avait fait l'appel, et a besoin de sa vérification de connection_aborted().
Utiliser flush() natif
Le flush() natif de PHP fonctionne aussi pour le streaming, mais il faut d'abord vider toutes les couches de tampon de sortie. Préférez oxphp_stream_flush() — il gère automatiquement les tampons de sortie et s'intègre au système de contre-pression d'OxPHP.
<?php
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
while (ob_get_level()) {
ob_end_clean();
}
for ($i = 0; $i < 100; $i++) {
echo "data: " . json_encode(['counter' => $i]) . "\n\n";
flush();
sleep(1);
}SSE en mode worker
SSE fonctionne aussi bien en mode standard qu'en mode worker. En mode worker, un flux n'occupe son thread de worker que pendant que son script s'exécute ou attend sans suspendre sa fibre, comme le fait un flush tant que le tampon de 64 chunks est plein. Pendant que le flux attend dans oxphp_sleep() ou sur une lecture passant par le hook d'exécution streams, le thread sert d'autres requêtes ; un thread de worker exécute jusqu'à 256 requêtes à la fois (voir Multiplexage de Fibers).
Dans une diffusion, chaque flux ouvert doit recevoir chaque événement ; l'exemple les lit donc depuis un Redis Stream plutôt que de les dépiler d'une liste. Lire un stream laisse ses entrées en place, et chaque lecteur conserve sa propre position, si bien que chaque client lit chaque événement que le stream contient encore. BRPOP sur une liste fonctionne à l'inverse : il retire l'élément qu'il renvoie, et cet élément va à une seule des connexions en attente sur la liste. C'est la bonne forme pour une file de tâches et la mauvaise pour une diffusion : avec deux flux ouverts, chaque événement n'atteindrait que l'un d'eux.
<?php
require __DIR__ . '/../vendor/autoload.php';
oxphp_worker(function () {
if (($_SERVER['HTTP_ACCEPT'] ?? '') !== 'text/event-stream') {
http_response_code(400);
echo json_encode(['error' => 'SSE only']);
return;
}
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
// A stream outlives max_execution_time, which is 30 s unless the PHP configuration sets another value
set_time_limit(0);
// One connection per stream: the blocking read below holds it for as long as the
// stream lives. The last argument is the read timeout, which has to exceed the
// 25 s the read blocks for.
$redis = new Redis();
$redis->connect('redis', 6379, 2.0, null, 0, 30.0);
// The newest entry in the stream. It also finds out whether the key holds a stream at all,
// while the answer can still be an error status: once the first frame is out the response is
// a 200, and a browser reconnects after every close of one.
$tail = $redis->xRevRange('events', '+', '-', 1);
if ($tail === false) {
error_log('SSE: ' . $redis->getLastError());
http_response_code(500);
return;
}
// Resume after the last event the browser saw (it sends the id back in Last-Event-ID).
// A first connection, or a header that is not a stream id, starts after the newest entry.
$last = $_SERVER['HTTP_LAST_EVENT_ID'] ?? '';
if (!preg_match('/^\d{1,19}-\d{1,19}$/', $last)) {
$last = $tail ? array_key_first($tail) : '0-0';
}
// Send the headers now; a quiet stream would otherwise send none until its first read returns.
// This named event carries the starting id, which the browser takes as its Last-Event-ID before
// the first event arrives; onmessage does not fire for it.
echo "event: ready\nid: {$last}\ndata:\n\n";
oxphp_stream_flush();
while (true) {
// Reading leaves the entries in the stream, so every open stream gets every one
$read = $redis->xRead(['events' => $last], 100, 25000);
if ($read === false) {
// Redis refused the read, the key having been replaced by a non-stream, say. End the stream;
// the browser reconnects and the check above answers it with an error status
return;
}
foreach ($read['events'] ?? [] as $id => $fields) {
echo "id: {$id}\ndata: {$fields['data']}\n\n";
$last = $id;
}
if (!$read) {
// No entry within 25 s — send heartbeat to keep the connection alive
echo ": heartbeat\n\n";
}
oxphp_stream_flush();
}
});Publiez depuis n'importe quel processus PHP (un gestionnaire de requête, un worker de file d'attente, un script CLI) :
$redis->xAdd('events', '*', ['data' => json_encode($payload)], 1000, true);Les deux derniers arguments plafonnent le stream à environ 1000 entrées (MAXLEN ~ 1000). Envoyez du JSON, ou toute autre donnée sans saut de ligne, comme data : l'exemple l'écrit sur une seule ligne data:.
Ce sur quoi repose l'exemple :
- L'id de l'entrée est l'id de l'événement. Un id comme
1790976033892-0part commeid:de l'événement, le navigateur renvoie le dernier qu'il a vu dansLast-Event-IDlorsqu'il se reconnecte, et le flux reprend après lui (voir Comportement à l'arrêt). - La première trame est un événement qui porte l'id de départ. Safari et Firefox ne retiennent un
id:commeLast-Event-IDdu navigateur que s'il vient d'une trame qui comporte une lignedata:. Unid:seul laisserait donc un navigateur coupé avant son premier événement sans aucun id à envoyer : il se reconnecterait, repartirait de l'entrée la plus récente et manquerait tout ce qui a été publié entre-temps. L'événementready, avec une lignedata:vide, lui en donne un.onmessagene se déclenche pas pour un événement nommé, alors queaddEventListener('ready', …)se déclenche ; une bibliothèque cliente qui transmet chaque événement à un unique callback doit l'ignorer. - Un flux qui ne peut pas démarrer reçoit un statut d'erreur, avant sa première trame. Redis refuse de lire une clé qui contient autre chose qu'un stream (une liste laissée par une ancienne file d'attente, par exemple), et aucun id ne rendrait cette lecture possible ; l'exemple le détecte donc dès sa première commande, journalise le message que renvoie Redis et répond
500. Il faut un statut plutôt qu'un flux qui se termine simplement : une fois la première trame partie, la réponse est un200, et un navigateur se reconnecte après chaque fermeture d'un flux200, toutes les quelques secondes tant que la page reste ouverte, à chaque fois avec une nouvelle connexion Redis et sans rien dans le journal. UnEventSourcene réessaie pas après un statut d'erreur ; la page reçoit un événementerroravecreadyStateàEventSource.CLOSEDet doit ouvrir elle-même un nouvelEventSourcesi elle en veut un. Il en va de même pour un Redis injoignable :connect()lève une exception, la réponse est un500, et l'EventSourceest fermé pour de bon. Une lecture que Redis refuse après le démarrage du flux (la clé remplacée par autre chose qu'un stream, par exemple) le termine par unreturn, et la reconnexion passe par la vérification ci-dessus. Sans cereturn, la boucle prendrait lefalsepour un délai expiré et n'enverrait que des heartbeats, à la chaîne. - Le format de
Last-Event-IDest vérifié. L'en-tête vient du client et peut contenir n'importe quoi, et Redis refuse une valeur qui n'est pas un id de stream,abcpar exemple. L'exemple traite une telle valeur comme un en-tête absent et repart après l'entrée la plus récente, comme le fait une première connexion. Le motif admet jusqu'à 19 chiffres de chaque côté du tiret, ce qui maintient chaque valeur acceptée dans la plage que Redis sait lire. - Un nouveau flux lit l'id le plus récent une seule fois. Il ne passe pas
$à chaque appel : Redis résout$en l'id le plus récent au moment de chaque appel, si bien que les entrées ajoutées entre deux appels seraient sautées. - L'historique est borné. Un lecteur dont le dernier événement a depuis été élagué (un navigateur qui était déconnecté, ou un flux retenu par un client lent pendant que plus d'entrées que le plafond étaient publiées) reprend à partir de la plus ancienne entrée encore présente. Les entrées élaguées entre-temps ne sont pas livrées, et rien ne le signale. Dimensionnez le plafond pour couvrir la plus longue interruption après laquelle vous voulez qu'un flux puisse reprendre.
- Chaque flux a sa propre connexion, avec un délai de lecture supérieur au temps de blocage. Sans l'argument de délai, phpredis se rabat sur
default_socket_timeout, et une lecture qui bloque plus longtemps que cela échoue avec uneRedisException. N'ouvrez pas une connexion par worker pour la confier à chaque flux : avec les hooks d'exécutionstreamsactivés, un worker exécute d'autres requêtes pendant qu'un flux attend sur une lecture, et une requête qui a utilisé une connexion la garde jusqu'à sa fin. Un second flux qui atteint la même connexion attend le premier. Si le premier est toujours ouvert lorsque l'attente expire (la borne vient demax_execution_timeet dedefault_socket_timeout; voir la section sur les hooks), phpredis lève uneRedisExceptionet le second flux échoue. - Le script désactive le minuteur d'exécution de PHP.
max_execution_timevaut 30 secondes sauf si la configuration PHP fixe une autre valeur, et l'image publiée n'embarque aucunphp.ini; un flux est censé durer plus longtemps. Si le minuteur reste actif, le script se termine sur une erreur fatale : le minuteur se déclenche au bout de 30 secondes et prend effet au retour de la lecture en cours, si bien qu'avec les lectures de 25 secondes de cet exemple, un flux silencieux se termine à la 50e seconde. En mode worker, l'appel désactive la limite de la requête du flux lui-même et d'aucune autre ; voir Mode worker → Échéances des requêtes. - Un client qui a fermé sa connexion est détecté au flush suivant. D'ici là, sa requête, et la connexion Redis avec elle, reste ouverte : 25 s au plus ici, soit le temps de blocage, qui est aussi l'intervalle des heartbeats.
Redis Pub/Sub livre lui aussi chaque message à chaque abonné connecté, mais il ne conserve rien : un message publié pendant qu'un client se reconnecte est perdu, et il n'y a aucun id à partir duquel reprendre. Ne l'utilisez que là où un événement manqué est sans importance.
Plusieurs instances derrière un répartiteur de charge
Un flux est une longue connexion vers une seule instance, choisie une fois pour toutes par le répartiteur. Avec plus d'une instance :
- Les événements doivent passer d'une instance à l'autre. Les objets
OxPHP\Shared\*vivent à l'intérieur d'un seul processus (voir État partagé), si bien qu'un flux sur une instance ne peut pas voir, par leur biais, un événement publié sur une autre. Les deux doivent lire le même stockage externe, et c'est précisément ce qu'est le Redis Stream ci-dessus. - La capacité se compte par instance. Chaque instance sert les flux pour lesquels son propre pool de workers laisse de la place (voir Bonnes pratiques). Préférez au round-robin une méthode de répartition qui compte les connexions ouvertes, comme
least_conndans nginx : le round-robin répartit les connexions selon leur arrivée, pas selon la durée pendant laquelle elles restent ouvertes, et un flux reste ouvert bien plus longtemps qu'une requête ordinaire. - Les sessions persistantes (sticky sessions) sont inutiles. Un navigateur qui se reconnecte peut atterrir sur une autre instance, ce qui ne pose aucun problème tant que la première trame et chaque événement portent un
id:et que chaque instance lit le même stream. - Le proxy ne doit pas mettre le flux en tampon. Un proxy qui met en tampon retient les événements jusqu'à ce que son tampon soit plein. Dans nginx, désactivez cette mise en tampon pour la location du flux avec
proxy_buffering off, ou envoyezX-Accel-Buffering: nodepuis le script, ce que nginx respecte sauf siproxy_ignore_headersen dispose autrement. - Les heartbeats doivent être plus fréquents que le délai d'inactivité du répartiteur. Voir « Des proxys intermédiaires ferment les connexions SSE inactives » dans Dépannage.
- Utilisez HTTP/2 entre le navigateur et le répartiteur. En HTTP/1.1, un navigateur autorise environ six connexions ouvertes par hôte, tous onglets confondus, et chaque flux en occupe une ; HTTP/2 transporte de nombreux flux sur une seule connexion.
Dépannage
Le client ne reçoit aucune donnée avant la fin du script
Le tampon de sortie PHP capture la sortie au lieu de la diffuser. Cela se produit lorsque des couches OB sont actives et que oxphp_stream_flush() n'est pas appelé.
Correctif : appelez oxphp_stream_flush() après chaque événement. Cette fonction vide toutes les couches de tampon de sortie PHP et envoie la sortie accumulée sous forme de chunk.
Les connexions SSE sont fermées après quelques minutes
Le max_execution_time de PHP se déclenche et termine le script. Les flux SSE doivent s'exécuter plus longtemps que la limite configurée.
Correctif : désactivez le minuteur d'exécution par requête en tête du script de streaming :
set_time_limit(0);C'est préférable à la définition globale de max_execution_time = 0 — cela laisse la limite en place pour les endpoints non-SSE. Autrement, si l'instance entière est dédiée aux flux de longue durée :
; php.ini
max_execution_time = 0Des proxys intermédiaires ferment les connexions SSE inactives
Les répartiteurs de charge et les proxys ferment souvent les connexions qui ne transportent aucune donnée pendant 30 à 60 secondes.
Correctif : envoyez un heartbeat sous forme de commentaire à intervalles réguliers pour maintenir la connexion active :
echo ": heartbeat\n\n";
oxphp_stream_flush();oxphp_stream_flush() renvoie false
oxphp_finish_request() a été appelé plus tôt dans la même requête. Une fois la réponse terminée, le streaming n'est plus possible. Vérifiez votre code à la recherche d'appels involontaires à oxphp_finish_request() avant le début du streaming.
Exemple Docker
Les endpoints SSE nécessitent que le minuteur d'exécution de PHP soit désactivé ou réglé sur une valeur élevée. Hors mode worker, comme dans cet exemple, chaque connexion SSE active occupe un worker PHP pendant toute la durée du flux ; dimensionnez donc le pool de workers pour absorber le nombre de flux concurrents attendu.
services:
app:
image: ghcr.io/oxphp/oxphp:0.13.0
ports:
- "8080:80"
volumes:
- ./src:/var/www/html
environment:
DOCUMENT_ROOT: "/var/www/html/public"
ENTRY_FILE: "index.php"
PHP_WORKERS: "32"Chaque script de streaming doit appeler set_time_limit(0) en tête afin que le minuteur par requête ne se déclenche pas en cours de flux. Cela maintient le max_execution_time global en vigueur pour les requêtes non-SSE, en mode worker également : chaque requête y a sa propre limite, si bien qu'un flux qui désactive la sienne laisse bornées les requêtes servies à côté de lui (voir Mode worker → Échéances des requêtes).
Bonnes pratiques
- Utilisez
oxphp_stream_flush()plutôt que leflush()natif pour une gestion automatique des tampons de sortie et une intégration à la contre-pression. - Envoyez des heartbeats périodiques sous forme de commentaires (
: heartbeat\n\n) toutes les 20 à 30 secondes pour empêcher les proxys intermédiaires de fermer les connexions inactives et pour détecter tôt les déconnexions client. - Gardez des charges utiles d'événements réduites. Les charges utiles volumineuses remplissent plus vite le tampon de 64 chunks, ce qui fait caler PHP à chaque flush. Pour de gros volumes de données, envoyez un ID d'événement et laissez le client récupérer la charge utile complète via une requête distincte.
- Désactivez le minuteur d'exécution de PHP par script avec
set_time_limit(0)pour les endpoints SSE de longue durée, ou réglezmax_execution_timesur une valeur suffisamment élevée pour couvrir la durée maximale de flux attendue. - Dimensionnez votre pool de workers pour le pic de flux concurrents. Hors mode worker, et en mode worker pour un flux dont les attentes ne suspendent pas sa fibre, chaque connexion SSE active occupe un worker PHP pendant toute sa durée (voir SSE en mode worker). Dans ces cas, prévoyez au moins un worker par client concurrent attendu, plus des workers supplémentaires pour les requêtes non-SSE ordinaires.
Remarques
- Les flux d'événements ne sont jamais compressés, en vertu de deux règles indépendantes :
text/event-streamn'est pas un type de média compressible, et une réponse dont la longueur est inconnue au moment de l'envoi des en-têtes est transmise sans compression, quoi que le client accepte. Cela vaut pour tous les codages proposés par OxPHP (Brotli, Zstandard comme gzip), si bien qu'aucune configuration ne peut activer la compression pour un flux. oxphp_stream_flush()renvoiefalsesioxphp_finish_request()a déjà été appelé sur la même requête.- En mode worker, un flux n'occupe son thread de worker que pendant qu'il s'exécute ou attend sans suspendre sa fibre ; pendant qu'il attend dans
oxphp_sleep()ou sur une lecture interceptée par un hook, le thread traite d'autres requêtes. Dans les autres modes, le worker reste occupé jusqu'à ce que le script PHP se termine.
Comportement à l'arrêt
Lorsque le serveur reçoit SIGTERM (un déploiement progressif, un docker stop, une éviction de pod Kubernetes), il draine gracieusement :
- Chaque flux SSE ouvert est terminé proprement à son prochain
flush— son gestionnaire abandonne comme si la connexion s'était fermée, de sorte que les callbacks deregister_shutdown_function()s'exécutent toujours et queerror_get_last()['message']contientRequest cancelled (shutdown). - Les clients HTTP/2 reçoivent une trame
GOAWAY; les connexions HTTP/1.1 keep-alive sont fermées. L'EventSourcedu navigateur se reconnecte automatiquement — vers une instance saine lorsqu'un répartiteur de charge est placé devant la flotte. - Le flux ne reçoit pas de 503 : ses en-têtes
200ont déjà été envoyés, le statut ne peut donc pas être réécrit. Concevez les clients pour qu'ils reprennent à partir du dernier id d'événement (envoyezid:avec chaque événement ; à la reconnexion, le navigateur le renvoie dans l'en-tête de requêteLast-Event-ID, disponible en PHP via$_SERVER['HTTP_LAST_EVENT_ID']). L'exemple en mode worker fait exactement cela.
Les requêtes ordinaires (non-streaming) en cours au moment du SIGTERM ne sont pas interrompues : elles disposent de toute la fenêtre de drainage pour se terminer normalement, et seules les requêtes encore en cours lorsque DRAIN_TIMEOUT_SECONDS (25 par défaut) expire sont annulées, avec environ 2 secondes de plus pour se dérouler. Réglez la période de grâce de terminaison de l'orchestrateur au-dessus de DRAIN_TIMEOUT_SECONDS + 2, plus l'appel bloquant le plus long dans lequel une requête peut se trouver à l'échéance (voir Arrêt gracieux).
Le terme « streaming » désigne ici toute réponse ayant déjà vidé une sortie en chunks — le serveur ne peut pas distinguer un téléchargement en streaming fini d'un flux d'événements infini, si bien qu'un gros téléchargement vidé en cours au moment du SIGTERM est lui aussi terminé prématurément, pas seulement le SSE. Une requête ayant appelé oxphp_finish_request() avant le SIGTERM est comptée comme ordinaire : sa réponse est complète, et son travail de fond restant bénéficie de la fenêtre de drainage.
Un gestionnaire bloqué à l'intérieur d'un unique appel natif de longue durée (un sleep() natif, un preg_match lourd, une requête de base de données bloquante) ne peut pas être interrompu tant que cet appel n'est pas revenu. En mode worker, préférez le oxphp_sleep() coopératif dans les boucles de streaming — il rend la main à l'ordonnanceur de fibers et est réveillé immédiatement à l'arrêt. Hors mode worker, oxphp_sleep() retombe sur un sleep bloquant classique, de sorte que le flux réagit à l'arrêt à son prochain flush après le retour du sleep.
Prompts pour votre agent
Ces prompts supposent un agent connecté au serveur MCP de la documentation OxPHP — il lira lui-même les pages dont il a besoin.
Construis un endpoint Server-Sent Events dans ce projet sur OxPHP. Lis la page `features/sse` via le serveur MCP de la documentation OxPHP.
Écris le gestionnaire autour des données que ce dépôt doit déjà pousser — heartbeats, déconnexions client, reprise depuis le dernier id d'événement — et dis-moi combien de workers demandera la concurrence que j'attends.Voir aussi
- Mode worker — workers PHP persistants pour réduire le coût de démarrage
- Délais d'expiration — configurer ou désactiver le délai d'expiration des requêtes pour les connexions de longue durée
- Fonctions PHP — référence complète de
oxphp_stream_flush()etoxphp_is_streaming() - Compression — quelles réponses sont compressées, et avec quel codage