fandhe_backend_core/streaming.rs
1//! レスポンス側 chunked ストリーミング送信の opt-in API(イシュー #319)。
2//!
3//! [`crate::server::Handler`] の既定メソッド
4//! [`crate::server::Handler::handle_streaming`] が返す型 [`StreamingResponse`] と、
5//! その producer 側ハンドル [`BodyWriter`] を定義する。`crates/core/src/server.rs`
6//! の書き出しループが `handle_streaming` から `Some` を得た場合のみ、通常の
7//! `Content-Length` 一括応答([`fandhe_backend_http::response::Response::serialize`])
8//! の代わりにこの経路([`fandhe_backend_http::response::Response::serialize_chunked_head`]
9//! と [`fandhe_backend_http::chunked::encode_chunk`] /
10//! [`fandhe_backend_http::chunked::encode_terminator`] の組み合わせ)を使う。
11//! 既存の `Handler::handle` のみを実装した型は本モジュールを一切意識せずに
12//! 動作し続ける(後方互換、`.claude/rules/feature-modification.md` の受け入れ基準 2)。
13//!
14//! # バックプレッシャ(受け入れ基準 3)
15//!
16//! [`StreamingResponse::channel`] が返す `mpsc::Sender` は bounded
17//! (容量は呼び出し元が指定)であり、[`BodyWriter::send`] は容量超過時に
18//! `.await` で待機する。受信側(コアの書き出しループ)はソケットへ実際に
19//! 書けた分だけチャネルから取り出すため、producer タスクがソケットの処理
20//! 速度を追い越して無制限にメモリを積み上げることはない(サーバ側バッファは
21//! 高々 `capacity × 1 チャンク分` に有界。`.claude/rules/security.md` の
22//! リソース枯渇 DoS 対策)。
23//!
24//! # 応答完全性(fail-closed)
25//!
26//! [`BodyWriter::finish`] を呼ばずに producer 側が `drop` された場合、
27//! 受信側は「終端が来ないままチャネルが閉じた」ことを検知し、
28//! [`fandhe_backend_http::chunked::encode_terminator`] を送出せず接続を
29//! クローズする契約(`crates/core/src/server.rs` の書き出しループを参照)。
30//! これにより、打ち切られた応答を完全な応答としてクライアント・キャッシュに
31//! 誤認させない(RFC 9112 の length 整合性維持)。
32
33use tokio::sync::mpsc;
34
35/// [`StreamingResponse::channel`] の既定チャネル容量。
36///
37/// [`BodyWriter`] の doc を参照。小さすぎるとチャンクごとの往復コストが
38/// 増え、大きすぎるとバックプレッシャの効きが弱まりサーバ側バッファ上限が
39/// 緩む。8 は「数チャンク分のパイプライン化を許しつつ上限を小さく保つ」
40/// 妥協値として選定した(実測チューニングは将来の課題、
41/// `.claude/rules/out-of-scope-tracking.md` 対象候補)。
42const DEFAULT_CHANNEL_CAPACITY: usize = 8;
43
44/// [`BodyWriter`] から [`StreamingResponse`] へ送られる 1 メッセージ。
45///
46/// 非公開。外部からは [`BodyWriter::send`] / [`BodyWriter::finish`] 経由でのみ
47/// 生成でき、任意の `StreamEvent` を直接構築させない。
48pub(crate) enum StreamEvent {
49 /// 1 チャンク分のボディデータ。空データは
50 /// [`fandhe_backend_http::chunked::encode_chunk`] 側で無出力になる
51 /// (誤終端防止)ため、ここでは特別扱いしない。
52 Chunk(Vec<u8>),
53 /// 明示的な終端([`BodyWriter::finish`])。
54 End,
55}
56
57/// [`BodyWriter::send`] / [`BodyWriter::finish`] が返すエラー。
58///
59/// 受信側(コアの書き出しループ)が既に終了した後(クライアント切断・
60/// 生存期間上限超過等でソケットを閉じた後)に producer が送信を試みた
61/// ことを示す。producer 側はこのエラーを受けたら以降の送信を止め、
62/// タスクを終了してよい。
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub struct StreamClosed;
65
66impl std::fmt::Display for StreamClosed {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 f.write_str("streaming response receiver has been closed")
69 }
70}
71
72impl std::error::Error for StreamClosed {}
73
74/// [`crate::server::Handler::handle_streaming`] が返すストリーミング応答。
75///
76/// ステータス・`Content-Type` のみを保持し、実データは
77/// [`StreamingResponse::channel`] で得られる [`BodyWriter`] 経由で producer
78/// タスクが逐次送る。`fandhe_backend_http::response::Response` とは異なり
79/// body を持たない(chunked framing はコアの書き出しループが直接組み立てる
80/// ため、ここでは中間表現としての `Response` を経由しない)。
81pub struct StreamingResponse {
82 /// 応答ステータスコード。書き出しループが
83 /// [`fandhe_backend_http::response::Response::serialize_chunked_head`] へ
84 /// そのまま渡す。
85 pub status: u16,
86 pub(crate) content_type: Option<&'static str>,
87 pub(crate) rx: mpsc::Receiver<StreamEvent>,
88}
89
90impl StreamingResponse {
91 /// `status` の chunked ストリーミング応答と、データ送出用の [`BodyWriter`] を
92 /// 既定容量(`DEFAULT_CHANNEL_CAPACITY`)で組み立てる。
93 ///
94 /// `Content-Type` を付けたい場合は [`Self::channel`] を使う。
95 #[must_use]
96 pub fn new(status: u16) -> (Self, BodyWriter) {
97 Self::channel(status, None, DEFAULT_CHANNEL_CAPACITY)
98 }
99
100 /// `status` / `content_type` / 明示的な `capacity`(bounded mpsc の容量)で
101 /// ストリーミング応答を組み立てる。
102 ///
103 /// `capacity` が `0` の場合は `1` に切り上げる(`tokio::sync::mpsc::channel`
104 /// は容量 0 を受け付けないため。バックプレッシャの効きを最も強くしたい
105 /// 呼び出し元が `0` を渡しても panic させないフェイルセーフ)。
106 ///
107 /// 戻り値のタプル `(StreamingResponse, BodyWriter)` は、前者を
108 /// `Handler::handle_streaming` の戻り値として返し、後者を
109 /// `tokio::spawn` した producer タスクへ move して使う想定
110 /// (`crate::server::Handler::handle_streaming` の doc test を参照)。
111 ///
112 /// ```
113 /// use fandhe_backend_core::streaming::StreamingResponse;
114 ///
115 /// # #[tokio::main(flavor = "current_thread")]
116 /// # async fn main() {
117 /// let (response, writer) = StreamingResponse::channel(200, Some("text/plain"), 4);
118 /// assert_eq!(response.status, 200);
119 ///
120 /// tokio::spawn(async move {
121 /// writer.send(b"hello ".to_vec()).await.ok();
122 /// writer.send(b"world".to_vec()).await.ok();
123 /// writer.finish().await.ok();
124 /// });
125 /// # let _ = response;
126 /// # }
127 /// ```
128 #[must_use]
129 pub fn channel(
130 status: u16,
131 content_type: Option<&'static str>,
132 capacity: usize,
133 ) -> (Self, BodyWriter) {
134 let (tx, rx) = mpsc::channel(capacity.max(1));
135 (
136 Self {
137 status,
138 content_type,
139 rx,
140 },
141 BodyWriter { tx },
142 )
143 }
144}
145
146/// [`StreamingResponse::channel`] の producer 側ハンドル。
147///
148/// `tokio::spawn` したタスクへ move して使う想定の `Send + 'static` 型。
149/// クローンして複数タスクから送信することもできる(`mpsc::Sender` の
150/// clone セマンティクスをそのまま継承。最後の 1 つが `finish` するか、
151/// 全クローンが `finish` を呼ばずに drop されるとチャネルが閉じる)。
152#[derive(Clone)]
153pub struct BodyWriter {
154 tx: mpsc::Sender<StreamEvent>,
155}
156
157impl BodyWriter {
158 /// 1 チャンク分のデータを送る。
159 ///
160 /// チャネルが満杯([`StreamingResponse::channel`] の `capacity` に到達)の
161 /// 場合は受信側(コアの書き出しループ)がソケットへ書き出して空きが
162 /// できるまで `.await` で停止する(バックプレッシャ、モジュール冒頭 doc)。
163 ///
164 /// `data` が空の場合も呼び出し自体は成功するが、送出時に
165 /// [`fandhe_backend_http::chunked::encode_chunk`] が無出力にする契約
166 /// のため実際のワイヤ出力は生じない(誤終端防止、同関数の doc を参照)。
167 ///
168 /// # Errors
169 ///
170 /// 受信側が既に終了している場合は [`StreamClosed`] を返す。
171 pub async fn send(&self, data: Vec<u8>) -> Result<(), StreamClosed> {
172 self.tx
173 .send(StreamEvent::Chunk(data))
174 .await
175 .map_err(|_| StreamClosed)
176 }
177
178 /// ストリームを正常終端する。
179 ///
180 /// 受信側は [`fandhe_backend_http::chunked::encode_terminator`] を送出し、
181 /// 応答を完全なものとして扱う(keep-alive 継続も許される)。`self` を
182 /// 消費することで「`finish` 後に `send` を呼ぶ」という無意味な呼び出し
183 /// 順序を型レベルで防ぐ。
184 ///
185 /// `finish` を呼ばずに `self` を drop した場合は打ち切りとして扱われ、
186 /// 受信側は終端チャンクを送らず接続をクローズする(モジュール冒頭
187 /// doc の「応答完全性」節を参照)。
188 ///
189 /// # Errors
190 ///
191 /// 受信側が既に終了している場合は [`StreamClosed`] を返す。
192 pub async fn finish(self) -> Result<(), StreamClosed> {
193 self.tx
194 .send(StreamEvent::End)
195 .await
196 .map_err(|_| StreamClosed)
197 }
198}
199
200/// コアの書き出しループ(`crates/core/src/server.rs`)が
201/// [`StreamingResponse`] を消費する際の単一ステップの結果。
202///
203/// `rx.recv()` の戻り値(`Option<StreamEvent>`)をそのまま公開型として
204/// 露出させると内部表現(`StreamEvent`)が漏れるため、書き出しループが
205/// 使う専用の変換関数 [`StreamingResponse::recv`] を介して受け渡す。
206pub(crate) enum RecvOutcome {
207 /// 次のチャンクを受信した(空データの可能性を含む)。
208 Chunk(Vec<u8>),
209 /// producer が [`BodyWriter::finish`] を呼び、正常終端した。
210 End,
211 /// producer 側が `finish` を呼ばずに drop され、チャネルが閉じた
212 /// (打ち切り。書き出しループは終端チャンクを送らず接続を閉じる)。
213 Aborted,
214}
215
216impl StreamingResponse {
217 /// 書き出しループ専用の 1 ステップ受信。`pub(crate)` に限定し、
218 /// 外部からは [`StreamingResponse::channel`] で得た [`BodyWriter`] 経由の
219 /// 送信のみを許す非対称 API とする(受信は常にコアが握る)。
220 pub(crate) async fn recv(&mut self) -> RecvOutcome {
221 match self.rx.recv().await {
222 Some(StreamEvent::Chunk(data)) => RecvOutcome::Chunk(data),
223 Some(StreamEvent::End) => RecvOutcome::End,
224 None => RecvOutcome::Aborted,
225 }
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[tokio::test]
234 async fn send_then_finish_yields_chunks_then_end() {
235 let (mut response, writer) = StreamingResponse::channel(200, None, 4);
236 writer.send(b"a".to_vec()).await.unwrap();
237 writer.send(b"b".to_vec()).await.unwrap();
238 writer.finish().await.unwrap();
239
240 match response.recv().await {
241 RecvOutcome::Chunk(data) => assert_eq!(data, b"a"),
242 _ => panic!("expected first chunk"),
243 }
244 match response.recv().await {
245 RecvOutcome::Chunk(data) => assert_eq!(data, b"b"),
246 _ => panic!("expected second chunk"),
247 }
248 match response.recv().await {
249 RecvOutcome::End => {}
250 _ => panic!("expected End"),
251 }
252 }
253
254 #[tokio::test]
255 async fn drop_without_finish_yields_aborted() {
256 let (mut response, writer) = StreamingResponse::channel(200, None, 4);
257 writer.send(b"only".to_vec()).await.unwrap();
258 drop(writer);
259
260 match response.recv().await {
261 RecvOutcome::Chunk(data) => assert_eq!(data, b"only"),
262 _ => panic!("expected chunk before abort"),
263 }
264 match response.recv().await {
265 RecvOutcome::Aborted => {}
266 _ => panic!("expected Aborted after writer drop without finish"),
267 }
268 }
269
270 #[tokio::test]
271 async fn send_after_receiver_dropped_returns_stream_closed() {
272 let (response, writer) = StreamingResponse::channel(200, None, 4);
273 drop(response);
274 let err = writer.send(b"x".to_vec()).await.unwrap_err();
275 assert_eq!(err, StreamClosed);
276 }
277
278 #[tokio::test]
279 async fn zero_capacity_is_rounded_up_to_one() {
280 // capacity = 0 を渡しても panic せず、最低 1 は送信できることを確認する
281 // (tokio::sync::mpsc::channel(0) は panic するため、コンストラクタ側で
282 // 切り上げていることの固定)。
283 let (mut response, writer) = StreamingResponse::channel(200, None, 0);
284 writer.send(b"ok".to_vec()).await.unwrap();
285 match response.recv().await {
286 RecvOutcome::Chunk(data) => assert_eq!(data, b"ok"),
287 _ => panic!("expected chunk"),
288 }
289 }
290
291 #[test]
292 fn stream_closed_display_and_error_trait() {
293 fn assert_error<E: std::error::Error>() {}
294 assert_error::<StreamClosed>();
295 assert_eq!(
296 StreamClosed.to_string(),
297 "streaming response receiver has been closed"
298 );
299 }
300}