Server-Sent Events (SSE)
OxPHP は、組み込みのバックプレッシャーを備えた Server-Sent Events プロトコルで、クライアントにリアルタイムデータをストリーミングします。PHP スクリプトで Content-Type: text/event-stream を設定し、oxphp_stream_flush() を呼び出すだけです。残りは OxPHP が処理します。
仕組み
ストリーミングレスポンスは、予測可能なライフサイクルに沿って進みます。
- ヘッダーを設定する。 PHP スクリプトは
header()でContent-Type: text/event-streamを設定し、echoを使って SSE 形式の行を書き出します。 - 最初のフラッシュでストリームが開く。
oxphp_stream_flush()の最初の呼び出しで HTTP ヘッダーがクライアントに送信され、ストリーミングモードに入ります。クライアント接続は開いたままになります。 - 各フラッシュがチャンクを送る。 以降の
oxphp_stream_flush()の呼び出しごとに、バッファされた出力が新しいチャンクとしてフラッシュされ、即座にクライアントへ届けられます。 - バッファが埋まるとバックプレッシャーが働く。 OxPHP は PHP ワーカーとクライアントの間に最大 64 チャンクの内部バッファを保持します。バッファが満杯になると(遅いクライアントが先行するチャンクをまだ消費していないため)、
oxphp_stream_flush()は空きができるまでブロックします。これによりメモリの際限ない増加を防ぎます。 - 終了時または切断時のクリーンアップ。 PHP スクリプトが終了すると、OxPHP は接続をグレースフルにクローズします。クライアントがストリームの途中で切断した場合、スクリプトがそれを知るのは、OxPHP が接続のクローズを検知した後の最初のフラッシュです。OxPHP は PHP の
connection_aborted()フラグを設定し、スクリプトがignore_user_abort(true)を呼び出していない限り、そのフラッシュの内部でスクリプトを停止させます。呼び出しは戻りません。これは、出力をもう書き込めなくなったスクリプトを PHP-FPM 下で PHP が停止させるのと同じ方法です。ignore_user_abort(true)を呼び出したスクリプトにはフラッシュから制御が戻り、スクリプトは実行を続けます。そのループを終わらせるのはconnection_aborted()のチェックです(クライアントの切断検知を参照)。
スループットをスムーズに保つため、イベントのペイロードは小さく保ってください。ペイロードが大きいと 64 チャンクのバッファがすぐに埋まり、フラッシュのたびに PHP がブロックする原因になります。
例
基本的な SSE ストリーム
<?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();
}
}ストリーミング状態の確認
現在のリクエストがすでにストリーミングモードに入っているかどうかを確認するには、oxphp_is_streaming() を使用します。これはミドルウェアや共有のリクエストハンドラーで役立ちます。
<?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();クライアントの切断検知
ヘッダーを送信済みのストリームは、フラッシュしたときに初めてクライアントがいなくなったことを知ります。デフォルトでは、そのフラッシュがスクリプトの停止する場所です。oxphp_stream_flush()(または flush())は戻らないため、while (!connection_aborted()) の条件が再び評価されることはなく、そのフラッシュを囲んでいたすべての finally ブロックはスキップされます。PHP-FPM も、出力をもう書き込めなくなったスクリプトを同じ方法で停止させます。
それでも実行されるのはリクエストの終了処理です。つまり、register_shutdown_function() のコールバック(その中では connection_aborted() は 1 を返します)と、リクエストとともに消えるオブジェクトのデストラクターです。これらが書き出したものを破棄する PHP-FPM とは異なり、OxPHP はコールバックが最初に出力を送った時点でそのコールバックを停止させ、それより後に登録されたコールバックをスキップします。デストラクターは引き続き実行されますが、最初に出力を送ったデストラクターは同じように停止させられ、それより後に予定されていたデストラクターはスキップされます。出力バッファ(ob_start() で開いたもの、または php.ini の output_buffering が開くもの)に保持された出力は、そのバッファがフラッシュされたときに初めて送信されます。このクリーンアップ処理では何も出力しないようにするか、以下のように ignore_user_abort(true) を呼び出してください。
スクリプト自体の中で、つまり finally ブロックやループの後でクリーンアップするには、ignore_user_abort(true) を呼び出し、connection_aborted() にループを終わらせてください。そうすると、クライアントがいなくなったことを検知したフラッシュは戻り、それ以降 connection_aborted() は 1 を返し、ループは条件を通って抜けます。
<?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
}この呼び出しを行うと、ループを終わらせるのは connection_aborted() のチェックだけになります。スクリプトはクライアントが切断した後も実行を続け、書き出した内容は破棄され、connection_aborted() は切断を報告します。これは他のどの SAPI でも同じ契約です。このようなループには独自の上限が必要です。クライアントより長く生き残ることを求め、かつ connection_aborted() をチェックせず自力でも終わらないストリームは、サーバーがシャットダウンするまでワーカーを占有し続けます。
この呼び出しが影響するのは、それを行ったリクエストだけです。ただし、リクエストが開始時に持つ値も同じように効きます。php.ini の ignore_user_abort = On、あるいはワーカースクリプトのブートストラップ(リクエストループより前のコードで、ワーカーの例の中にはこれで始まるものもあります)にある ignore_user_abort(true) は、すべてのリクエストの出発点となる基準値です。そのため、そこで処理されるすべてのストリームは呼び出しを行ったかのように振る舞い、connection_aborted() のチェックが必要になります。
ネイティブの flush() を使う
PHP のネイティブな flush() でもストリーミングは可能ですが、事前にすべての出力バッファレイヤーをクリアする必要があります。oxphp_stream_flush() を優先してください。こちらは出力バッファを自動的に管理し、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
SSE は標準モードとワーカーモードの両方で動作します。ワーカーモードでは、ストリームがワーカースレッドを占有するのは、そのスクリプトが実行中であるか、ファイバーを中断せずに待機している間(64 チャンクのバッファが満杯のときのフラッシュがこれに当たります)だけです。ストリームが oxphp_sleep() で、あるいは streams ランタイムフックを介した読み取りで待機している間、スレッドは他のリクエストを処理します。1 つのワーカースレッドは最大 256 のリクエストを同時に実行します(ファイバー多重化を参照)。
ブロードキャストでは、開いているすべてのストリームがすべてのイベントを受け取る必要があるため、この例ではリストからポップするのではなく、Redis Stream からイベントを読み取ります。ストリームを読み取ってもエントリーはその場に残り、各リーダーは自分の位置を保持するので、すべてのクライアントがストリームにまだ残っているすべてのイベントを読み取ります。リストに対する BRPOP は逆の動作をします。返した要素を削除し、その要素はリストで待機している接続のうちちょうど 1 つにだけ渡ります。これはワークキューには正しい形ですが、ブロードキャストには誤った形です。ストリームが 2 つ開いていれば、各イベントはそのうち 1 つにしか届きません。
<?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();
}
});任意の PHP プロセス(リクエストハンドラー、キューワーカー、CLI スクリプト)から発行します。
$redis->xAdd('events', '*', ['data' => json_encode($payload)], 1000, true);最後の 2 つの引数は、ストリームをおよそ 1000 エントリーに制限します(MAXLEN ~ 1000)。data には JSON か、改行を含まないその他のデータを送ってください。この例ではそれを 1 行の data: 行に書き出します。
この例は次の点を前提としています。
- エントリー ID がイベント ID になる。
1790976033892-0のような ID がイベントのid:として送出され、ブラウザは再接続時に最後に受け取った ID をLast-Event-IDで返し、ストリームはその続きから再開します(シャットダウン時の挙動を参照)。 - 最初のフレームは開始 ID を運ぶイベントである。 Safari と Firefox は、
data:行を持つフレームからしかid:をブラウザのLast-Event-IDとして採用しません。そのためid:だけのフレームでは、最初のイベントより前に切断されたブラウザは送るべき ID を持たないままになり、再接続して最新のエントリーから開始し、その間に発行されたものを取りこぼします。空のdata:行を持つreadyイベントが、ブラウザに ID を与えます。名前付きイベントに対してonmessageは発火せず、addEventListener('ready', …)は発火します。すべてのイベントを 1 つのコールバックに渡すクライアントライブラリでは、このイベントをスキップする必要があります。 - 開始できないストリームには、最初のフレームより前にエラーステータスを返す。 Redis はストリーム以外のもの(たとえば以前のキューの名残のリスト)を保持するキーの読み取りを拒否し、どんな ID を使ってもその読み取りは成功しません。そのためこの例では最初のコマンドでそれを検知し、Redis が返したメッセージをログに記録して
500を返します。単に終了するストリームではなく、ステータスでなければなりません。最初のフレームを送出した時点でレスポンスは200になり、ブラウザは200のストリームが閉じられるたびに、ページが開いている限り数秒おきに再接続するからです。そのたびに新しい Redis 接続が作られ、ログには何も残りません。EventSourceはエラーステータスの後には再試行しません。ページはreadyStateがEventSource.CLOSEDになったerrorイベントを受け取り、必要なら自分で新しいEventSourceを開かなければなりません。到達できない Redis についても同じです。connect()が例外をスローし、レスポンスは500になり、EventSourceは完全に閉じられます。ストリームの開始後に Redis が読み取りを拒否した場合(たとえばキーがストリーム以外のものに置き換えられた場合)は、returnでストリームを終了し、再接続は上記のチェックを通ります。このreturnがなければ、ループはfalseをタイムアウトと見なし、ハートビートだけを立て続けに送り続けることになります。 Last-Event-IDの形式をチェックする。 このヘッダーはクライアントから送られてくるため何でも入りうるうえ、Redis はストリーム ID ではない値(たとえばabc)を拒否します。この例ではそのような値をヘッダーがない場合と同じように扱い、初回接続と同様に最新のエントリーの後から開始します。このパターンはダッシュの両側にそれぞれ最大 19 桁を許容し、受け入れる値がすべて Redis が読み取れる範囲に収まるようにしています。- 新しいストリームは最新の ID を一度だけ読み取る。 呼び出しのたびに
$を渡すことはしません。Redis は$を各呼び出しの時点での最新 ID に解決するため、2 回の呼び出しの間に追加されたエントリーがスキップされてしまうからです。 - 履歴には上限がある。 最後に受け取ったイベントがすでにトリムされたリーダー(切断されていたブラウザや、上限を超える数のイベントが発行される間、遅いクライアントによって滞っていたストリーム)は、まだ残っている最も古いエントリーから続行します。その間にトリムされたエントリーは配信されず、そのことは何も報告されません。ストリームを再開させたい最長の停止時間をカバーできるよう、上限のサイズを決めてください。
- 各ストリームは、ブロック時間より長い読み取りタイムアウトを持つ専用の接続を使う。 タイムアウト引数がない場合、phpredis は
default_socket_timeoutにフォールバックし、それより長くブロックする読み取りはRedisExceptionで失敗します。ワーカーごとに 1 つの接続を開いてすべてのストリームに渡すことはしないでください。streamsランタイムフックが有効な場合、ストリームが読み取りで待機している間にワーカーは他のリクエストを実行し、接続を使用したリクエストは終了するまでその接続を保持します。同じ接続にたどり着いた 2 つ目のストリームは、1 つ目を待ちます。待機の上限に達したときに 1 つ目がまだ開いていれば(その上限はmax_execution_timeとdefault_socket_timeoutから決まります。フックのセクションを参照)、phpredis はRedisExceptionをスローし、2 つ目のストリームは失敗します。 - スクリプトは PHP の実行タイマーを無効にする。
max_execution_timeは PHP の設定で別の値が指定されていない限り 30 秒で、リリースイメージにはphp.iniが同梱されていません。ストリームはこの制限より長く生き続けることを前提としています。タイマーを有効のままにすると、スクリプトは致命的エラーで終了します。タイマーは 30 秒後に発火し、進行中の読み取りが戻った時点で効力を持つため、ここでの 25 秒の読み取りでは、静かなストリームは 50 秒目に終了します。ワーカーモードでは、この呼び出しが無効にするのはストリーム自身のリクエストの制限だけで、他のリクエストの制限には影響しません。ワーカーモード → リクエストの期限を参照してください。 - 接続を閉じたクライアントは、次のフラッシュで検知される。 それまでは、そのリクエストとそれに伴う Redis 接続は開いたままです。ここでは最大でブロック時間の 25 秒で、これはハートビートの間隔でもあります。
Redis Pub/Sub も各メッセージを接続中のすべてのサブスクライバーに配信しますが、何も保持しません。クライアントが再接続している間に発行されたメッセージは失われ、再開するための ID もありません。イベントの取りこぼしが問題にならない場合にのみ使用してください。
ロードバランサー配下の複数インスタンス
ストリームは 1 つのインスタンスへの 1 本の長い接続であり、そのインスタンスはバランサーによって一度だけ選ばれます。インスタンスが複数ある場合は、次の点に注意してください。
- イベントはインスタンスをまたぐ必要がある。
OxPHP\Shared\*オブジェクトは 1 つのプロセス内に存在するため(共有状態を参照)、あるインスタンス上のストリームは、別のインスタンスで発行されたイベントをそれらを通じて見ることはできません。両者が同じ外部ストアを読み取る必要があり、上記の Redis Stream がまさにそれです。 - キャパシティはインスタンスごとに数える。 各インスタンスは、自身のワーカープールに余裕がある分だけのストリームを処理します(ベストプラクティスを参照)。ラウンドロビンよりも、nginx の
least_connのように開いている接続数を数えるバランシング方式を優先してください。ラウンドロビンは接続を到着順に振り分け、開いている時間の長さは考慮しません。そしてストリームは通常のリクエストよりはるかに長く開いたままになります。 - スティッキーセッションは不要。 再接続するブラウザは別のインスタンスにたどり着くことがありますが、最初のフレームとすべてのイベントが
id:を持ち、すべてのインスタンスが同じストリームを読み取っている限り問題ありません。 - プロキシはストリームをバッファリングしてはならない。 バッファリングするプロキシは、バッファが埋まるまでイベントを留め置きます。nginx では、ストリームの location で
proxy_buffering offによってバッファリングを無効にするか、スクリプトからX-Accel-Buffering: noを送ってください。nginx はproxy_ignore_headersで別の指定がない限り、これに従います。 - ハートビートはバランサーのアイドルタイムアウトより頻繁に送る。 トラブルシューティングの「中間プロキシがアイドル状態の SSE 接続を閉じる」を参照してください。
- ブラウザとバランサーの間では HTTP/2 を使う。 HTTP/1.1 では、ブラウザはすべてのタブを合わせてホストあたり約 6 本の接続しか開けず、各ストリームが 1 本ずつ使います。HTTP/2 は 1 本の接続で多数のストリームを運びます。
トラブルシューティング
スクリプトが終了するまでクライアントにデータが届かない
PHP の出力バッファが、出力をストリーミングせずにキャプチャしています。これは OB レイヤーが有効なまま oxphp_stream_flush() が呼び出されていない場合に発生します。
対処: 各イベントの後に oxphp_stream_flush() を呼び出してください。この関数はすべての PHP 出力バッファレイヤーをフラッシュし、蓄積された出力をチャンクとして送信します。
数分後に SSE 接続が閉じられる
PHP の max_execution_time が発火してスクリプトを終了させています。SSE ストリームは設定された制限より長く稼働する必要があります。
対処: ストリーミングスクリプトの先頭で、リクエストごとの実行タイマーを無効化してください。
set_time_limit(0);これはグローバルに max_execution_time = 0 を設定するよりも望ましい方法です。非 SSE エンドポイントには制限を残せるからです。あるいは、インスタンス全体を長時間稼働するストリーム専用にする場合は、次のようにします。
; php.ini
max_execution_time = 0中間プロキシがアイドル状態の SSE 接続を閉じる
ロードバランサーやプロキシは、30〜60 秒間データを運ばない接続をしばしば閉じます。
対処: 一定間隔でコメントハートビートを送信し、接続をアクティブに保ってください。
echo ": heartbeat\n\n";
oxphp_stream_flush();oxphp_stream_flush() が false を返す
同じリクエスト内で先に oxphp_finish_request() が呼び出されています。レスポンスがいったん終了すると、ストリーミングはできません。ストリーミング開始前に oxphp_finish_request() を意図せず呼び出していないか、コードを確認してください。
Docker の例
SSE エンドポイントでは、PHP の実行タイマーを無効化するか、高い値に設定する必要があります。この例のようにワーカーモード以外では、アクティブな SSE 接続はそれぞれ、ストリームの全期間にわたって PHP ワーカーを 1 つ占有するため、想定される同時ストリーム数に合わせてワーカープールのサイズを決めてください。
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"各ストリーミングスクリプトは、リクエストごとのタイマーがストリームの途中で発火しないよう、先頭で set_time_limit(0) を呼び出すべきです。これにより、非 SSE リクエストに対してはグローバルな max_execution_time が有効なまま維持されます。これはワーカーモードでも同じです。ワーカーモードでは各リクエストが独自の制限を持つため、ストリームが自分の制限を無効にしても、その横で処理されるリクエストには制限が残ります(ワーカーモード → リクエストの期限を参照)。
ベストプラクティス
- ネイティブの
flush()ではなくoxphp_stream_flush()を使用する。 出力バッファの自動管理とバックプレッシャー統合が得られます。 - 定期的にコメントハートビートを送信する(
: heartbeat\n\n)。20〜30 秒ごとに送ることで、中間プロキシがアイドル状態の接続を閉じるのを防ぎ、クライアントの切断を早期に検知できます。 - イベントのペイロードを小さく保つ。 ペイロードが大きいと 64 チャンクのバッファが早く埋まり、フラッシュのたびに PHP が停滞します。大きなデータの場合は、イベント ID を送り、完全なペイロードは別のリクエストでクライアントに取得させてください。
- スクリプトごとに PHP の実行タイマーを無効化する。 長時間稼働する SSE エンドポイントでは
set_time_limit(0)を使うか、想定される最長のストリーム時間をカバーできるようmax_execution_timeを十分高く設定してください。 - ピーク時の同時ストリーム数に合わせてワーカープールのサイズを決める。 ワーカーモード以外では、またワーカーモードでも待機がファイバーを中断しないストリームでは、アクティブな SSE 接続はそれぞれ、その全期間にわたって PHP ワーカーを 1 つ保持します(ワーカーモードでの SSEを参照)。そうした場合は、想定される同時クライアントごとに少なくとも 1 つのワーカーを見込み、加えて通常の非 SSE リクエスト用のワーカーを追加してください。
注意点
- イベントストリームは、互いに独立した 2 つのルールによって決して圧縮されません。
text/event-streamは圧縮可能なメディアタイプではなく、さらにヘッダー送信時点で長さが不明なレスポンスは、クライアントが何を受け入れるかにかかわらず非圧縮のまま渡されます。これは OxPHP が提供するすべてのエンコーディング(Brotli、Zstandard、gzip のいずれも)に当てはまるため、どんな設定でもストリームの圧縮を有効にすることはできません。 - 同じリクエストで
oxphp_finish_request()がすでに呼び出されていた場合、oxphp_stream_flush()はfalseを返します。 - ワーカーモードでは、ストリームがワーカースレッドを占有するのは、実行中か、ファイバーを中断せずに待機している間だけです。
oxphp_sleep()やフックされた読み取りで待機している間、スレッドは他のリクエストを処理します。それ以外のモードでは、ワーカーは PHP スクリプトが終了するまで占有されたままです。
シャットダウン時の挙動
サーバーが SIGTERM を受信すると(ローリングデプロイ、docker stop、Kubernetes の Pod 退避など)、グレースフルにドレインします。
- 開いている各 SSE ストリームは、次の
flushでクリーンに終了されます。ハンドラーは接続が閉じられたかのように中断するため、register_shutdown_function()のコールバックは依然として実行され、error_get_last()['message']はRequest cancelled (shutdown)を返します。 - HTTP/2 クライアントは
GOAWAYフレームを受信します。HTTP/1.1 の keep-alive 接続は閉じられます。ブラウザのEventSourceは自動的に再接続します。フリートの前段にロードバランサーがある場合は、健全なインスタンスへ再接続します。 - ストリームは 503 を受け取りません。その
200ヘッダーはすでに送信済みなので、ステータスを書き換えることはできません。クライアントは最後のイベント ID から再開するよう設計してください(各イベントにid:を送信します。再接続時、ブラウザはそれをLast-Event-IDリクエストヘッダーとして送り返し、PHP では$_SERVER['HTTP_LAST_EVENT_ID']として利用できます)。ワーカーモードの例はまさにそのとおりに動作します。
SIGTERM 時に処理中だった通常の(ストリーミングではない)リクエストは中断されません。ドレインウィンドウの全時間を使って通常どおり完了でき、DRAIN_TIMEOUT_SECONDS(デフォルト 25)が経過してもまだ実行中のリクエストだけがキャンセルされ、後始末のためにさらに約 2 秒が与えられます。オーケストレーターの終了猶予期間は、DRAIN_TIMEOUT_SECONDS + 2 に、期限の時点でリクエストが入っている可能性のある最長のブロッキング呼び出しの時間を加えた値より大きく設定してください(グレースフルシャットダウンを参照)。
ここでの「ストリーミング」とは、すでにチャンク化された出力をフラッシュしたあらゆるレスポンスを指します。サーバーは有限のストリーミングダウンロードと無限のイベントストリームを区別できないため、SIGTERM 時に処理中だった大きなフラッシュ済みダウンロードも、SSE だけでなく早期に終了されます。SIGTERM より前に oxphp_finish_request() を呼び出したリクエストは通常のものとして扱われます。そのレスポンスは完了しており、残りのバックグラウンド処理はドレインウィンドウを与えられます。
単一の長時間稼働するネイティブ呼び出し(ネイティブの sleep()、重い preg_match、ブロッキングなデータベースクエリ)の内部でブロックしているハンドラーは、その呼び出しが返るまで中断できません。ワーカーモードでは、ストリーミングループで協調的な oxphp_sleep() を優先してください。これはファイバースケジューラーに制御を譲り、シャットダウン時には即座に起こされます。ワーカーモード以外では oxphp_sleep() は通常のブロッキングスリープにフォールバックするため、ストリームはスリープが返った後の次の flush でシャットダウンに反応します。
エージェント向けプロンプト
これらのプロンプトは、OxPHP ドキュメント MCP サーバー を接続したエージェントを前提としています — 必要なページはエージェントが自分で読み取ります。
このプロジェクトに OxPHP で Server-Sent Events のエンドポイントを作ってください。OxPHP ドキュメント MCP サーバー経由で `features/sse` ページを読んでください。
このリポジトリがすでにプッシュしなければならないデータを中心にハンドラーを書き — ハートビート、クライアントの切断、最後のイベント ID からの再開を含めて — 私が想定している同時接続数にはワーカーがいくつ必要になるのかを教えてください。