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}