@@ -163,6 +163,8 @@ mut:
163163 send_window i64
164164 trailer_block []u8 // assembled trailer HEADERS + CONTINUATION fragments
165165 in_trailers bool // set once a trailer section (a 2nd HEADERS block) begins
166+ refused bool // §5.1.2: over the concurrency limit — decode then RST(REFUSED_STREAM)
167+ over_end bool // §5.1: HEADERS after END_STREAM — decoded for HPACK sync, then RST(STREAM_CLOSED)
166168}
167169
168170// H2ServerConn drives one server-side HTTP/2 connection over a transport.
@@ -182,6 +184,37 @@ mut:
182184 idle_handle int
183185}
184186
187+// H2StreamState is the server's view of a client-initiated stream for the
188+// RFC 9113 §5.1 frame-acceptance rules. An open or half-closed stream is kept
189+// in c.streams until its response is sent, so:
190+// - active: present in c.streams (open / half-closed local)
191+// - idle: never opened — an odd id above any we have accepted, OR any even
192+// (server-initiated) id, since this server never opens push streams
193+// - closed: a client (odd) id at or below the highest we have accepted that is
194+// no longer in the map (already finished or reset)
195+enum H2StreamState {
196+ active
197+ idle
198+ closed
199+}
200+
201+// classify_stream maps a stream id to its §5.1 state from the server's side.
202+// `last_stream_id` tracks only client-initiated (odd) ids, so "closed" applies
203+// ONLY to an odd id <= last_stream_id; an even id was never opened by this
204+// server (it initiates no streams) and is therefore idle, not closed — a frame
205+// on it is a connection PROTOCOL_ERROR, not STREAM_CLOSED. id 0 (the connection
206+// control stream) is never a request stream; callers that can receive it
207+// (WINDOW_UPDATE) handle id 0 before calling this.
208+fn (c &H2ServerConn) classify_stream(stream_id u32) H2StreamState {
209+ if stream_id in c.streams {
210+ return .active
211+ }
212+ if stream_id & 1 == 1 && stream_id <= c.last_stream_id {
213+ return .closed
214+ }
215+ return .idle
216+}
217+
185218// serve_h2_conn drives a single HTTP/2 server-side connection until the
186219// transport closes or a protocol error forces a GOAWAY. `handler` is invoked
187220// once per fully-received request stream.
@@ -268,6 +301,12 @@ fn (mut c H2ServerConn) dispatch_frame(frame H2Frame, mut handler Handler) ! {
268301 if frame.stream_id != c.awaiting_cont {
269302 return error('h2 server: CONTINUATION on the wrong stream')
270303 }
304+ } else if frame is H2RstStreamFrame && frame.stream_id == c.awaiting_cont {
305+ // RFC 9113 §6.4: RST_STREAM is legal at any point including mid-CONTINUATION.
306+ // Cancel the stream and clear the CONTINUATION wait without a connection error.
307+ c.awaiting_cont = 0
308+ c.streams.delete(frame.stream_id)
309+ return
271310 } else {
272311 return error('h2 server: expected CONTINUATION after HEADERS without END_HEADERS')
273312 }
@@ -280,6 +319,12 @@ fn (mut c H2ServerConn) dispatch_frame(frame H2Frame, mut handler Handler) ! {
280319 c.closing = true
281320 }
282321 H2RstStreamFrame {
322+ // RFC 9113 §5.1/§6.4: RST_STREAM on the connection stream (id 0) or an
323+ // idle stream is a connection error PROTOCOL_ERROR. On an open stream it
324+ // cancels it; on an already-closed stream it is ignored.
325+ if frame.stream_id == 0 || c.classify_stream(frame.stream_id) == .idle {
326+ return error('h2 server: RST_STREAM on idle stream ${frame.stream_id}')
327+ }
283328 c.streams.delete(frame.stream_id)
284329 }
285330 H2PriorityFrame {
@@ -332,6 +377,10 @@ fn (mut c H2ServerConn) handle_control_frame(frame H2Frame) ! {
332377 c.send_window += i64(frame.window_size_increment)
333378 } else if mut s := c.streams[frame.stream_id] {
334379 s.send_window += i64(frame.window_size_increment)
380+ } else if c.classify_stream(frame.stream_id) == .idle {
381+ // RFC 9113 §5.1: a WINDOW_UPDATE on an idle stream is a connection
382+ // error PROTOCOL_ERROR. On a closed stream it is ignored.
383+ return error('h2 server: WINDOW_UPDATE on idle stream ${frame.stream_id}')
335384 }
336385 }
337386 else {}
@@ -397,6 +446,15 @@ fn (mut c H2ServerConn) on_headers(frame H2HeadersFrame, mut handler Handler) !
397446 // A HEADERS block for a stream that is already open is a trailer section
398447 // (RFC 9113 §8.1), not a new request.
399448 if mut existing := c.streams[frame.stream_id] {
449+ if existing.end_stream {
450+ // Half-closed (remote): the stream already delivered END_STREAM.
451+ // The further HEADERS block is invalid (RFC 9113 §5.1, STREAM_CLOSED),
452+ // but we must still route it through on_trailers → finalize_trailers so
453+ // the HPACK block is decoded: the decoder is stateful and connection-wide
454+ // (RFC 7541 §2.2) — skipping a block desyncs all future requests.
455+ // finalize_trailers RSTs on over_end after the decode.
456+ existing.over_end = true
457+ }
400458 c.on_trailers(mut existing, frame, mut handler)!
401459 return
402460 }
@@ -412,6 +470,14 @@ fn (mut c H2ServerConn) on_headers(frame H2HeadersFrame, mut handler Handler) !
412470 end_stream: frame.end_stream
413471 send_window: i64(c.peer.initial_window_size)
414472 }
473+ // RFC 9113 §5.1.2: a stream that would exceed the concurrency limit we
474+ // advertised is refused. We still assemble and HPACK-decode its header block
475+ // (the decoder is stateful — skipping it would desync the dynamic table) but
476+ // answer with RST_STREAM(REFUSED_STREAM) instead of serving it; see
477+ // finalize_headers. The just-created stream is not yet counted in c.streams.
478+ if u32(c.streams.len) >= h2_server_max_concurrent_streams {
479+ s.refused = true
480+ }
415481 c.streams[frame.stream_id] = s
416482 if !frame.end_headers {
417483 c.awaiting_cont = frame.stream_id
@@ -457,6 +523,13 @@ fn (mut c H2ServerConn) finalize_headers(mut s H2ServerStream, mut handler Handl
457523 return error('h2 server: HPACK decode error (COMPRESSION_ERROR)')
458524 }
459525 s.headers_done = true
526+ // §5.1.2: an over-limit stream was decoded only to keep HPACK in sync; refuse
527+ // it now without validating or running the request.
528+ if s.refused {
529+ c.send_rst_stream(s.id, .refused_stream)!
530+ c.streams.delete(s.id)
531+ return
532+ }
460533 // A well-decoded but malformed request is a STREAM error (RFC 9113 §8.1.1) —
461534 // reset just this stream and keep the connection alive.
462535 h2_validate_request_pseudo(s.headers) or {
@@ -494,6 +567,13 @@ fn (mut c H2ServerConn) finalize_trailers(mut s H2ServerStream, mut handler Hand
494567 c.send_goaway(.compression_error, 'HPACK decode error in trailers') or {}
495568 return error('h2 server: HPACK decode error in trailers (COMPRESSION_ERROR)')
496569 }
570+ // A HEADERS block received after END_STREAM (RFC 9113 §5.1): decoded above for
571+ // HPACK sync; now RST the stream with STREAM_CLOSED and stop.
572+ if s.over_end {
573+ c.send_rst_stream(s.id, .stream_closed)!
574+ c.streams.delete(s.id)
575+ return
576+ }
497577 // A well-decoded but malformed trailer section is a STREAM error (§8.1.1).
498578 mut reason := if !s.end_stream {
499579 // A trailer section MUST terminate the stream (RFC 9113 §8.1).
@@ -524,17 +604,36 @@ fn (mut c H2ServerConn) finalize_trailers(mut s H2ServerStream, mut handler Hand
524604
525605fn (mut c H2ServerConn) on_data(frame H2DataFrame, mut handler Handler) ! {
526606 mut s := c.streams[frame.stream_id] or {
527- // DATA for an unknown stream (likely already RST'd); just drop and
528- // keep flow control consistent. Credit flow_size (full wire bytes
529- // including padding) per RFC 7540 §6.9.1.
607+ // RFC 9113 §5.1/§6.1: DATA is only valid on an open or half-closed (local)
608+ // stream. On an idle stream (never opened) it is a connection error
609+ // PROTOCOL_ERROR; on a closed stream (already finished or reset) it is a
610+ // STREAM_CLOSED stream error.
611+ if c.classify_stream(frame.stream_id) == .idle {
612+ return error('h2 server: DATA on idle stream ${frame.stream_id}')
613+ }
614+ // Closed stream: credit the connection window (the peer spent it on bytes
615+ // sent legitimately within its window) before RST-ing just this stream.
530616 if frame.flow_size > 0 {
531- c.send_window_update(0, u32(frame.flow_size))!
617+ c.send_window_update(0, u32(frame.flow_size)) or {}
532618 }
619+ c.send_rst_stream(frame.stream_id, .stream_closed)!
533620 return
534621 }
535622 if !s.headers_done {
536623 return error('h2 server: DATA before END_HEADERS')
537624 }
625+ if s.end_stream {
626+ // Half-closed (remote): the stream already delivered END_STREAM (its
627+ // response may still be in flight while we are blocked on flow control).
628+ // Further DATA is a STREAM_CLOSED stream error (RFC 9113 §5.1). Credit the
629+ // connection window first, then reset just this stream.
630+ if frame.flow_size > 0 {
631+ c.send_window_update(0, u32(frame.flow_size)) or {}
632+ }
633+ c.send_rst_stream(s.id, .stream_closed)!
634+ c.streams.delete(s.id)
635+ return
636+ }
538637 if frame.data.len > 0 {
539638 if s.body.len + frame.data.len > h2_server_max_request_body {
540639 // Credit the connection window before resetting so the peer is
@@ -568,7 +667,7 @@ fn (mut c H2ServerConn) run_request(mut s H2ServerStream, mut handler Handler) !
568667 return
569668 }
570669 resp := handler.handle(req)
571- c.send_response(s.id, resp)!
670+ c.send_response(s.id, resp, mut handler)!
572671 c.streams.delete(s.id)
573672}
574673
@@ -636,7 +735,7 @@ fn (mut c H2ServerConn) build_request(s &H2ServerStream) !Request {
636735 return req
637736}
638737
639-fn (mut c H2ServerConn) send_response(stream_id u32, resp Response) ! {
738+fn (mut c H2ServerConn) send_response(stream_id u32, resp Response, mut handler Handler) ! {
640739 status := if resp.status_code == 0 { 200 } else { resp.status_code }
641740 mut fields := [H2HeaderField{':status', status.str()}]
642741 for key in resp.header.keys() {
@@ -654,7 +753,7 @@ fn (mut c H2ServerConn) send_response(stream_id u32, resp Response) ! {
654753 block := c.encoder.encode(fields)
655754 c.send_header_block(stream_id, block, !has_body)!
656755 if has_body {
657- c.send_body(stream_id, body)!
756+ c.send_body(stream_id, body, mut handler)!
658757 }
659758}
660759
@@ -690,7 +789,7 @@ fn (mut c H2ServerConn) send_header_block(stream_id u32, block []u8, end_stream
690789 }
691790}
692791
693-fn (mut c H2ServerConn) send_body(stream_id u32, body []u8) ! {
792+fn (mut c H2ServerConn) send_body(stream_id u32, body []u8, mut handler Handler) ! {
694793 max := int(c.peer.max_frame_size)
695794 mut off := 0
696795 for off < body.len {
@@ -698,7 +797,15 @@ fn (mut c H2ServerConn) send_body(stream_id u32, body []u8) ! {
698797 // (RFC 7540 Section 6.9). When either is exhausted, read frames until
699798 // the peer grows a window with WINDOW_UPDATE.
700799 for c.send_window <= 0 || c.stream_send_window(stream_id) <= 0 {
701- c.pump_for_window(stream_id)!
800+ c.pump_for_window(mut handler)!
801+ // pump_for_window dispatches inbound frames; if the peer reset this
802+ // stream (RST_STREAM, or an illegal frame on the now half-closed
803+ // stream), it is gone from the map. Stop writing its response, but do
804+ // NOT error — a single stream's reset is a stream-scoped event and must
805+ // not tear down the whole connection.
806+ if stream_id !in c.streams {
807+ return
808+ }
702809 }
703810 avail := if c.send_window < c.stream_send_window(stream_id) {
704811 c.send_window
@@ -735,19 +842,19 @@ fn (c &H2ServerConn) stream_send_window(stream_id u32) i64 {
735842 return 0
736843}
737844
738-// pump_for_window reads one frame while a response is blocked on flow control,
739-// servicing control frames (SETTINGS / PING / WINDOW_UPDATE) via
740-// handle_control_frame and aborting on RST_STREAM for the active stream.
741-fn (mut c H2ServerConn) pump_for_window(stream_id u32) ! {
845+// pump_for_window reads one frame while a response is blocked on flow control and
846+// routes it through the SAME dispatch path as the main loop. Delegating to
847+// dispatch_frame (rather than re-implementing a subset) means every rule applies
848+// here too: a HEADERS frame arriving before the unblocking WINDOW_UPDATE is
849+// HPACK-decoded and — being over the concurrency limit — answered with
850+// RST_STREAM(REFUSED_STREAM), instead of being silently dropped (which desynced
851+// the HPACK decoder and skipped the required reset). dispatch_frame does not
852+// re-enter run_request from here: a new stream is refused (never served), and the
853+// active stream is half-closed (remote) so its further DATA/HEADERS are
854+// STREAM_CLOSED — the caller (send_body) detects the resulting reset and stops.
855+fn (mut c H2ServerConn) pump_for_window(mut handler Handler) ! {
742856 frame := c.read_frame()!
743- c.handle_control_frame(frame)!
744- if frame is H2RstStreamFrame {
745- if frame.stream_id == stream_id {
746- return error('h2 server: stream reset by peer while writing response')
747- }
748- }
749- // With SETTINGS_MAX_CONCURRENT_STREAMS=1 no other stream frames are
750- // expected mid-response; ignore anything else defensively.
857+ c.dispatch_frame(frame, mut handler)!
751858}
752859
753860fn (mut c H2ServerConn) send_window_update(stream_id u32, inc u32) ! {
@@ -653,3 +653,375 @@ fn test_h2_server_accepts_post_with_trailers() {
653653 }
654654 assert status == 200, 'trailers POST should succeed, got status ${status}'
655655}
656+
657+// drive_until_goaway_or_close writes `out`, then reads frames until it sees a
658+// GOAWAY (returns its error code) or the connection closes (returns -1). A
659+// RST_STREAM on any stream is a wrong-scope answer for a connection error and
660+// returns -2.
661+fn drive_until_goaway_or_close(mut client_end PipeEnd, out []u8, label string) i64 {
662+ client_end.write(out) or {
663+ assert false, '${label}: client write failed: ${err}'
664+ return -3
665+ }
666+ mut fr := FrameReader{
667+ end: client_end
668+ }
669+ for _ in 0 .. 32 {
670+ f := fr.next() or { return -1 }
671+ match f {
672+ H2GoawayFrame {
673+ return i64(f.error_code)
674+ }
675+ H2RstStreamFrame {
676+ return -2
677+ }
678+ else {}
679+ }
680+ }
681+ assert false, '${label}: server sent neither GOAWAY nor closed'
682+ return -3
683+}
684+
685+// preface_and_settings returns the client preface followed by an empty SETTINGS
686+// frame — the start of every scripted session.
687+fn preface_and_settings() []u8 {
688+ mut out := []u8{}
689+ out << h2_client_preface.bytes()
690+ out << H2Frame(H2SettingsFrame{}).encode()
691+ return out
692+}
693+
694+// RFC 9113 §5.1: a DATA frame on an idle stream (one never opened) is a
695+// connection error of type PROTOCOL_ERROR.
696+fn test_h2_server_data_on_idle_stream_is_connection_error() {
697+ mut out := preface_and_settings()
698+ out << H2Frame(H2DataFrame{
699+ stream_id: 1
700+ data: 'x'.bytes()
701+ end_stream: true
702+ }).encode()
703+ mut client_end := spawn_h2_echo_server()
704+ code := drive_until_goaway_or_close(mut client_end, out, 'DATA on idle stream')
705+ assert code == i64(u32(H2ErrorCode.protocol_error)), 'DATA on idle: expected GOAWAY(PROTOCOL_ERROR), got ${code}'
706+}
707+
708+// RFC 9113 §5.1/§6.4: a RST_STREAM on an idle stream is a connection error of
709+// type PROTOCOL_ERROR.
710+fn test_h2_server_rst_on_idle_stream_is_connection_error() {
711+ mut out := preface_and_settings()
712+ out << H2Frame(H2RstStreamFrame{
713+ stream_id: 1
714+ error_code: u32(H2ErrorCode.cancel)
715+ }).encode()
716+ mut client_end := spawn_h2_echo_server()
717+ code := drive_until_goaway_or_close(mut client_end, out, 'RST on idle stream')
718+ assert code == i64(u32(H2ErrorCode.protocol_error)), 'RST on idle: expected GOAWAY(PROTOCOL_ERROR), got ${code}'
719+}
720+
721+// RFC 9113 §5.1: a WINDOW_UPDATE on an idle stream is a connection error of type
722+// PROTOCOL_ERROR.
723+fn test_h2_server_window_update_on_idle_stream_is_connection_error() {
724+ mut out := preface_and_settings()
725+ out << H2Frame(H2WindowUpdateFrame{
726+ stream_id: 1
727+ window_size_increment: 100
728+ }).encode()
729+ mut client_end := spawn_h2_echo_server()
730+ code := drive_until_goaway_or_close(mut client_end, out, 'WINDOW_UPDATE on idle stream')
731+ assert code == i64(u32(H2ErrorCode.protocol_error)), 'WINDOW_UPDATE on idle: expected GOAWAY(PROTOCOL_ERROR), got ${code}'
732+}
733+
734+// RFC 9113 §5.1/§6.1: a DATA frame on a closed stream (one already served and
735+// removed) is a STREAM error of type STREAM_CLOSED — the connection stays up.
736+fn test_h2_server_data_on_closed_stream_is_stream_closed() {
737+ mut enc := H2HpackEncoder{}
738+ block := enc.encode(valid_get_pseudo)
739+ mut out := preface_and_settings()
740+ // Open and immediately close stream 1 (HEADERS + END_STREAM -> served).
741+ out << H2Frame(H2HeadersFrame{
742+ stream_id: 1
743+ fragment: block
744+ end_headers: true
745+ end_stream: true
746+ }).encode()
747+ // Then DATA on the now-closed stream 1.
748+ out << H2Frame(H2DataFrame{
749+ stream_id: 1
750+ data: 'x'.bytes()
751+ end_stream: true
752+ }).encode()
753+ mut client_end := spawn_h2_echo_server()
754+ client_end.write(out) or {
755+ assert false, 'DATA on closed stream: client write failed: ${err}'
756+ return
757+ }
758+ mut fr := FrameReader{
759+ end: client_end
760+ }
761+ mut code := i64(-1)
762+ for _ in 0 .. 32 {
763+ f := fr.next() or { break }
764+ if f is H2RstStreamFrame {
765+ if f.stream_id == 1 {
766+ code = i64(f.error_code)
767+ break
768+ }
769+ }
770+ }
771+ assert code == i64(u32(H2ErrorCode.stream_closed)), 'DATA on closed: expected RST_STREAM(STREAM_CLOSED), got ${code}'
772+}
773+
774+// RFC 9113 §5.1.2: a HEADERS frame opening a stream beyond the advertised
775+// SETTINGS_MAX_CONCURRENT_STREAMS (1) is refused with RST_STREAM(REFUSED_STREAM).
776+// Both streams omit END_STREAM so the first stays parked (open) when the second
777+// arrives, exercising the concurrency check rather than serial processing.
778+fn test_h2_server_refuses_stream_over_concurrency_limit() {
779+ mut enc := H2HpackEncoder{}
780+ mut out := preface_and_settings()
781+ out << H2Frame(H2HeadersFrame{
782+ stream_id: 1
783+ fragment: enc.encode(valid_get_pseudo)
784+ end_headers: true
785+ end_stream: false
786+ }).encode()
787+ out << H2Frame(H2HeadersFrame{
788+ stream_id: 3
789+ fragment: enc.encode(valid_get_pseudo)
790+ end_headers: true
791+ end_stream: false
792+ }).encode()
793+ mut client_end := spawn_h2_echo_server()
794+ client_end.write(out) or {
795+ assert false, 'concurrency limit: client write failed: ${err}'
796+ return
797+ }
798+ mut fr := FrameReader{
799+ end: client_end
800+ }
801+ mut code := i64(-1)
802+ for _ in 0 .. 32 {
803+ f := fr.next() or { break }
804+ if f is H2RstStreamFrame {
805+ if f.stream_id == 3 {
806+ code = i64(f.error_code)
807+ break
808+ }
809+ }
810+ }
811+ assert code == i64(u32(H2ErrorCode.refused_stream)), 'over-limit stream: expected RST_STREAM(REFUSED_STREAM) on stream 3, got ${code}'
812+}
813+
814+// RFC 9113 §5.1: an even (server-initiated) stream id is never opened by this
815+// server — it is idle, NOT closed, even after the client has opened a higher odd
816+// stream. A DATA frame on it must be a connection error PROTOCOL_ERROR, not a
817+// STREAM_CLOSED stream error. Regression for the last_stream_id-parity miss
818+// (Codex, #27589 discussion_r3488271404).
819+fn test_h2_server_data_on_even_stream_is_connection_error() {
820+ mut enc := H2HpackEncoder{}
821+ mut out := preface_and_settings()
822+ // Client opens odd stream 3 (served, advancing last_stream_id to 3)...
823+ out << H2Frame(H2HeadersFrame{
824+ stream_id: 3
825+ fragment: enc.encode(valid_get_pseudo)
826+ end_headers: true
827+ end_stream: true
828+ }).encode()
829+ // ...then sends DATA on even stream 2, which is below last_stream_id but was
830+ // never opened (even ids are server-initiated). It is idle, so -> conn error.
831+ out << H2Frame(H2DataFrame{
832+ stream_id: 2
833+ data: 'x'.bytes()
834+ end_stream: true
835+ }).encode()
836+ mut client_end := spawn_h2_echo_server()
837+ code := drive_until_goaway_or_close(mut client_end, out, 'DATA on even idle stream')
838+ assert code == i64(u32(H2ErrorCode.protocol_error)), 'DATA on even idle stream: expected GOAWAY(PROTOCOL_ERROR), got ${code}'
839+}
840+
841+// RFC 9113 §5.1.2: an over-limit stream opened while the server is blocked writing
842+// a stalled response (flow control) must still be HPACK-decoded and refused — the
843+// stall path (send_body -> pump_for_window) routes frames through the same dispatch
844+// as the main loop. Regression for the divergent-reader miss (Codex, #27589
845+// discussion_r3488302384): set the initial window to 0 so stream 1's response body
846+// stalls, then send HEADERS for stream 3 before the WINDOW_UPDATE that unblocks it.
847+fn test_h2_server_refuses_over_limit_stream_during_window_stall() {
848+ mut enc := H2HpackEncoder{}
849+ mut out := []u8{}
850+ out << h2_client_preface.bytes()
851+ // Zero initial window -> stream 1's response body cannot be sent until a
852+ // WINDOW_UPDATE arrives, parking the server in pump_for_window.
853+ out << H2Frame(H2SettingsFrame{
854+ settings: [H2Setting{h2_settings_initial_window_size, 0}]
855+ }).encode()
856+ out << H2Frame(H2HeadersFrame{
857+ stream_id: 1
858+ fragment: enc.encode(valid_get_pseudo)
859+ end_headers: true
860+ end_stream: true
861+ }).encode()
862+ // Over-limit stream 3 arrives while stream 1's response is stalled.
863+ out << H2Frame(H2HeadersFrame{
864+ stream_id: 3
865+ fragment: enc.encode(valid_get_pseudo)
866+ end_headers: true
867+ end_stream: true
868+ }).encode()
869+ // Unblock stream 1 so its response completes after the refusal.
870+ out << H2Frame(H2WindowUpdateFrame{
871+ stream_id: 1
872+ window_size_increment: 1000
873+ }).encode()
874+
875+ mut client_end := spawn_h2_echo_server()
876+ client_end.write(out) or {
877+ assert false, 'window-stall refusal: client write failed: ${err}'
878+ return
879+ }
880+ mut fr := FrameReader{
881+ end: client_end
882+ }
883+ mut dec := H2HpackDecoder{}
884+ mut rst3 := i64(-1)
885+ mut s1_status := 0
886+ mut s1_ended := false
887+ for _ in 0 .. 64 {
888+ f := fr.next() or { break }
889+ match f {
890+ H2RstStreamFrame {
891+ if f.stream_id == 3 {
892+ rst3 = i64(f.error_code)
893+ }
894+ }
895+ H2HeadersFrame {
896+ if f.stream_id == 1 {
897+ for hf in dec.decode(f.fragment) or { []H2HeaderField{} } {
898+ if hf.name == ':status' {
899+ s1_status = hf.value.int()
900+ }
901+ }
902+ }
903+ }
904+ H2DataFrame {
905+ if f.stream_id == 1 && f.end_stream {
906+ s1_ended = true
907+ }
908+ }
909+ else {}
910+ }
911+
912+ if rst3 >= 0 && s1_ended {
913+ break
914+ }
915+ }
916+ assert rst3 == i64(u32(H2ErrorCode.refused_stream)), 'over-limit stream during stall: expected RST_STREAM(REFUSED_STREAM) on stream 3, got ${rst3}'
917+ assert s1_status == 200, 'stalled stream 1 should still complete with 200, got ${s1_status}'
918+ assert s1_ended, 'stalled stream 1 response should complete after WINDOW_UPDATE'
919+}
920+
921+// RFC 9113 §5.1 / RFC 7541 §2.2: a HEADERS block arriving on a half-closed
922+// (remote) stream must be HPACK-decoded before the server sends
923+// RST_STREAM(STREAM_CLOSED). The HPACK dynamic table is connection-wide; skipping
924+// the decode desyncs it, so a subsequent request that uses an indexed reference to
925+// an entry from the skipped block gets GOAWAY(COMPRESSION_ERROR) instead of a 200.
926+//
927+// Setup: zero initial window stalls stream 1's response body, keeping stream 1 in
928+// c.streams while we inject a post-END_STREAM HEADERS for it. That block carries a
929+// literal-with-incremental-indexing entry ("x-sync: 1" → dynamic index 62). Stream
930+// 3 then references that entry via 0xBE. If the decode was skipped, 0xBE is
931+// unresolvable and the connection closes with COMPRESSION_ERROR.
932+//
933+// HPACK bytes used (RFC 7541 static table indices):
934+// 0x82 = indexed(2) → :method: GET
935+// 0x84 = indexed(4) → :path: /
936+// 0x87 = indexed(7) → :scheme: https
937+// 0x01 0x09 ... → :authority: h.example (literal-without-indexing, name idx 1)
938+// 0x40 0x06 x-sync 0x01 1 → literal-incremental "x-sync: 1" → dynamic index 62
939+// 0xBE = indexed(62) → x-sync: 1 (fails with COMPRESSION_ERROR if table desynced)
940+fn test_h2_server_hpack_sync_after_post_end_stream_headers() {
941+ // All-manual HPACK: no H2HpackEncoder so the dynamic table stays predictable.
942+ // Static-table references only for the base pseudo-headers; no new entries.
943+ base_block := [u8(0x82), 0x84, 0x87, 0x01, 0x09, 0x68, 0x2e, 0x65, 0x78, 0x61, 0x6d, 0x70,
944+ 0x6c, 0x65]
945+ // Literal-with-incremental-indexing: adds "x-sync: 1" to the dynamic table.
946+ invalid_block := [u8(0x40), 0x06, 0x78, 0x2d, 0x73, 0x79, 0x6e, 0x63, 0x01, 0x31]
947+ // Same pseudo-headers plus indexed(62) = "x-sync: 1" from the dynamic table.
948+ mut s3_block := base_block.clone()
949+ s3_block << u8(0xBE)
950+
951+ mut out := []u8{}
952+ out << h2_client_preface.bytes()
953+ // Zero initial window → stream 1's response body cannot be sent; the server
954+ // parks in pump_for_window with stream 1 still in c.streams.
955+ out << H2Frame(H2SettingsFrame{
956+ settings: [H2Setting{h2_settings_initial_window_size, 0}]
957+ }).encode()
958+ out << H2Frame(H2HeadersFrame{
959+ stream_id: 1
960+ fragment: base_block
961+ end_headers: true
962+ end_stream: true
963+ }).encode()
964+ // Post-END_STREAM HEADERS on stream 1 containing the HPACK incremental entry.
965+ out << H2Frame(H2HeadersFrame{
966+ stream_id: 1
967+ fragment: invalid_block
968+ end_headers: true
969+ end_stream: false
970+ }).encode()
971+ // Stream 3 references the entry from the invalid block (dynamic index 62).
972+ out << H2Frame(H2HeadersFrame{
973+ stream_id: 3
974+ fragment: s3_block
975+ end_headers: true
976+ end_stream: true
977+ }).encode()
978+ // Unblock stream 3's stalled response body.
979+ out << H2Frame(H2WindowUpdateFrame{
980+ stream_id: 3
981+ window_size_increment: 1000
982+ }).encode()
983+
984+ mut client_end := spawn_h2_echo_server()
985+ client_end.write(out) or {
986+ assert false, 'HPACK sync: client write failed: ${err}'
987+ return
988+ }
989+ mut fr := FrameReader{
990+ end: client_end
991+ }
992+ mut dec := H2HpackDecoder{}
993+ mut rst1 := i64(-1)
994+ mut s3_status := 0
995+ mut goaway_code := i64(-1)
996+ for _ in 0 .. 64 {
997+ f := fr.next() or { break }
998+ match f {
999+ H2GoawayFrame {
1000+ goaway_code = i64(f.error_code)
1001+ break
1002+ }
1003+ H2RstStreamFrame {
1004+ if f.stream_id == 1 {
1005+ rst1 = i64(f.error_code)
1006+ }
1007+ }
1008+ H2HeadersFrame {
1009+ if f.stream_id == 3 {
1010+ for hf in dec.decode(f.fragment) or { []H2HeaderField{} } {
1011+ if hf.name == ':status' {
1012+ s3_status = hf.value.int()
1013+ }
1014+ }
1015+ }
1016+ }
1017+ else {}
1018+ }
1019+
1020+ if s3_status != 0 && rst1 >= 0 {
1021+ break
1022+ }
1023+ }
1024+ assert goaway_code == -1, 'HPACK sync: unexpected GOAWAY (code ${goaway_code}) — post-END_STREAM block was not decoded'
1025+ assert rst1 == i64(u32(H2ErrorCode.stream_closed)), 'HPACK sync: expected RST_STREAM(STREAM_CLOSED) on stream 1, got ${rst1}'
1026+ assert s3_status == 200, 'HPACK sync: stream 3 should get 200 (dynamic table intact), got ${s3_status}'
1027+}
@@ -10,26 +10,20 @@
1010# Generated with h2spec v2.6.0 (146 tests). The original baseline was 37 failures
1111# (109 pass). Request header/pseudo-header validation (RFC 9113 §8.1.2/§8.2.2/§8.3)
1212# plus HPACK decode-error scope (§4.3: a decode failure is a connection-level
13-# COMPRESSION_ERROR, not RST_STREAM) closed 21 of them — 129 pass, 16 fail —
14-# verified stable at --timeout 5. NOTE: a handful of h2spec cases (CONTINUATION
15-# sequencing, unknown error codes) are timing-flaky at h2spec's 2s default — they
16-# wait for the server's GOAWAY/close — so run_h2spec.sh uses --timeout 5
17-# (H2SPEC_TIMEOUT) to keep the gate stable. Do not add a flaky case to this list;
18-# raise the timeout instead. The remaining 16 are genuine server-side conformance
19-# gaps: the stream state machine (frames on idle/closed streams), concurrent-stream
20-# limit, PRIORITY self-dependency, SETTINGS_ENABLE_PUSH validation, and flow-control
21-# zero-increment/overflow handling. Remove a line when the server is fixed to pass.
22-http2/5.1 :: closed: Sends a DATA frame
23-http2/5.1 :: closed: Sends a DATA frame after sending RST_STREAM frame
24-http2/5.1 :: half closed (remote): Sends a DATA frame
25-http2/5.1 :: idle: Sends a DATA frame
26-http2/5.1 :: idle: Sends a RST_STREAM frame
27-http2/5.1 :: idle: Sends a WINDOW_UPDATE frame
28-http2/5.1.2 :: Sends HEADERS frames that causes their advertised concurrent stream limit to be exceeded
13+# COMPRESSION_ERROR, not RST_STREAM) closed 21; the §5.1/§6.1/§6.4 stream-state
14+# rules (frames on idle streams are connection errors, on closed streams are
15+# STREAM_CLOSED) closed 8 more; routing the flow-control stall path
16+# (pump_for_window) through the same dispatch closed §5.1.2 (an over-limit stream
17+# opened while a response is stalled is now refused) — 138 pass, 7 fail — verified
18+# stable at --timeout 5. NOTE: a handful of h2spec cases (CONTINUATION sequencing,
19+# unknown error codes) are timing-flaky at h2spec's 2s default — they wait for the
20+# server's GOAWAY/close — so run_h2spec.sh uses --timeout 5 (H2SPEC_TIMEOUT) to keep
21+# the gate stable. Do not add a flaky case to this list; raise the timeout instead.
22+# The remaining 7 are genuine server-side conformance gaps: PRIORITY self-dependency,
23+# SETTINGS_ENABLE_PUSH validation, and flow-control zero-increment/overflow handling.
24+# Remove a line when the server is fixed to pass.
2925http2/5.3.1 :: Sends HEADERS frame that depends on itself
3026http2/5.3.1 :: Sends PRIORITY frame that depend on itself
31-http2/6.1 :: Sends a DATA frame on the stream that is not in "open" or "half-closed (local)" state
32-http2/6.4 :: Sends a RST_STREAM frame on a idle stream
3327http2/6.5.2 :: SETTINGS_ENABLE_PUSH (0x2): Sends the value other than 0 or 1
3428http2/6.9 :: Sends a WINDOW_UPDATE frame with a flow control window increment of 0
3529http2/6.9 :: Sends a WINDOW_UPDATE frame with a flow control window increment of 0 on a stream