Skip to content

feat: Implement graceful shutdown#91

Merged
algesten merged 2 commits intoalgesten:mainfrom
HMBSbige:feature/close-notify
Apr 22, 2026
Merged

feat: Implement graceful shutdown#91
algesten merged 2 commits intoalgesten:mainfrom
HMBSbige:feature/close-notify

Conversation

@HMBSbige
Copy link
Copy Markdown
Contributor

@HMBSbige HMBSbige commented Mar 10, 2026

Closes #72

Code cleanup (commit 1)

  • Remove unnecessary &mut on OsRng in rust_crypto kx_group
  • Replace if let Err(_) with idiomatic .is_err() in auto cross_matrix tests
  • Inline self.last_now and remove extra blank lines in Server::poll_output (dtls12)
  • Move helper functions before #[cfg(test)] per file ordering convention (dtls12 server)

close_notify implementation (commit 2)

  • Add Dtls::close() API for graceful connection shutdown via close_notify alert
  • Add Output::CloseNotify variant to signal peer-initiated closure to the application
  • Add Error::ConnectionClosed for send attempts on closed connections
  • DTLS 1.2: receiver auto-replies with reciprocal close_notify per RFC 5246 §7.2.1
  • DTLS 1.3: sender enters HalfClosedLocal (write-closed, read-open) per RFC 8446 §6.1; receiver filters records by sequence number per RFC 9147 §5.10
  • Engine handles record-layer concerns (detecting alerts, filtering records); client/server handles connection state and Output::CloseNotify emission
  • Extract test helpers (complete_dtls12_handshake, setup_connected_12_pair, complete_dtls13_handshake, setup_connected_13_pair) from edge.rs into common.rs and consolidate imports
  • Update README with Output::CloseNotify documentation and example

@HMBSbige HMBSbige marked this pull request as draft March 10, 2026 10:16
@HMBSbige HMBSbige force-pushed the feature/close-notify branch from 7d7f3e8 to 77bf722 Compare March 10, 2026 10:45
@HMBSbige HMBSbige marked this pull request as ready for review March 10, 2026 10:45
Copy link
Copy Markdown
Owner

@algesten algesten left a comment

Choose a reason for hiding this comment

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

Thanks for looking at this!

A couple of comments and questions. I also see we have some unrelated code cleanup, like moving code around and removing white space, and a change to &mut OsRng. We can keep that in PR, but you need to force push and make "clean" commits I can rebase at the end.

Comment thread src/dtls12/client.rs Outdated
Comment thread src/dtls12/engine.rs Outdated
Comment thread src/dtls12/server.rs
Comment thread src/dtls13/engine.rs Outdated
@HMBSbige HMBSbige force-pushed the feature/close-notify branch 2 times, most recently from 9820a7a to ebb9d22 Compare March 11, 2026 03:09
Copy link
Copy Markdown
Owner

@algesten algesten left a comment

Choose a reason for hiding this comment

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

Thanks!

Much improved with the client/server having the state logic.

This read through I got stuck on insert_incoming and the amount of logic being done there. I see dtls13 has set some pattern that I don't quite feel is right.

Comment thread src/dtls12/engine.rs Outdated
Comment thread src/dtls12/incoming.rs Outdated
Comment thread src/dtls13/engine.rs Outdated
@HMBSbige HMBSbige force-pushed the feature/close-notify branch 2 times, most recently from 82fbb62 to 563ed6a Compare March 13, 2026 02:08
Copy link
Copy Markdown
Contributor Author

@HMBSbige HMBSbige left a comment

Choose a reason for hiding this comment

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

I've extracted the per-record processing out of insert_incoming into dedicated methods:

  • dtls12: extract_alerts() — epoch-aware alert handling + post-close_notify filtering
  • dtls13: extract_control_records() — ACK/Alert/CCS + close_notify sequence filtering

insert_incoming is back to capacity-check + dispatch in both versions.

The into_records/from_records decompose-recompose pattern is still there though. I considered moving it into incoming.rs alongside RecordDecrypt, but alert processing needs engine state (peer_encryption_enabled, buffers_free, close_notify_received/close_notify_sequence) which doesn't fit the stateless parsing layer.

If you have a different direction in mind for eliminating the pattern, I'm happy to follow your lead.

@algesten
Copy link
Copy Markdown
Owner

Thanks for extracting extract_alerts / extract_control_records — that's already an improvement. For the remaining into_records/from_records pattern, I think the right direction is to push control-record dispatch one level down, into the parse loop.

Records::parse already walks records one-at-a-time through the RecordDecrypt trait — that's the natural place to classify a record as "queue it" vs "handle now and drop". Alert/CCS/ACK records never enter Incoming in the first place, and insert_incoming goes back to capacity-check + dispatch.

Roughly:

// Extend (or rename) RecordDecrypt. The engine already implements it, so
// engine state (peer_encryption_enabled, buffers_free, close_notify_received, …)
// stays where it belongs — no split.
pub trait RecordHandler: RecordDecrypt /* or fold the methods in */ {
    /// Called after a record is parsed and (if applicable) decrypted,
    /// before it is added to Records. Return Ok(false) to drop the
    /// record; its buffer goes back to the pool via Record::into_buffer.
    fn classify(&mut self, record: &Record) -> Result<bool, Error>;
}

In Records::parse:

let record = match Record::parse(slice, decrypt, cs)? {
    Some(r) => r,
    None => continue, // already-handled cases: replay, bad header, …
};

if !decrypt.classify(&record)? {
    // Control record (Alert / CCS / ACK) — engine consumed it.
    continue;
}

records.try_push(record).map_err(|_| Error::TooManyRecords)?;

Engine impl then houses what extract_alerts currently does, but one record at a time — no ArrayVec rebuild, no from_records:

impl RecordHandler for Engine {
    fn classify(&mut self, record: &Record) -> Result<bool, Error> {
        match record.record().content_type {
            ContentType::Alert => { /* epoch-aware alert handling, return Ok(false) */ }
            // dtls13 also: Ack => process_ack; ChangeCipherSpec => discard
            ContentType::ApplicationData if self.close_notify_received => Ok(false),
            _ => Ok(true),
        }
    }
}

Net effect:

  • Incoming::from_records and the ArrayVec<Record, 8> scratch go away; Incoming is invariant once built.
  • insert_incoming shrinks back to capacity-check + handshake/non-handshake dispatch.
  • Alert handling lives next to the decryption pass it's logically paired with.
  • Symmetric between dtls12 and dtls13.

One real tradeoff: RecordDecrypt stops being "just crypto". I'd either rename it to RecordHandler (my preference — one trait, one call site) or add a sibling trait. Whichever you find cleaner.

- Remove unnecessary `&mut` on `OsRng` (rust_crypto kx_group)
- Replace `if let Err(_)` with idiomatic `.is_err()` (auto cross_matrix)
- Inline `self.last_now` and remove extra blank lines in Server::poll_output
- Move helper functions before `#[cfg(test)]` per file ordering convention
@HMBSbige HMBSbige force-pushed the feature/close-notify branch from 563ed6a to 10cbdea Compare April 22, 2026 14:19
@HMBSbige HMBSbige marked this pull request as draft April 22, 2026 14:22
@HMBSbige HMBSbige force-pushed the feature/close-notify branch 4 times, most recently from 9e44576 to 62628c3 Compare April 22, 2026 17:25
Comment thread tests/dtls13/edge.rs Fixed
Comment thread tests/dtls13/edge.rs Fixed
Comment thread tests/dtls13/edge.rs Fixed
Add close_notify support for both DTLS 1.2 and DTLS 1.3, implementing
graceful connection shutdown per RFC 5246 §7.2.1 and RFC 9147 §5.10.

DTLS 1.2: close() sends close_notify and transitions to Closed state.
Receiving close_notify triggers a reciprocal alert and discards pending
writes. No half-close support (full close only).

DTLS 1.3: close() sends close_notify and enters HalfClosedLocal state
where the read half remains open. Receiving close_notify while
half-closed transitions to Closed. Incoming KeyUpdate messages are
still processed (recv keys updated) but no outgoing records are sent.

Engine tracks close_notify at the record layer (filtering app data
after the alert sequence), while client/server handle connection state
and Output::CloseNotify emission.

Error::ConnectionClosed replaces SecurityError("connection closed") for
send_application_data on closed connections.
@HMBSbige HMBSbige force-pushed the feature/close-notify branch from 62628c3 to 8ff6ef4 Compare April 22, 2026 17:47
@HMBSbige HMBSbige marked this pull request as ready for review April 22, 2026 17:52
@HMBSbige
Copy link
Copy Markdown
Contributor Author

Records::parse now invokes RecordHandler::classify_record on each parsed record and consumes control records before they reach Incoming — Alert / ACK / CCS / post-close_notify in dtls13, Alert / post-close_notify in dtls12. insert_incoming is back to capacity + dispatch, and from_records is gone.

The signature is Record -> Option<Record> rather than &Record -> bool: the handler takes ownership and calls record.into_buffer() itself, so buffer-return stays on one side instead of being split between parser and handler. Option<Record> also leaves room for record substitution if that's ever needed.

Copy link
Copy Markdown
Owner

@algesten algesten left a comment

Choose a reason for hiding this comment

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

Thanks for taking it all the way!

@algesten algesten merged commit aa60bf2 into algesten:main Apr 22, 2026
46 checks passed
@HMBSbige HMBSbige deleted the feature/close-notify branch April 22, 2026 18:24
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.

feat: Add close_notify graceful shutdown API

3 participants