Skip to main content

zerodds_http2/
stream.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Stream-State-Machine — RFC 9113 §5.1.
5
6use crate::error::Http2Error;
7
8/// Stream-Identifier.
9pub type StreamId = u32;
10
11/// Stream-State (RFC 9113 §5.1).
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum StreamState {
14    /// `idle`.
15    Idle,
16    /// `reserved (local)`.
17    ReservedLocal,
18    /// `reserved (remote)`.
19    ReservedRemote,
20    /// `open`.
21    Open,
22    /// `half-closed (local)`.
23    HalfClosedLocal,
24    /// `half-closed (remote)`.
25    HalfClosedRemote,
26    /// `closed`.
27    Closed,
28}
29
30/// Eingehender Event aus Sicht der State-Machine.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum StreamEvent {
33    /// Empfangen `HEADERS` ohne `END_STREAM`.
34    RecvHeaders,
35    /// Empfangen `HEADERS`/`DATA` mit `END_STREAM`.
36    RecvEndStream,
37    /// Versendet `HEADERS` ohne `END_STREAM`.
38    SendHeaders,
39    /// Versendet `END_STREAM`.
40    SendEndStream,
41    /// Empfangen `PUSH_PROMISE`.
42    RecvPushPromise,
43    /// Versendet `PUSH_PROMISE`.
44    SendPushPromise,
45    /// `RST_STREAM` — terminiert sofort.
46    Reset,
47}
48
49/// Wendet einen Event auf den State an. Spec §5.1.
50///
51/// # Errors
52/// `InvalidState` wenn der Event im aktuellen State nicht erlaubt ist.
53pub fn transition(state: StreamState, event: StreamEvent) -> Result<StreamState, Http2Error> {
54    use StreamEvent as E;
55    use StreamState as S;
56    let next = match (state, event) {
57        (_, E::Reset) => S::Closed,
58        (S::Idle, E::RecvHeaders) => S::Open,
59        (S::Idle, E::SendHeaders) => S::Open,
60        (S::Idle, E::RecvPushPromise) => S::ReservedRemote,
61        (S::Idle, E::SendPushPromise) => S::ReservedLocal,
62        (S::ReservedLocal, E::SendHeaders) => S::HalfClosedRemote,
63        (S::ReservedLocal, E::SendEndStream) => S::Closed,
64        (S::ReservedRemote, E::RecvHeaders) => S::HalfClosedLocal,
65        (S::ReservedRemote, E::RecvEndStream) => S::Closed,
66        (S::Open, E::SendEndStream) => S::HalfClosedLocal,
67        (S::Open, E::RecvEndStream) => S::HalfClosedRemote,
68        (S::Open, E::RecvHeaders | E::SendHeaders) => S::Open,
69        (S::HalfClosedLocal, E::RecvEndStream) => S::Closed,
70        (S::HalfClosedLocal, E::RecvHeaders) => S::HalfClosedLocal,
71        (S::HalfClosedRemote, E::SendEndStream) => S::Closed,
72        (S::HalfClosedRemote, E::SendHeaders) => S::HalfClosedRemote,
73        (S::Closed, _) => return Err(Http2Error::InvalidState),
74        _ => return Err(Http2Error::InvalidState),
75    };
76    Ok(next)
77}
78
79/// Spec §5.1.1: Client-initiierte Streams sind ungerade.
80#[must_use]
81pub fn is_client_initiated(stream_id: StreamId) -> bool {
82    stream_id != 0 && stream_id % 2 == 1
83}
84
85/// Spec §5.1.1: Server-initiierte Streams sind gerade.
86#[must_use]
87pub fn is_server_initiated(stream_id: StreamId) -> bool {
88    stream_id != 0 && stream_id % 2 == 0
89}
90
91#[cfg(test)]
92#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn idle_to_open_via_headers() {
98        assert_eq!(
99            transition(StreamState::Idle, StreamEvent::RecvHeaders).unwrap(),
100            StreamState::Open
101        );
102    }
103
104    #[test]
105    fn open_send_end_stream_half_closes_local() {
106        assert_eq!(
107            transition(StreamState::Open, StreamEvent::SendEndStream).unwrap(),
108            StreamState::HalfClosedLocal
109        );
110    }
111
112    #[test]
113    fn half_closed_local_recv_end_stream_closes() {
114        assert_eq!(
115            transition(StreamState::HalfClosedLocal, StreamEvent::RecvEndStream).unwrap(),
116            StreamState::Closed
117        );
118    }
119
120    #[test]
121    fn reset_from_any_state_closes() {
122        for s in [
123            StreamState::Idle,
124            StreamState::Open,
125            StreamState::HalfClosedLocal,
126            StreamState::HalfClosedRemote,
127            StreamState::ReservedLocal,
128            StreamState::ReservedRemote,
129        ] {
130            assert_eq!(
131                transition(s, StreamEvent::Reset).unwrap(),
132                StreamState::Closed
133            );
134        }
135    }
136
137    #[test]
138    fn reserved_local_send_headers_half_closes_remote() {
139        assert_eq!(
140            transition(StreamState::ReservedLocal, StreamEvent::SendHeaders).unwrap(),
141            StreamState::HalfClosedRemote
142        );
143    }
144
145    #[test]
146    fn closed_state_rejects_non_reset_events() {
147        assert!(transition(StreamState::Closed, StreamEvent::SendHeaders).is_err());
148    }
149
150    #[test]
151    fn idle_send_end_stream_invalid() {
152        assert!(transition(StreamState::Idle, StreamEvent::SendEndStream).is_err());
153    }
154
155    #[test]
156    fn client_streams_are_odd() {
157        assert!(is_client_initiated(1));
158        assert!(is_client_initiated(3));
159        assert!(!is_client_initiated(0));
160        assert!(!is_client_initiated(2));
161    }
162
163    #[test]
164    fn server_streams_are_even() {
165        assert!(is_server_initiated(2));
166        assert!(is_server_initiated(4));
167        assert!(!is_server_initiated(0));
168        assert!(!is_server_initiated(1));
169    }
170
171    #[test]
172    fn open_recv_headers_stays_open_for_trailers() {
173        assert_eq!(
174            transition(StreamState::Open, StreamEvent::RecvHeaders).unwrap(),
175            StreamState::Open
176        );
177    }
178}