Skip to content

Commit 02090a5

Browse files
committed
fix(network): buffer chunked relay read-ahead
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
1 parent 249d6c7 commit 02090a5

2 files changed

Lines changed: 133 additions & 66 deletions

File tree

crates/openshell-supervisor-network/src/l7/relay.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ use std::sync::Arc;
3535
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt};
3636
use tracing::{debug, warn};
3737

38+
const CONNECTION_READ_AHEAD_BYTES: usize = 8 * 1024;
39+
3840
/// Context for L7 request policy evaluation.
3941
#[derive(Clone)]
4042
#[cfg_attr(test, derive(Default))]
@@ -754,6 +756,17 @@ where
754756
C: AsyncRead + AsyncWrite + Unpin + Send,
755757
U: AsyncRead + AsyncWrite + Unpin + Send,
756758
{
759+
// Keep read-ahead state for the lifetime of the inspected connection. An
760+
// HTTP parser may fetch bytes from the next pipelined request while
761+
// finishing the current one; retaining them here ensures the next request
762+
// still passes through its own policy decision.
763+
let mut client_buffer =
764+
tokio::io::BufReader::with_capacity(CONNECTION_READ_AHEAD_BYTES, client);
765+
let mut upstream_buffer =
766+
tokio::io::BufReader::with_capacity(CONNECTION_READ_AHEAD_BYTES, upstream);
767+
let client = &mut client_buffer;
768+
let upstream = &mut upstream_buffer;
769+
757770
match config.protocol {
758771
L7Protocol::Rest | L7Protocol::Websocket => {
759772
relay_rest(config, &engine, client, upstream, ctx).await
@@ -813,6 +826,15 @@ where
813826
C: AsyncRead + AsyncWrite + Unpin + Send,
814827
U: AsyncRead + AsyncWrite + Unpin + Send,
815828
{
829+
// Route selection also owns the full keep-alive loop, so buffered bytes
830+
// remain available across per-request parsing and authorization.
831+
let mut client_buffer =
832+
tokio::io::BufReader::with_capacity(CONNECTION_READ_AHEAD_BYTES, client);
833+
let mut upstream_buffer =
834+
tokio::io::BufReader::with_capacity(CONNECTION_READ_AHEAD_BYTES, upstream);
835+
let client = &mut client_buffer;
836+
let upstream = &mut upstream_buffer;
837+
816838
let provider =
817839
crate::l7::rest::RestProvider::with_options(crate::l7::path::CanonicalizeOptions {
818840
allow_encoded_slash: configs.iter().any(|config| config.allow_encoded_slash),

crates/openshell-supervisor-network/src/l7/rest.rs

Lines changed: 111 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,6 @@ async fn max_middleware_body_bytes() -> usize {
6767
chain[0].max_payload_bytes()
6868
}
6969
const RELAY_BUF_SIZE: usize = 8192;
70-
/// Maximum aggregate chunk framing relayed for one body. Framing includes
71-
/// chunk-size lines, the CRLF after each chunk payload, and trailers. Unknown
72-
/// length framing lines must be read to their boundary one byte at a time so a
73-
/// pipelined request is not consumed; bounding their aggregate size also bounds
74-
/// the resulting read/write amplification for tiny chunks.
75-
const MAX_CHUNKED_FRAMING_BYTES: usize = 32 * 1024;
7670
const RESPONSE_UNIT_COALESCE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(2);
7771
const HTTP_METHOD_PREFIXES: &[&[u8]] = &[
7872
b"GET ",
@@ -3134,10 +3128,10 @@ where
31343128
let started_at = std::time::Instant::now();
31353129
let mut read_buf = [0u8; RELAY_BUF_SIZE];
31363130
let mut parse_buf = Vec::from(already_forwarded);
3131+
let mut forwarded_len = already_forwarded.len();
31373132
let mut pos = 0usize;
31383133
let mut chunk_count = 0usize;
31393134
let mut chunk_payload_bytes = 0usize;
3140-
let mut framing_bytes = 0usize;
31413135

31423136
// Parse chunk-size lines + chunk payloads until final 0-size chunk, then
31433137
// parse trailers until the terminating empty trailer line.
@@ -3147,17 +3141,15 @@ where
31473141
if let Some(end) = find_crlf(&parse_buf, pos) {
31483142
break end;
31493143
}
3150-
ensure_chunked_framing_capacity(
3151-
framing_bytes,
3152-
parse_buf.len().saturating_sub(pos).saturating_add(1),
3153-
)?;
3144+
if parse_buf.len().saturating_sub(pos) >= MAX_HEADER_BYTES {
3145+
return Err(miette!("Chunk-size line exceeds limit"));
3146+
}
31543147
let target_len = parse_buf
31553148
.len()
31563149
.checked_add(1)
31573150
.ok_or_else(|| miette!("Chunked body size overflow"))?;
31583151
relay_chunked_until_len(
31593152
reader,
3160-
writer,
31613153
&mut read_buf,
31623154
&mut parse_buf,
31633155
target_len,
@@ -3166,7 +3158,9 @@ where
31663158
)
31673159
.await?;
31683160
};
3169-
add_chunked_framing_bytes(&mut framing_bytes, size_line_end + 2 - pos)?;
3161+
if size_line_end.saturating_add(2).saturating_sub(pos) > MAX_HEADER_BYTES {
3162+
return Err(miette!("Chunk-size line exceeds limit"));
3163+
}
31703164

31713165
let size_line = std::str::from_utf8(&parse_buf[pos..size_line_end])
31723166
.into_diagnostic()
@@ -3180,6 +3174,15 @@ where
31803174
.into_diagnostic()
31813175
.map_err(|_| miette!("Invalid chunk size token: {size_token:?}"))?;
31823176
pos = size_line_end + 2;
3177+
flush_validated_chunked_bytes(
3178+
writer,
3179+
&parse_buf,
3180+
&mut forwarded_len,
3181+
pos,
3182+
false,
3183+
generation_guard,
3184+
)
3185+
.await?;
31833186

31843187
if chunk_size == 0 {
31853188
// Parse trailers (if any). Terminates on empty trailer line.
@@ -3189,17 +3192,15 @@ where
31893192
if let Some(end) = find_crlf(&parse_buf, pos) {
31903193
break end;
31913194
}
3192-
ensure_chunked_framing_capacity(
3193-
framing_bytes,
3194-
parse_buf.len().saturating_sub(pos).saturating_add(1),
3195-
)?;
3195+
if parse_buf.len().saturating_sub(pos) >= MAX_HEADER_BYTES {
3196+
return Err(miette!("Chunk trailer line exceeds limit"));
3197+
}
31963198
let target_len = parse_buf
31973199
.len()
31983200
.checked_add(1)
31993201
.ok_or_else(|| miette!("Chunked trailer size overflow"))?;
32003202
relay_chunked_until_len(
32013203
reader,
3202-
writer,
32033204
&mut read_buf,
32043205
&mut parse_buf,
32053206
target_len,
@@ -3208,11 +3209,23 @@ where
32083209
)
32093210
.await?;
32103211
};
3212+
if trailer_end.saturating_add(2).saturating_sub(pos) > MAX_HEADER_BYTES {
3213+
return Err(miette!("Chunk trailer line exceeds limit"));
3214+
}
32113215

32123216
let trailer_line = &parse_buf[pos..trailer_end];
3213-
add_chunked_framing_bytes(&mut framing_bytes, trailer_end + 2 - pos)?;
3217+
let trailer_is_empty = trailer_line.is_empty();
32143218
pos = trailer_end + 2;
3215-
if trailer_line.is_empty() {
3219+
flush_validated_chunked_bytes(
3220+
writer,
3221+
&parse_buf,
3222+
&mut forwarded_len,
3223+
pos,
3224+
trailer_is_empty,
3225+
generation_guard,
3226+
)
3227+
.await?;
3228+
if trailer_is_empty {
32163229
debug!(
32173230
chunk_count,
32183231
chunk_payload_bytes,
@@ -3223,6 +3236,11 @@ where
32233236
return Ok(());
32243237
}
32253238
trailer_count += 1;
3239+
if pos > RELAY_BUF_SIZE * 4 && forwarded_len >= pos {
3240+
parse_buf.drain(..pos);
3241+
forwarded_len -= pos;
3242+
pos = 0;
3243+
}
32263244
}
32273245
}
32283246

@@ -3234,10 +3252,8 @@ where
32343252
.checked_add(2)
32353253
.ok_or_else(|| miette!("Chunk size overflow"))?;
32363254

3237-
ensure_chunked_framing_capacity(framing_bytes, 2)?;
32383255
relay_chunked_until_len(
32393256
reader,
3240-
writer,
32413257
&mut read_buf,
32423258
&mut parse_buf,
32433259
chunk_with_crlf_end,
@@ -3248,42 +3264,35 @@ where
32483264
if &parse_buf[chunk_end..chunk_with_crlf_end] != b"\r\n" {
32493265
return Err(miette!("Chunk missing terminating CRLF"));
32503266
}
3251-
add_chunked_framing_bytes(&mut framing_bytes, 2)?;
32523267
pos = chunk_with_crlf_end;
3268+
flush_validated_chunked_bytes(
3269+
writer,
3270+
&parse_buf,
3271+
&mut forwarded_len,
3272+
pos,
3273+
false,
3274+
generation_guard,
3275+
)
3276+
.await?;
32533277
chunk_count += 1;
32543278
chunk_payload_bytes = chunk_payload_bytes.saturating_add(chunk_size);
32553279

32563280
// Keep parser memory bounded for long streams.
3257-
if pos > RELAY_BUF_SIZE * 4 {
3281+
if pos > RELAY_BUF_SIZE * 4 && forwarded_len >= pos {
32583282
parse_buf.drain(..pos);
3283+
forwarded_len -= pos;
32593284
pos = 0;
32603285
}
32613286
}
32623287
}
32633288

3264-
fn ensure_chunked_framing_capacity(current: usize, additional: usize) -> Result<()> {
3265-
if additional > MAX_CHUNKED_FRAMING_BYTES.saturating_sub(current) {
3266-
return Err(miette!(
3267-
"Chunked body framing exceeds {MAX_CHUNKED_FRAMING_BYTES} bytes"
3268-
));
3269-
}
3270-
Ok(())
3271-
}
3272-
3273-
fn add_chunked_framing_bytes(current: &mut usize, additional: usize) -> Result<()> {
3274-
ensure_chunked_framing_capacity(*current, additional)?;
3275-
*current += additional;
3276-
Ok(())
3277-
}
3278-
3279-
/// Read and forward only the bytes needed to reach `target_len`.
3289+
/// Read only the bytes needed to reach `target_len`.
32803290
///
3281-
/// Limiting each socket read to the current chunked framing boundary prevents
3282-
/// a pipelined request from being consumed and written upstream as part of the
3283-
/// authorized request body.
3284-
async fn relay_chunked_until_len<R, W>(
3291+
/// The connection-scoped buffered reader may fetch more data from the socket,
3292+
/// but this parser consumes only the current body. Any read-ahead remains in
3293+
/// that reader for the next request's policy decision.
3294+
async fn relay_chunked_until_len<R>(
32853295
reader: &mut R,
3286-
writer: &mut W,
32873296
read_buf: &mut [u8; RELAY_BUF_SIZE],
32883297
parse_buf: &mut Vec<u8>,
32893298
target_len: usize,
@@ -3292,7 +3301,6 @@ async fn relay_chunked_until_len<R, W>(
32923301
) -> Result<()>
32933302
where
32943303
R: AsyncRead + Unpin,
3295-
W: AsyncWrite + Unpin,
32963304
{
32973305
while parse_buf.len() < target_len {
32983306
let remaining = target_len - parse_buf.len();
@@ -3307,12 +3315,42 @@ where
33073315
if let Some(guard) = generation_guard {
33083316
guard.ensure_current()?;
33093317
}
3310-
writer.write_all(&read_buf[..n]).await.into_diagnostic()?;
33113318
parse_buf.extend_from_slice(&read_buf[..n]);
33123319
}
33133320
Ok(())
33143321
}
33153322

3323+
/// Forward complete, validated chunk framing in relay-sized batches.
3324+
///
3325+
/// Tiny chunks no longer cause one upstream write per framing byte, while the
3326+
/// final forced flush guarantees the complete body reaches upstream before the
3327+
/// response relay starts.
3328+
async fn flush_validated_chunked_bytes<W>(
3329+
writer: &mut W,
3330+
parse_buf: &[u8],
3331+
forwarded_len: &mut usize,
3332+
validated_len: usize,
3333+
force: bool,
3334+
generation_guard: Option<&PolicyGenerationGuard>,
3335+
) -> Result<()>
3336+
where
3337+
W: AsyncWrite + Unpin,
3338+
{
3339+
let pending = validated_len.saturating_sub(*forwarded_len);
3340+
if pending == 0 || (!force && pending < RELAY_BUF_SIZE) {
3341+
return Ok(());
3342+
}
3343+
if let Some(guard) = generation_guard {
3344+
guard.ensure_current()?;
3345+
}
3346+
writer
3347+
.write_all(&parse_buf[*forwarded_len..validated_len])
3348+
.await
3349+
.into_diagnostic()?;
3350+
*forwarded_len = validated_len;
3351+
Ok(())
3352+
}
3353+
33163354
fn find_crlf(buf: &[u8], start: usize) -> Option<usize> {
33173355
buf.get(start..)?
33183356
.windows(2)
@@ -3940,6 +3978,7 @@ mod tests {
39403978
#[derive(Default)]
39413979
struct CountingWriter {
39423980
writes: usize,
3981+
bytes: Vec<u8>,
39433982
}
39443983

39453984
impl AsyncWrite for CountingWriter {
@@ -3949,6 +3988,7 @@ mod tests {
39493988
buffer: &[u8],
39503989
) -> Poll<std::io::Result<usize>> {
39513990
self.writes += 1;
3991+
self.bytes.extend_from_slice(buffer);
39523992
Poll::Ready(Ok(buffer.len()))
39533993
}
39543994

@@ -4677,8 +4717,9 @@ mod tests {
46774717
let mut wire = chunked_body.to_vec();
46784718
wire.extend_from_slice(pipelined_request);
46794719

4680-
let (mut relay_reader, mut client_writer) = tokio::io::duplex(4096);
4720+
let (relay_reader, mut client_writer) = tokio::io::duplex(4096);
46814721
client_writer.write_all(&wire).await.unwrap();
4722+
let mut relay_reader = tokio::io::BufReader::with_capacity(4096, relay_reader);
46824723
let (mut relay_writer, mut upstream_reader) = tokio::io::duplex(4096);
46834724

46844725
relay_chunked(&mut relay_reader, &mut relay_writer, &[], None)
@@ -4704,8 +4745,9 @@ mod tests {
47044745
let mut later_read = body_remainder.to_vec();
47054746
later_read.extend_from_slice(pipelined_request);
47064747

4707-
let (mut relay_reader, mut client_writer) = tokio::io::duplex(4096);
4748+
let (relay_reader, mut client_writer) = tokio::io::duplex(4096);
47084749
client_writer.write_all(&later_read).await.unwrap();
4750+
let mut relay_reader = tokio::io::BufReader::with_capacity(4096, relay_reader);
47094751
let (mut relay_writer, mut upstream_reader) = tokio::io::duplex(4096);
47104752
relay_writer.write_all(already_forwarded).await.unwrap();
47114753

@@ -4731,32 +4773,35 @@ mod tests {
47314773
}
47324774

47334775
#[tokio::test]
4734-
async fn relay_chunked_bounds_tiny_chunk_io_amplification() {
4735-
let mut wire = Vec::new();
4776+
async fn relay_chunked_buffers_tiny_chunks_without_an_aggregate_framing_limit() {
4777+
let mut chunked_body = Vec::new();
47364778
for _ in 0..10_000 {
4737-
wire.extend_from_slice(b"1\r\na\r\n");
4779+
chunked_body.extend_from_slice(b"1\r\na\r\n");
47384780
}
4739-
wire.extend_from_slice(b"0\r\n\r\n");
4781+
chunked_body.extend_from_slice(b"0\r\n\r\n");
4782+
let pipelined_request = b"DELETE /blocked HTTP/1.1\r\nHost: example.com\r\n\r\n";
4783+
let mut wire = chunked_body.clone();
4784+
wire.extend_from_slice(pipelined_request);
47404785

4741-
let mut reader = CountingReader::new(wire);
4786+
let mut reader =
4787+
tokio::io::BufReader::with_capacity(RELAY_BUF_SIZE, CountingReader::new(wire));
47424788
let mut writer = CountingWriter::default();
4743-
let error = relay_chunked(&mut reader, &mut writer, &[], None)
4789+
relay_chunked(&mut reader, &mut writer, &[], None)
47444790
.await
4745-
.expect_err("excessive aggregate chunk framing must be rejected");
4791+
.expect("valid tiny chunks must not be rejected by an aggregate framing limit");
47464792

4793+
let reads_after_body = reader.get_ref().reads;
4794+
let mut remaining = vec![0; pipelined_request.len()];
4795+
reader.read_exact(&mut remaining).await.unwrap();
4796+
assert_eq!(remaining, pipelined_request);
4797+
assert_eq!(writer.bytes, chunked_body);
47474798
assert!(
4748-
error
4749-
.to_string()
4750-
.contains("Chunked body framing exceeds 32768 bytes")
4751-
);
4752-
assert!(
4753-
reader.reads < 40_000,
4754-
"framing limit allowed {} socket reads",
4755-
reader.reads
4799+
reads_after_body < 100,
4800+
"connection buffering required {reads_after_body} underlying reads"
47564801
);
47574802
assert!(
4758-
writer.writes < 40_000,
4759-
"framing limit allowed {} upstream writes",
4803+
writer.writes < 100,
4804+
"framing coalescing required {} upstream writes",
47604805
writer.writes
47614806
);
47624807
}

0 commit comments

Comments
 (0)