Skip to content

Fix flickery haproxy tests - #145

Open
ctz wants to merge 2 commits into
mainfrom
jbp-haproxy-fixes
Open

Fix flickery haproxy tests#145
ctz wants to merge 2 commits into
mainfrom
jbp-haproxy-fixes

Conversation

@ctz

@ctz ctz commented Jul 31, 2026

Copy link
Copy Markdown
Member

Assisted by Opus 5.

ctz added 2 commits July 31, 2026 14:03
OpenSSL by default only reads a record a time, which is visible to callers as it
affects IO readiness of the underlying socket.  See `SSL_get_read_ahead` and
associated.

Rustls reads blindly the underlying transport.  This means haproxy sometimes hangs as
enters the TLS library entirely on the basis of IO readiness, rather than looking at
any libssl API.

This change constrains reading to follow TLS records, which is OpenSSL's default.
If we can't make progress to handshake completion due to IO, we shouldn't
be reporting success.
Comment thread src/bio.rs
/// Account for `data`, which was just read from the transport.
fn consume(&mut self, data: &[u8]) {
if self.body_remaining > 0 {
self.body_remaining -= data.len().min(self.body_remaining);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: maybe Ord::min()? (Also below.)

Comment thread src/bio.rs
}

/// Account for `data`, which was just read from the transport.
fn consume(&mut self, data: &[u8]) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this consume() name seems a little confusing, since it's not like RecordLimit is ingesting the bytes -- maybe update()?

Comment thread src/bio.rs
self.header_used += take;

if self.header_used == Self::HEADER_LEN {
self.body_remaining = u16::from_be_bytes([self.header[3], self.header[4]]) as usize;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't it possible for data to contain some prefix of the body at this point?

Comment thread src/lib.rs
Comment on lines +1175 to +1176
/// `try_io()` can return `Ok(())` with the handshake still in flight:
/// `complete_io` gives up early if the `BIO` blocks part-way through

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: initial documentation comment line?

Comment thread src/lib.rs
/// (it never reaches the error stack) and leaves `SSL_get_error` to
/// report `SSL_ERROR_WANT_READ`/`_WRITE` from the `BIO`'s retry flags,
/// which record whichever direction actually blocked.
fn check_handshake_complete(&self) -> Result<(), error::Error> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: is there a good reason for the error:: qualification here?

Comment thread src/lib.rs
if let Err(e) = acceptor.read_tls(bio) {
return Err(error::Error::from_io(e));
};
// Keep reading until we have the whole `ClientHello`. Stopping

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-existing, but maybe yield this out of the match to reduce drift a little?

Comment thread src/bio.rs
}

/// Account for `data`, which was just read from the transport.
fn consume(&mut self, data: &[u8]) {

@cpu cpu Jul 31, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it might be helpful here to add a debug_assert!(data.len() <= self.allowance()); to maintain the cross-function invariant where consume() is only fed at most allowance() bytes. WDYT?

Comment thread src/bio.rs
let mut offset = 0;
let mut reads = 0;
while offset < record.len() {
let take = limit.allowance().min(chunk).min(record.len() - offset);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this clamping is maybe forgiving an over-allowance that we want to test?

If I mutate allowance() to change the 0 arm to return just Self::HEADER_LEN without subtracting self.header_used, the tests still pass.

Maybe better as:

  fn drive(limit: &mut RecordLimit, record: &[u8], chunk: usize) -> usize {
      let mut offset = 0;
      let mut reads = 0;
      while offset < record.len() {
          // the limiter must ask for exactly the rest of the header,
          // then exactly the rest of the body
          let expected = match offset < RecordLimit::HEADER_LEN {
              true => RecordLimit::HEADER_LEN - offset,
              false => record.len() - offset,
          };
          assert_eq!(limit.allowance(), expected);

          let take = expected.min(chunk);
          limit.consume(&record[offset..offset + take]);
          offset += take;
          reads += 1;
      }
      reads
  }

With that form in place the tests fail w/ my mutant version:

thread 'bio::tests::handles_split_header' (268500) panicked at src/bio.rs:479:13:
assertion `left == right` failed
  left: 5
 right: 4

Comment thread src/bio.rs
match rc {
1 => Ok(read_bytes),
1 => {
self.record_limit.consume(&buf[..read_bytes]);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe worth a bit of defense in depth here against a buggy BIO_read_ex that stores more than the dlen it updates? Something like:

let read_bytes = read_bytes.min(buf.len());
self.record_limit.consume(&buf[..read_bytes]);
Ok(read_bytes)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants