Skip to main content

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}