medvednikov

/

vxx2Public
0 commits0 issues4 pull requests0 contributorsDiscussionsProjectsCI

net.http: HTTP/2 server stream state machine (RFC 9113 §5.1/§6.1/§6.4) #4

Openquaesitor-scientiamwants to mergepr/27589intomaster· last Jun 28
3 files changed+512-39
vlib/net/http/h2_server.v+128-21

@@ -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) ! {

vlib/net/http/h2_server_test.v+372-0

@@ -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+}

vlib/net/http/h2spec/h2spec_expected_failures.txt+12-18

@@ -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