Skip to main content

webui/
streaming.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Streaming `ResponseWriter` helpers for actix-web (or any) HTTP host.
5//!
6//! `webui-handler` writes through a push-based [`ResponseWriter`] trait —
7//! every `Raw` fragment, attribute, signal value, route element open/close,
8//! CSS preload `<link>`, and template assignment is a separate `write()`
9//! call (~hundreds per render). The default host pattern collects them all
10//! into a `String`, then serves the whole HTML body in one shot — which
11//! delays first-byte until the entire render finishes and forces the
12//! browser to wait for everything before parsing.
13//!
14//! The helpers here let a host **flush bytes to the network as soon as
15//! they're written**:
16//!
17//! * [`StreamingWriter`] — coalesces small writes into ~4 KB chunks and
18//!   pushes them through a **bounded** [`tokio::sync::mpsc::Sender`]. The
19//!   bound (`DEFAULT_CHANNEL_CAPACITY = 4` chunks ≈ 16 KB) provides
20//!   backpressure: when a slow client cannot keep up, the producer parks
21//!   on the channel until the receiver drains, instead of queuing the
22//!   entire response in memory. A configurable flush deadline (via
23//!   [`with_flush_timeout`](StreamingWriter::with_flush_timeout)) caps
24//!   the maximum time a producer thread can be parked, bounding the
25//!   slow-loris DoS surface to `timeout × concurrent_renders`. When the
26//!   receiver is dropped (client disconnect) or the deadline elapses,
27//!   `write` returns a typed error so the handler aborts instead of
28//!   doing wasted CPU work.
29//!
30//! * [`ChunkPool`] — lock-free shared pool of chunk buffers. Used via
31//!   [`StreamingWriter::new_pooled`] to recycle the per-flush `Vec<u8>`
32//!   across requests, eliminating per-flush heap allocation in
33//!   steady-state high-RPS workloads.
34//!
35//! Hot-path allocation profile:
36//!
37//! * `StreamingWriter::new()` (unpooled): one `Vec::reserve` per ~4 KB
38//!   flush (the previous buffer is moved zero-copy into [`bytes::Bytes`]
39//!   when `len < cap`; when `len == cap`, `Bytes::from(Vec)` is still a
40//!   move via `into_boxed_slice`). Plus one small `Box<Shared>` for the
41//!   refcount metadata.
42//! * `StreamingWriter::new_pooled()`: zero per-flush heap allocation in
43//!   steady state — chunk buffers come from the pool and return on
44//!   `Bytes` drop. Single atomic CAS per acquire/release.
45//!
46//! # Per-render HTML injection
47//!
48//! Hosts that need to splice HTML at the structural `</head>` or `</body>`
49//! boundaries (image preload `<link>` tags, dev livereload `<script>`,
50//! CSP nonce reflections, analytics, etc.) should use
51//! [`RenderOptions::with_head_inject`] / [`RenderOptions::with_body_inject`]
52//! on the handler side — NOT a writer-level scanner. The parser already
53//! synthesises `head_end` / `body_end` signal fragments at the exact
54//! structural boundaries; the handler emits the inject HTML there with
55//! zero scan cost and no risk of mis-firing on `</body>` literals
56//! appearing inside HTML comments, `<iframe srcdoc>`, or inline scripts.
57//!
58//! [`RenderOptions::with_head_inject`]: webui_handler::RenderOptions::with_head_inject
59//! [`RenderOptions::with_body_inject`]: webui_handler::RenderOptions::with_body_inject
60
61use bytes::Bytes;
62use crossbeam_queue::ArrayQueue;
63use std::sync::Arc;
64use std::time::{Duration, Instant};
65use tokio::sync::mpsc::Sender;
66use webui_handler::{FlushWriter, HandlerError, ResponseWriter, Result};
67
68// ── ChunkPool ──────────────────────────────────────────────────────
69
70/// Lock-free shared pool of `Vec<u8>` buffers used to recycle chunk
71/// allocations across `StreamingWriter` instances.
72///
73/// Backed by a [`crossbeam_queue::ArrayQueue`] (MPMC, lock-free, fixed
74/// capacity). Acquiring a buffer is a single atomic CAS; releasing is
75/// the same. When the pool is empty, `acquire` allocates a fresh
76/// `Vec<u8>`. When the pool is full, `release` drops the buffer.
77///
78/// # Lifetime model
79///
80/// A buffer leaves the pool on `acquire`, gets handed to
81/// [`bytes::Bytes::from_owner`] wrapped in a [`PooledChunk`] owner,
82/// and is released back to the pool when **the last `Bytes` reference
83/// is dropped** — typically after the HTTP framework has flushed the
84/// chunk to the wire. Because `Bytes` may be dropped on any thread
85/// (the actix worker that wrote the chunk to the socket, not the
86/// `spawn_blocking` worker that produced it), the pool MUST be
87/// thread-safe — `ArrayQueue` is.
88///
89/// # Sizing
90///
91/// `max_pool` should match the expected concurrent in-flight chunk
92/// count: `concurrent_renders × channel_capacity` in the worst case.
93/// For the production setup (4-chunk channels, ~100 concurrent
94/// renders), `max_pool = 512` covers the working set; surplus buffers
95/// are dropped when full so memory cannot grow unboundedly.
96///
97/// `chunk_size` should match `StreamingWriter::CHUNK_TARGET +
98/// BUF_HEADROOM`. When acquiring, the writer always grows the buffer
99/// if the pool returned a smaller one (host code that mixes pool
100/// sizes pays a one-time grow per buffer).
101///
102/// # Cost
103///
104/// * `acquire`: 1 atomic CAS (~10 ns on x86) + an `unwrap_or_else`
105///   that allocates only on miss.
106/// * `release`: 1 atomic CAS + drop-on-overflow.
107/// * Pool storage: `max_pool * size_of::<AtomicCell<Vec<u8>>>` =
108///   ~32 bytes per slot, i.e. 512 slots = 16 KiB pool overhead.
109///
110/// # Example
111///
112/// ```ignore
113/// use std::sync::Arc;
114/// use webui::streaming::{ChunkPool, StreamingWriter};
115///
116/// // Construct ONE pool at server startup:
117/// let pool = Arc::new(ChunkPool::new(512, StreamingWriter::CHUNK_TARGET));
118///
119/// // Each request:
120/// let (tx, rx) = tokio::sync::mpsc::channel(StreamingWriter::DEFAULT_CHANNEL_CAPACITY);
121/// let writer = StreamingWriter::new_pooled(tx, Arc::clone(&pool));
122/// ```
123pub struct ChunkPool {
124    queue: ArrayQueue<Vec<u8>>,
125    chunk_size: usize,
126}
127
128impl ChunkPool {
129    /// Create a new shared chunk pool. Wrap in `Arc` and share across
130    /// all `StreamingWriter` instances that should recycle their
131    /// chunk buffers.
132    ///
133    /// `max_pool` is the maximum number of buffers held idle at once.
134    /// Surplus buffers are dropped (returned to the allocator) — this
135    /// caps total pool memory at `max_pool × chunk_size`.
136    ///
137    /// `chunk_size` is the initial capacity used when allocating a
138    /// fresh buffer on a pool miss. Pre-sizing avoids a Vec-grow on
139    /// the hot path.
140    #[must_use]
141    pub fn new(max_pool: usize, chunk_size: usize) -> Self {
142        Self {
143            // ArrayQueue requires capacity > 0.
144            queue: ArrayQueue::new(max_pool.max(1)),
145            chunk_size,
146        }
147    }
148
149    /// Acquire a buffer from the pool, or allocate a fresh one if the
150    /// pool is empty. The returned `Vec` is empty (`len == 0`); its
151    /// capacity is at least `chunk_size` (may be larger if a previous
152    /// caller grew it).
153    ///
154    /// Trusts that callers (only [`PooledChunk::drop`] in this crate)
155    /// have already cleared the buffer before release. In debug builds
156    /// we assert the invariant; release builds skip the check to keep
157    /// `acquire` to a single CAS + capacity check.
158    fn acquire(&self) -> Vec<u8> {
159        match self.queue.pop() {
160            Some(mut buf) => {
161                debug_assert!(
162                    buf.is_empty(),
163                    "ChunkPool invariant violation: pool returned non-empty buffer"
164                );
165                if buf.capacity() < self.chunk_size {
166                    buf.reserve(self.chunk_size - buf.capacity());
167                }
168                buf
169            }
170            None => Vec::with_capacity(self.chunk_size),
171        }
172    }
173
174    /// Release a buffer back to the pool. The buffer is `clear()`-ed
175    /// here (cheap — sets `len` to 0, no deallocation), so `acquire`
176    /// can trust the invariant and skip a defensive clear on the hot
177    /// path. Drops the buffer if the pool is full.
178    fn release(&self, mut buf: Vec<u8>) {
179        buf.clear();
180        // ArrayQueue::push returns Err with the value if full; we
181        // simply drop in that case.
182        let _ = self.queue.push(buf);
183    }
184
185    /// Number of buffers currently idle in the pool. Snapshot-only;
186    /// useful for diagnostic metrics.
187    #[must_use]
188    pub fn idle_count(&self) -> usize {
189        self.queue.len()
190    }
191
192    /// Maximum buffers the pool can hold idle.
193    #[must_use]
194    pub fn capacity(&self) -> usize {
195        self.queue.capacity()
196    }
197}
198
199/// Owner type given to [`bytes::Bytes::from_owner`] so the chunk
200/// buffer returns to the pool when the last `Bytes` reference drops.
201///
202/// `AsRef<[u8]>` is the only contract `Bytes::from_owner` requires;
203/// the data pointer it captures stays valid as long as `self` is
204/// alive (the `Bytes` keeps `self` alive via its internal owner box).
205struct PooledChunk {
206    /// `Option` so we can `take()` the `Vec` in `Drop` and return
207    /// it to the pool — Drop receives `&mut self`, so we can't move
208    /// out of the field directly. Using `Option` keeps the impl
209    /// safe (no `ManuallyDrop` / `unsafe`) at the cost of one
210    /// 8-byte tag per chunk-in-flight; negligible vs the chunk size.
211    buf: Option<Vec<u8>>,
212    pool: Arc<ChunkPool>,
213}
214
215impl PooledChunk {
216    #[inline]
217    fn new(buf: Vec<u8>, pool: Arc<ChunkPool>) -> Self {
218        Self {
219            buf: Some(buf),
220            pool,
221        }
222    }
223}
224
225impl AsRef<[u8]> for PooledChunk {
226    #[inline]
227    fn as_ref(&self) -> &[u8] {
228        // INVARIANT: `buf` is always `Some` until `Drop`. Any `None`
229        // observation here would be a use-after-take bug. We use a
230        // safe match instead of unwrap to comply with the workspace's
231        // `disallowed-methods` lint.
232        match self.buf.as_deref() {
233            Some(slice) => slice,
234            // Should never happen — Drop is the only `take` site, and
235            // `Bytes::from_owner` doesn't expose `&mut`. Returning
236            // `&[]` here is defensive: the wire would just see a
237            // truncated chunk rather than a panic.
238            None => &[],
239        }
240    }
241}
242
243impl Drop for PooledChunk {
244    fn drop(&mut self) {
245        if let Some(buf) = self.buf.take() {
246            // `release` clears the buffer; we don't double-clear here.
247            self.pool.release(buf);
248        }
249    }
250}
251
252// ── StreamingWriter ────────────────────────────────────────────────
253
254/// Streaming `ResponseWriter` backed by a **bounded** tokio mpsc channel
255/// of [`Bytes`].
256///
257/// Coalesces small writes into ~4 KB chunks before flushing. The
258/// underlying channel has a small bound
259/// ([`DEFAULT_CHANNEL_CAPACITY`](Self::DEFAULT_CHANNEL_CAPACITY)) so a
260/// slow consumer naturally backpressures the producer — the render
261/// thread parks instead of queuing the entire response in memory.
262///
263/// A flush deadline ([`with_flush_timeout`](Self::with_flush_timeout))
264/// caps the maximum time the producer thread will park per flush,
265/// bounding the slow-loris DoS surface. When the receiver is dropped
266/// (typically client disconnect) or the deadline is exceeded, subsequent
267/// [`ResponseWriter::write`] calls return a typed error
268/// ([`HandlerError::ClientDisconnected`] / [`HandlerError::StreamTimeout`])
269/// so the handler can short-circuit the render.
270///
271/// # Example
272///
273/// ```ignore
274/// use std::time::Duration;
275/// use tokio::sync::mpsc;
276/// use webui::streaming::StreamingWriter;
277///
278/// let (tx, mut rx) = mpsc::channel(StreamingWriter::DEFAULT_CHANNEL_CAPACITY);
279/// actix_web::rt::task::spawn_blocking(move || {
280///     let mut writer = StreamingWriter::new(tx)
281///         .with_flush_timeout(Duration::from_secs(30));
282///     handler.render(&protocol, &state, &opts, &mut writer);
283///     let _ = ResponseWriter::end(&mut writer);
284/// });
285/// // … wrap rx in a Stream and pass to HttpResponse::streaming …
286/// ```
287pub struct StreamingWriter {
288    tx: Sender<Bytes>,
289    buf: Vec<u8>,
290    chunk_target: usize,
291    /// Maximum time `flush_buf` may park on the channel. `None` =
292    /// unbounded (backwards-compatible default).
293    flush_timeout: Option<Duration>,
294    /// Cached terminal error set after the first failed send/timeout,
295    /// so subsequent `write()` calls short-circuit without paying for
296    /// another atomic round-trip on the channel.
297    terminated: Option<TerminationCause>,
298    /// Optional shared chunk pool. When set, every flushed chunk is
299    /// wrapped in a [`PooledChunk`] owner so its allocation returns
300    /// to the pool when the consumer drops the `Bytes`. The next
301    /// chunk buffer is acquired from the pool instead of being
302    /// freshly allocated. See [`ChunkPool`] for the cost model.
303    pool: Option<Arc<ChunkPool>>,
304}
305
306/// Reason a `StreamingWriter` is terminated. Stored unit-style; no
307/// payload allocation per failed write.
308#[derive(Copy, Clone, Debug, Eq, PartialEq)]
309enum TerminationCause {
310    Disconnected,
311    Timeout,
312}
313
314impl From<TerminationCause> for HandlerError {
315    fn from(cause: TerminationCause) -> Self {
316        match cause {
317            TerminationCause::Disconnected => HandlerError::ClientDisconnected,
318            TerminationCause::Timeout => HandlerError::StreamTimeout,
319        }
320    }
321}
322
323impl StreamingWriter {
324    /// Default chunk-coalescing target. ~4 KB is a balance: large enough
325    /// to amortise per-call channel + actix overhead, small enough that
326    /// head/body content arrives before the body_end script.
327    ///
328    /// Tunable via [`with_chunk_size`](Self::with_chunk_size).
329    pub const CHUNK_TARGET: usize = 4 * 1024;
330
331    /// Default bounded-channel capacity in chunks. With
332    /// `CHUNK_TARGET = 4 KB`, this caps in-flight memory at ~16 KB per
333    /// in-progress request.
334    pub const DEFAULT_CHANNEL_CAPACITY: usize = 4;
335
336    /// Minimum allowed chunk size. Below this the per-flush channel
337    /// overhead dominates the payload cost.
338    const MIN_CHUNK_TARGET: usize = 64;
339
340    /// Slack added to the chunk buffer's capacity beyond `chunk_target`,
341    /// to absorb a single oversized write without an immediate growth
342    /// realloc. 1 KiB is comfortably above the largest single
343    /// `ResponseWriter::write` call the WebUI handler emits in
344    /// practice (signal values, attribute values, raw fragments are
345    /// all small).
346    const BUF_HEADROOM: usize = 1024;
347
348    /// Wrap a tokio mpsc sender. Each render allocates its own chunk
349    /// buffers via the system allocator. For pooled allocation across
350    /// requests, use [`new_pooled`](Self::new_pooled).
351    #[must_use]
352    pub fn new(tx: Sender<Bytes>) -> Self {
353        Self {
354            tx,
355            buf: Vec::with_capacity(Self::CHUNK_TARGET + Self::BUF_HEADROOM),
356            chunk_target: Self::CHUNK_TARGET,
357            flush_timeout: None,
358            terminated: None,
359            pool: None,
360        }
361    }
362
363    /// Wrap a tokio mpsc sender, drawing chunk buffers from the
364    /// shared `pool`. Recycled buffers eliminate per-flush allocation
365    /// in steady-state high-RPS workloads. The pool is shared via
366    /// `Arc` and is safe to use from any number of concurrent
367    /// `StreamingWriter` instances; release happens when the consumer
368    /// drops the `Bytes`, on whichever thread held the last reference.
369    ///
370    /// `chunk_target` defaults to [`CHUNK_TARGET`](Self::CHUNK_TARGET);
371    /// override with [`with_chunk_size`](Self::with_chunk_size). When
372    /// the pool's chunk size disagrees with the writer's target, the
373    /// writer grows the acquired buffer on first use (one-time cost).
374    #[must_use]
375    pub fn new_pooled(tx: Sender<Bytes>, pool: Arc<ChunkPool>) -> Self {
376        let buf = pool.acquire();
377        Self {
378            tx,
379            buf,
380            chunk_target: Self::CHUNK_TARGET,
381            flush_timeout: None,
382            terminated: None,
383            pool: Some(pool),
384        }
385    }
386
387    /// Override the chunk-coalescing target. Larger chunks reduce
388    /// channel + syscall overhead at the cost of higher first-byte
389    /// latency. Values below 64 bytes are silently raised to 64.
390    ///
391    /// Common sizes:
392    /// - **1 KB**: minimise TTFB on small pages.
393    /// - **4 KB** (default): balanced for ~24 KB SSR pages.
394    /// - **16 KB**: match TLS record size for large SSR (>200 KB).
395    #[must_use]
396    pub fn with_chunk_size(mut self, bytes: usize) -> Self {
397        let target = bytes.max(Self::MIN_CHUNK_TARGET);
398        self.chunk_target = target;
399        // Re-initialise the buffer at the new target. If pooled, the
400        // current buffer goes back to the pool (it may be wrong-sized
401        // for this writer, but other writers can still use it).
402        let old = std::mem::replace(
403            &mut self.buf,
404            Vec::with_capacity(target + Self::BUF_HEADROOM),
405        );
406        if let Some(pool) = self.pool.as_ref() {
407            pool.release(old);
408        }
409        self
410    }
411
412    /// Cap the maximum time a flush may park on the channel before
413    /// returning [`HandlerError::StreamTimeout`]. `None` (default) means
414    /// flushes block indefinitely on slow consumers.
415    ///
416    /// Production HTTP hosts should set this (e.g. 30 s) so a single
417    /// slow-loris client cannot pin a render thread forever. The chosen
418    /// timeout × concurrent-render-limit is the upper bound on resources
419    /// an attacker can pin.
420    ///
421    /// Requires an active tokio runtime to be in TLS (i.e. the writer
422    /// is being driven from a `spawn_blocking` task on a tokio runtime).
423    /// Without one, the timeout is silently ignored and a plain
424    /// `blocking_send` is performed.
425    #[must_use]
426    pub fn with_flush_timeout(mut self, timeout: Duration) -> Self {
427        self.flush_timeout = Some(timeout);
428        self
429    }
430
431    /// Send the current buffer as a chunk. Returns `Err` and marks the
432    /// writer terminated on disconnect or timeout.
433    fn flush_buf(&mut self) -> Result<()> {
434        if self.buf.is_empty() {
435            return Ok(());
436        }
437        if let Some(cause) = self.terminated {
438            return Err(cause.into());
439        }
440        // Take the current buffer; immediately install the next one
441        // (pool-acquired or freshly allocated) so subsequent writes
442        // don't need to grow on the fly.
443        let chunk = std::mem::take(&mut self.buf);
444        self.buf = match self.pool.as_ref() {
445            Some(pool) => {
446                // `acquire` returns a buffer with at least `chunk_size`
447                // capacity (clamped at construction); grow if our
448                // chunk_target was overridden to be larger.
449                let mut next = pool.acquire();
450                let want = self.chunk_target + Self::BUF_HEADROOM;
451                if next.capacity() < want {
452                    next.reserve(want - next.capacity());
453                }
454                next
455            }
456            None => Vec::with_capacity(self.chunk_target + Self::BUF_HEADROOM),
457        };
458
459        // Build the payload. Pooled chunks wrap the Vec in a
460        // PooledChunk owner so the buffer returns to the pool on
461        // last-Bytes-drop. Unpooled chunks move the Vec into Bytes
462        // directly (zero-copy via Bytes::from).
463        let payload = match self.pool.as_ref() {
464            Some(pool) => Bytes::from_owner(PooledChunk::new(chunk, Arc::clone(pool))),
465            None => Bytes::from(chunk),
466        };
467
468        let outcome = send_with_optional_timeout(&self.tx, payload, self.flush_timeout);
469        match outcome {
470            SendOutcome::Ok => Ok(()),
471            SendOutcome::Disconnected => {
472                self.terminated = Some(TerminationCause::Disconnected);
473                Err(HandlerError::ClientDisconnected)
474            }
475            SendOutcome::TimedOut => {
476                self.terminated = Some(TerminationCause::Timeout);
477                Err(HandlerError::StreamTimeout)
478            }
479        }
480    }
481
482    /// True after the writer has been terminated by a disconnect or
483    /// flush timeout.
484    #[must_use]
485    pub fn is_terminated(&self) -> bool {
486        self.terminated.is_some()
487    }
488}
489
490impl Drop for StreamingWriter {
491    fn drop(&mut self) {
492        // Return the still-empty next-chunk buffer to the pool so it
493        // doesn't fall on the floor at end-of-render. After the final
494        // `flush_buf`, `self.buf` is the freshly-acquired or freshly-
495        // allocated next buffer; if the render ended without filling
496        // it, releasing it keeps the pool's working set warm.
497        if let Some(pool) = self.pool.as_ref() {
498            let buf = std::mem::take(&mut self.buf);
499            // Only return non-trivial allocations; an empty Vec carries
500            // no allocation (Vec::new) and would just churn the queue.
501            if buf.capacity() > 0 {
502                pool.release(buf);
503            }
504        }
505    }
506}
507
508enum SendOutcome {
509    Ok,
510    Disconnected,
511    TimedOut,
512}
513
514/// Send a chunk via blocking_send, optionally bounded by `timeout`.
515///
516/// When `timeout` is `Some` and a tokio runtime is in TLS, this uses
517/// `Handle::block_on(timeout(send))`. The `block_on` is legal here only
518/// when called from a `spawn_blocking` worker — not from inside async
519/// code — so the writer's documented usage pattern is required.
520///
521/// When `timeout` is `None` we skip the runtime-handle TLS lookup
522/// entirely (saves ~10 ns/flush; meaningful at 10k+ RPS).
523///
524/// **Slow-loris guard fail-safety.** If `timeout` is `Some` but no
525/// tokio runtime is in TLS, we MUST NOT silently fall through to an
526/// unbounded `blocking_send` — that would defeat the documented
527/// slow-loris bound (`timeout × concurrent_renders`). Instead we
528/// emit a `log::warn!` once per process so operators see the
529/// misconfiguration, then enforce the deadline ourselves with a
530/// runtime-free `try_send` + `std::thread::sleep` poll loop. The
531/// poll interval is short relative to the typical timeout (30 s in
532/// production), so the worst-case wakeup overshoot is bounded.
533fn send_with_optional_timeout(
534    tx: &Sender<Bytes>,
535    payload: Bytes,
536    timeout: Option<Duration>,
537) -> SendOutcome {
538    // Fast path: most production writers don't set a timeout.
539    let Some(deadline) = timeout else {
540        return match tx.blocking_send(payload) {
541            Ok(()) => SendOutcome::Ok,
542            Err(_) => SendOutcome::Disconnected,
543        };
544    };
545    if let Ok(handle) = tokio::runtime::Handle::try_current() {
546        let timed =
547            handle.block_on(async { tokio::time::timeout(deadline, tx.send(payload)).await });
548        return match timed {
549            Ok(Ok(())) => SendOutcome::Ok,
550            Ok(Err(_)) => SendOutcome::Disconnected,
551            Err(_) => SendOutcome::TimedOut,
552        };
553    }
554    // No runtime in TLS. Calling `tx.blocking_send` from inside a
555    // tokio worker that's not `spawn_blocking` would panic ("Cannot
556    // block the current thread from within a runtime") and with
557    // `panic = "abort"` in the workspace release profile that aborts
558    // the whole process. Calling it from a raw `std::thread::spawn`
559    // would silently disable the slow-loris bound. Neither is
560    // acceptable, so we enforce the deadline ourselves with a
561    // try_send poll loop.
562    no_runtime_timeout_warn_once();
563    runtime_free_send(tx, payload, deadline)
564}
565
566fn no_runtime_timeout_warn_once() {
567    use std::sync::atomic::{AtomicBool, Ordering};
568    static WARNED: AtomicBool = AtomicBool::new(false);
569    if !WARNED.swap(true, Ordering::Relaxed) {
570        log::warn!(
571            "StreamingWriter::with_flush_timeout was set, but no tokio runtime is in TLS. \
572             Falling back to a runtime-free poll loop (slow-loris bound is preserved but \
573             with a small wakeup overshoot). Wire the writer from `spawn_blocking` to use \
574             the precise tokio path."
575        );
576    }
577}
578
579/// Runtime-free deadline-bounded send. Polls `try_send` with a
580/// short `thread::sleep` between attempts. The poll interval is
581/// 1 ms, so wakeup overshoot vs the configured deadline is bounded
582/// by 1 ms — negligible compared to the typical 30 s production
583/// timeout. Backs off to a longer interval after the first second
584/// to keep idle CPU low for large timeouts.
585fn runtime_free_send(tx: &Sender<Bytes>, payload: Bytes, deadline: Duration) -> SendOutcome {
586    use tokio::sync::mpsc::error::TrySendError;
587    let start = Instant::now();
588    let mut payload = payload;
589    let mut interval = Duration::from_millis(1);
590    let backoff_after = Duration::from_secs(1);
591    loop {
592        match tx.try_send(payload) {
593            Ok(()) => return SendOutcome::Ok,
594            Err(TrySendError::Closed(_)) => return SendOutcome::Disconnected,
595            Err(TrySendError::Full(returned)) => {
596                if start.elapsed() >= deadline {
597                    return SendOutcome::TimedOut;
598                }
599                std::thread::sleep(interval);
600                if start.elapsed() >= backoff_after && interval < Duration::from_millis(50) {
601                    interval = Duration::from_millis(50);
602                }
603                payload = returned;
604            }
605        }
606    }
607}
608
609impl ResponseWriter for StreamingWriter {
610    fn write(&mut self, content: &str) -> Result<()> {
611        if let Some(cause) = self.terminated {
612            return Err(cause.into());
613        }
614        self.buf.extend_from_slice(content.as_bytes());
615        if self.buf.len() >= self.chunk_target {
616            self.flush_buf()?;
617        }
618        Ok(())
619    }
620
621    fn write_attribute(&mut self, name: &str, value: &str) -> Result<()> {
622        if let Some(cause) = self.terminated {
623            return Err(cause.into());
624        }
625        webui_handler::append_attribute_to_bytes(&mut self.buf, name, value);
626        if self.buf.len() >= self.chunk_target {
627            self.flush_buf()?;
628        }
629        Ok(())
630    }
631
632    fn write_boolean_attribute(&mut self, name: &str) -> Result<()> {
633        if let Some(cause) = self.terminated {
634            return Err(cause.into());
635        }
636        webui_handler::append_boolean_attribute_to_bytes(&mut self.buf, name);
637        if self.buf.len() >= self.chunk_target {
638            self.flush_buf()?;
639        }
640        Ok(())
641    }
642
643    fn end(&mut self) -> Result<()> {
644        // Surface the final-flush error so the caller can distinguish
645        // "fully delivered" from "client gave up at the very last
646        // chunk." If `terminated` is already set, `write()` already
647        // surfaced the error earlier — return Ok here so the caller
648        // doesn't see the same disconnect twice.
649        //
650        // This is the contract that motivated introducing
651        // `HandlerError::ClientDisconnected` / `StreamTimeout` in the
652        // first place: callers want a programmatic signal so they can
653        // decrement `render_errors_total` correctly and avoid logging
654        // truncated responses as 200-OK successes.
655        if self.terminated.is_some() {
656            return Ok(());
657        }
658        self.flush_buf()
659    }
660}
661
662impl FlushWriter for StreamingWriter {
663    fn flush(&mut self) -> Result<()> {
664        self.flush_buf()
665    }
666}
667
668// ── Tests ──────────────────────────────────────────────────────────
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673
674    // ── StreamingWriter tests ───────────────────────────────────────
675
676    fn drain(mut rx: tokio::sync::mpsc::Receiver<Bytes>) -> String {
677        let mut buf = Vec::new();
678        while let Ok(chunk) = rx.try_recv() {
679            buf.extend_from_slice(&chunk);
680        }
681        String::from_utf8(buf).expect("valid utf-8")
682    }
683
684    #[test]
685    fn streaming_writer_coalesces_small_writes() {
686        let (tx, rx) = tokio::sync::mpsc::channel::<Bytes>(8);
687        let mut w = StreamingWriter::new(tx);
688        for _ in 0..10 {
689            ResponseWriter::write(&mut w, "abc").unwrap();
690        }
691        ResponseWriter::end(&mut w).unwrap();
692        drop(w);
693        assert_eq!(drain(rx), "abc".repeat(10));
694    }
695
696    #[test]
697    fn streaming_writer_flushes_at_chunk_boundary() {
698        let (tx, mut rx) = tokio::sync::mpsc::channel::<Bytes>(8);
699        let mut w = StreamingWriter::new(tx);
700        let big = "x".repeat(StreamingWriter::CHUNK_TARGET);
701        ResponseWriter::write(&mut w, &big).unwrap();
702        let first = rx.try_recv().expect("first chunk should be available");
703        assert_eq!(first.len(), StreamingWriter::CHUNK_TARGET);
704        ResponseWriter::end(&mut w).unwrap();
705    }
706
707    #[test]
708    fn streaming_writer_explicit_flush_sends_partial_chunk() {
709        let (tx, mut rx) = tokio::sync::mpsc::channel::<Bytes>(8);
710        let mut writer = StreamingWriter::new(tx);
711        ResponseWriter::write(&mut writer, "boundary").unwrap();
712        assert!(rx.try_recv().is_err());
713
714        FlushWriter::flush(&mut writer).unwrap();
715        assert_eq!(
716            rx.try_recv().expect("explicitly flushed chunk"),
717            Bytes::from_static(b"boundary")
718        );
719    }
720
721    #[test]
722    fn streaming_writer_returns_typed_error_after_disconnect() {
723        let (tx, rx) = tokio::sync::mpsc::channel::<Bytes>(1);
724        let mut w = StreamingWriter::new(tx);
725        drop(rx);
726        ResponseWriter::write(&mut w, "hi").unwrap();
727        let big = "x".repeat(StreamingWriter::CHUNK_TARGET);
728        let result = ResponseWriter::write(&mut w, &big);
729        assert!(matches!(result, Err(HandlerError::ClientDisconnected)));
730        assert!(w.is_terminated());
731        let result2 = ResponseWriter::write(&mut w, "more");
732        assert!(matches!(result2, Err(HandlerError::ClientDisconnected)));
733    }
734
735    #[test]
736    fn streaming_writer_end_after_disconnect_succeeds() {
737        let (tx, rx) = tokio::sync::mpsc::channel::<Bytes>(1);
738        let mut w = StreamingWriter::new(tx);
739        drop(rx);
740        let big = "x".repeat(StreamingWriter::CHUNK_TARGET);
741        let _ = ResponseWriter::write(&mut w, &big);
742        assert!(w.is_terminated());
743        // Already-terminated end() returns Ok — the error was already
744        // surfaced via write() and the caller acted on it.
745        ResponseWriter::end(&mut w).unwrap();
746    }
747
748    /// Regression for the bug Akrosh caught: when the writer hasn't
749    /// yet flushed (sub-`chunk_target` content) and the receiver has
750    /// disconnected, `end()` MUST surface the typed error rather than
751    /// silently returning `Ok(())` and lying to the caller about a
752    /// successful response.
753    #[test]
754    fn streaming_writer_end_surfaces_first_flush_error() {
755        let (tx, rx) = tokio::sync::mpsc::channel::<Bytes>(1);
756        let mut w = StreamingWriter::new(tx);
757        drop(rx);
758        // Below `chunk_target` — no automatic flush from write(),
759        // so `terminated` is None at the time end() runs.
760        ResponseWriter::write(&mut w, "small").unwrap();
761        assert!(!w.is_terminated(), "no automatic flush yet");
762
763        let result = ResponseWriter::end(&mut w);
764        assert!(
765            matches!(result, Err(HandlerError::ClientDisconnected)),
766            "end() must surface ClientDisconnected from final flush, got {result:?}"
767        );
768        assert!(w.is_terminated(), "writer must be marked terminated");
769    }
770
771    #[test]
772    fn streaming_writer_custom_chunk_size() {
773        let (tx, mut rx) = tokio::sync::mpsc::channel::<Bytes>(8);
774        let mut w = StreamingWriter::new(tx).with_chunk_size(128);
775        ResponseWriter::write(&mut w, &"x".repeat(127)).unwrap();
776        assert!(rx.try_recv().is_err(), "below threshold, no flush yet");
777        ResponseWriter::write(&mut w, "x").unwrap();
778        let first = rx.try_recv().expect("chunk should flush at 128 bytes");
779        assert_eq!(first.len(), 128);
780    }
781
782    #[test]
783    fn streaming_writer_min_chunk_size_clamp() {
784        let (tx, _rx) = tokio::sync::mpsc::channel::<Bytes>(8);
785        let w = StreamingWriter::new(tx).with_chunk_size(1);
786        assert_eq!(w.chunk_target, StreamingWriter::MIN_CHUNK_TARGET);
787    }
788
789    /// Positive test for the slow-loris guard. Without a tokio runtime
790    /// in TLS, `with_flush_timeout` is forced down the runtime-free
791    /// poll-loop path. Fill a 1-slot channel without consuming it,
792    /// then verify the writer surfaces `Err(StreamTimeout)` after the
793    /// configured deadline (and does NOT silently fall through to an
794    /// untimed `blocking_send` as the previous implementation did).
795    ///
796    /// Akrosh's review caught this gap: the slow-loris bound was
797    /// previously the framework's only DoS guard but had no positive
798    /// test, and the fallback path was a `debug_assert!(false)` that
799    /// compiled to a no-op in release.
800    #[test]
801    fn streaming_writer_flush_timeout_fires_without_runtime() {
802        let (tx, _rx) = tokio::sync::mpsc::channel::<Bytes>(1);
803        let mut w = StreamingWriter::new(tx)
804            .with_chunk_size(64)
805            .with_flush_timeout(Duration::from_millis(150));
806
807        // Fill the 1-slot channel.
808        ResponseWriter::write(&mut w, &"x".repeat(64)).unwrap();
809        // Next flush has nowhere to go → must time out within
810        // ~deadline + 1 ms (poll interval). Allow a generous CI cushion.
811        let start = Instant::now();
812        let result = ResponseWriter::write(&mut w, &"y".repeat(64));
813        let elapsed = start.elapsed();
814
815        assert!(
816            matches!(result, Err(HandlerError::StreamTimeout)),
817            "expected Err(StreamTimeout), got {result:?}"
818        );
819        assert!(
820            elapsed >= Duration::from_millis(150),
821            "must wait at least the deadline; elapsed={elapsed:?}"
822        );
823        assert!(
824            elapsed < Duration::from_millis(1500),
825            "must not block much past the deadline; elapsed={elapsed:?}"
826        );
827        assert!(w.is_terminated(), "writer must be marked terminated");
828
829        // Subsequent writes short-circuit (no second timeout wait).
830        let start = Instant::now();
831        let result2 = ResponseWriter::write(&mut w, "more");
832        assert!(matches!(result2, Err(HandlerError::StreamTimeout)));
833        assert!(
834            start.elapsed() < Duration::from_millis(50),
835            "subsequent writes must short-circuit; elapsed={:?}",
836            start.elapsed()
837        );
838    }
839
840    // ── ChunkPool tests ─────────────────────────────────────────────
841
842    /// Acquire/release round-trip: a buffer pushed into the pool comes
843    /// back out empty with at least the requested capacity.
844    #[test]
845    fn pool_round_trip() {
846        let pool = ChunkPool::new(4, 1024);
847        let buf = pool.acquire();
848        assert!(buf.capacity() >= 1024);
849        assert_eq!(buf.len(), 0);
850        assert_eq!(pool.idle_count(), 0);
851
852        pool.release(buf);
853        assert_eq!(pool.idle_count(), 1);
854
855        // Second acquire returns the released buffer (capacity preserved).
856        let buf2 = pool.acquire();
857        assert!(buf2.capacity() >= 1024);
858        assert_eq!(pool.idle_count(), 0);
859    }
860
861    /// A non-empty buffer released to the pool must come back empty
862    /// (defensive `clear()` in `acquire`).
863    #[test]
864    fn pool_clears_dirty_buffer_on_acquire() {
865        let pool = ChunkPool::new(2, 16);
866        let mut dirty = Vec::with_capacity(64);
867        dirty.extend_from_slice(b"leftover content");
868        pool.release(dirty);
869
870        let acquired = pool.acquire();
871        assert_eq!(acquired.len(), 0, "acquired buffer must be empty");
872    }
873
874    /// Pool capacity is enforced — overflow buffers are dropped, not
875    /// queued.
876    #[test]
877    fn pool_full_drops_excess() {
878        let pool = ChunkPool::new(2, 8);
879        pool.release(Vec::with_capacity(8));
880        pool.release(Vec::with_capacity(8));
881        assert_eq!(pool.idle_count(), 2);
882        // Third release would exceed capacity; queue rejects it silently.
883        pool.release(Vec::with_capacity(8));
884        assert_eq!(pool.idle_count(), 2, "pool must not grow beyond capacity");
885    }
886
887    /// `PooledChunk` must return its buffer to the pool when dropped.
888    /// This is the lifecycle the production path depends on:
889    /// `Bytes::from_owner(PooledChunk)` keeps the chunk alive while
890    /// the actix worker writes it to the wire; on the worker's drop,
891    /// the buffer recycles.
892    #[test]
893    fn pooled_chunk_drop_returns_to_pool() {
894        let pool = Arc::new(ChunkPool::new(4, 256));
895        assert_eq!(pool.idle_count(), 0);
896
897        let buf = pool.acquire();
898        assert_eq!(pool.idle_count(), 0);
899
900        let payload = Bytes::from_owner(PooledChunk::new(buf, Arc::clone(&pool)));
901        // Bytes is alive → buffer is "in flight".
902        assert_eq!(pool.idle_count(), 0);
903
904        drop(payload);
905        // Buffer returned.
906        assert_eq!(pool.idle_count(), 1);
907    }
908
909    /// Cloning a `Bytes` shares the chunk; only when the LAST clone
910    /// drops does the buffer return to the pool. This models the
911    /// actix → tcp pipeline where multiple internal layers may hold
912    /// references.
913    #[test]
914    fn pooled_chunk_returns_after_last_clone_drop() {
915        let pool = Arc::new(ChunkPool::new(4, 256));
916        let buf = pool.acquire();
917        let original = Bytes::from_owner(PooledChunk::new(buf, Arc::clone(&pool)));
918        let clone1 = original.clone();
919        let clone2 = original.clone();
920        assert_eq!(pool.idle_count(), 0);
921
922        drop(original);
923        assert_eq!(pool.idle_count(), 0, "still 2 refs alive");
924        drop(clone1);
925        assert_eq!(pool.idle_count(), 0, "still 1 ref alive");
926        drop(clone2);
927        assert_eq!(pool.idle_count(), 1, "last ref dropped, buffer returned");
928    }
929
930    /// `StreamingWriter::new_pooled` recycles its chunk buffers across
931    /// successive renders that share the same pool. After the first
932    /// render fills the pool, subsequent renders should not allocate
933    /// fresh chunk buffers.
934    #[test]
935    fn streaming_writer_pooled_recycles_buffers() {
936        let pool = Arc::new(ChunkPool::new(8, StreamingWriter::CHUNK_TARGET));
937
938        // First render: pool starts empty, every flush allocates.
939        {
940            let (tx, rx) = tokio::sync::mpsc::channel::<Bytes>(8);
941            let mut w = StreamingWriter::new_pooled(tx, Arc::clone(&pool));
942            for _ in 0..3 {
943                ResponseWriter::write(&mut w, &"x".repeat(StreamingWriter::CHUNK_TARGET)).unwrap();
944            }
945            ResponseWriter::end(&mut w).unwrap();
946            drop(w);
947            // Drain the channel — drops the Bytes → returns chunks to pool.
948            let _ = drain(rx);
949        }
950        let after_first = pool.idle_count();
951        assert!(
952            after_first >= 3,
953            "after first render, pool should have ≥3 buffers; got {after_first}"
954        );
955
956        // Second render: should reuse pooled buffers.
957        let before_second = pool.idle_count();
958        {
959            let (tx, rx) = tokio::sync::mpsc::channel::<Bytes>(8);
960            let mut w = StreamingWriter::new_pooled(tx, Arc::clone(&pool));
961            for _ in 0..3 {
962                ResponseWriter::write(&mut w, &"x".repeat(StreamingWriter::CHUNK_TARGET)).unwrap();
963            }
964            ResponseWriter::end(&mut w).unwrap();
965            drop(w);
966            let _ = drain(rx);
967        }
968        let after_second = pool.idle_count();
969        // Idle count should be steady — every buffer acquired during the
970        // second render came back at the end.
971        assert!(
972            after_second >= before_second.saturating_sub(1),
973            "pool should not shrink across renders: before={before_second} after={after_second}"
974        );
975    }
976
977    /// Cross-thread drop safety: a `PooledChunk` built on thread A
978    /// can be dropped on thread B, and the buffer returns to the
979    /// shared pool correctly. This is the actix scenario (producer
980    /// is `spawn_blocking`, consumer drops on the I/O worker).
981    #[test]
982    fn pooled_chunk_cross_thread_drop() {
983        let pool = Arc::new(ChunkPool::new(4, 128));
984        let buf = pool.acquire();
985        let payload = Bytes::from_owner(PooledChunk::new(buf, Arc::clone(&pool)));
986
987        let pool_for_thread = Arc::clone(&pool);
988        let h = std::thread::spawn(move || {
989            // Drop on the spawned thread.
990            drop(payload);
991            // Verify drop ran by checking idle count from this thread.
992            assert_eq!(pool_for_thread.idle_count(), 1);
993        });
994        h.join().unwrap();
995        // Main thread sees the recycled buffer too.
996        assert_eq!(pool.idle_count(), 1);
997    }
998}