Skip to content

Fix HPACK dynamic table desync#2628

Open
const-t wants to merge 11 commits into
masterfrom
kt-fix-hpack-desync
Open

Fix HPACK dynamic table desync#2628
const-t wants to merge 11 commits into
masterfrom
kt-fix-hpack-desync

Conversation

@const-t
Copy link
Copy Markdown
Contributor

@const-t const-t commented Mar 11, 2026

No description provided.

Comment thread fw/http_frame.c
* stream during next sending if current sending of this stream
* has been postponed due to lack of tcp window.
*/
r = tfw_h2_stream_fsm_ignore_err(ctx, stream, HTTP2_HEADERS, 0);
Copy link
Copy Markdown
Contributor Author

@const-t const-t Mar 11, 2026

Choose a reason for hiding this comment

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

@krizhanovsky @EvgeniiMekhanik This is alternative solution that lets to choose another stream on the next scheduling cycle, because not encoding header if they can't be sent due to too small window (snd_wnd <= FRAME_HEADER_SIZE + TLS_MAX_OVERHEAD window too small for any stream not only for the current), but has worse latency because of postponing encoding. And may postpone already framed stream, that leads to smaller HEADERS frame than possible and forces to use CONTINUATION frame.

From my point of view, setting of ctx->cur_send_headers directly is not consistent, but maybe you guys have another vision.

diff --git a/fw/http_frame.c b/fw/http_frame.c
index 8e9b29a0c..968d1c360 100644
--- a/fw/http_frame.c
+++ b/fw/http_frame.c
@@ -2161,6 +2161,13 @@ do {									\
 	T_FSM_EXIT();							\
 } while(0)

+#define CALC_MAX_MIN_FRAME_LENGTH(len, out_max, out_min)		\
+do {									\
+	(out_max) = min(TLS_MAX_PAYLOAD_SIZE, *snd_wnd - TLS_MAX_OVERHEAD); \
+	(out_max) -= FRAME_HEADER_SIZE;					\
+	(out_min) = min(min_to_send, (unsigned int)(len));		\
+} while (0)
+
 #define CALC_FRAME_LENGTH_AND_SET_FRAME_TYPE(type, len)			\
 do {									\
 	unsigned int max_len;						\
@@ -2170,9 +2177,7 @@ do {									\
 		*stop = true;						\
 		T_FSM_EXIT();						\
 	}								\
-	max_len = min(TLS_MAX_PAYLOAD_SIZE, *snd_wnd - TLS_MAX_OVERHEAD); \
-	max_len -= FRAME_HEADER_SIZE;					\
-	min_len = min(min_to_send, (unsigned int)len);			\
+	CALC_MAX_MIN_FRAME_LENGTH(len, max_len, min_len);		\
 	frame_length = tfw_h2_calc_frame_length(ctx, stream, type, len,	\
 						max_len); 		\
 	/*								\
@@ -2190,9 +2195,16 @@ do {									\
 	T_FSM_JMP(state);						\
 } while(0)

+#define FRAME_XMIT_FSM_SET_NEXT_STATE(state)				\
+	__fsm_const_state = state
+
 	T_FSM_START(stream->xmit.state) {

 	T_FSM_STATE(HTTP2_ENCODE_HEADERS) {
+		if (*snd_wnd <= FRAME_HEADER_SIZE + TLS_MAX_OVERHEAD) {
+			*stop = true;
+			T_FSM_EXIT();
+		}
 		r = tfw_h2_stream_xmit_prepare_resp(stream);
 		fallthrough;
 	}
@@ -2210,8 +2222,14 @@ do {									\
 	}

 	T_FSM_STATE(HTTP2_MAKE_HEADERS_FRAMES) {
-		CALC_FRAME_LENGTH_AND_SET_FRAME_TYPE(HTTP2_HEADERS,
-						     stream->xmit.h_len);
+		unsigned int min_len, max_len;
+
+		CALC_MAX_MIN_FRAME_LENGTH(stream->xmit.h_len, max_len, min_len);
+		frame_length = tfw_h2_calc_frame_length(ctx, stream,
+							HTTP2_HEADERS,
+							stream->xmit.h_len,
+							max_len);
+
 		if (unlikely(ctx->hpack.enc_tbl.wnd_changed)) {
 			r = tfw_hpack_enc_tbl_write_sz(&ctx->hpack.enc_tbl,
 						       stream);
@@ -2229,6 +2247,12 @@ do {									\
 			return r;
 		}

+		if (frame_length < min_len) {
+			FRAME_XMIT_FSM_SET_NEXT_STATE(HTTP2_SEND_FRAMES);
+			*stop = true;
+			T_FSM_EXIT();
+		}
+
 		FRAME_XMIT_FSM_NEXT(frame_length, HTTP2_SEND_FRAMES);
 	}

@const-t const-t changed the title Fix HAPCK dynamic table desync Fix HPACK dynamic table desync Mar 11, 2026
@const-t const-t force-pushed the kt-fix-hpack-desync branch 4 times, most recently from f48b3f2 to c7e24c8 Compare March 17, 2026 14:54
@const-t
Copy link
Copy Markdown
Contributor Author

const-t commented Mar 17, 2026

@krizhanovsky @EvgeniiMekhanik the commit refactor: Move skb related functions to ss_skb.h is optional and can be dropped. It was introduced as part of the commit where I reworked trailers encoding, however we decided to not use HPACK dynamic table for trailers and I dropped that patch, but this commit with moving functions left in the PR.

@const-t const-t marked this pull request as ready for review March 17, 2026 15:18
@const-t const-t requested review from EvgeniiMekhanik and krizhanovsky and removed request for krizhanovsky March 18, 2026 14:34
@const-t const-t force-pushed the kt-fix-hpack-desync branch from c7e24c8 to 9cd2132 Compare March 30, 2026 12:47
Comment thread fw/http_stream.c
do { \
if ((ctx->cur_##op##_headers \
&& (type != HTTP2_CONTINUATION && type != HTTP2_RST_STREAM)) \
&& ((type == HTTP2_HEADERS && !is_send) || \
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It seems that it is wrong. A HEADERS frame without the END_HEADERS flag set MUST be followed by a CONTINUATION frame for the same stream. A receiver MUST treat the receipt of any other type of frame or a frame on a different stream as a connection error (Section 5.4.1) of type PROTOCOL_ERROR.

It seems that HTTP2_RST_STREAM is also forbidden. So it should be
if ((ctx->cur_##op##_headers and type != HTTP2_CONTINUATION)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think it's better to check tcp window and do not prepare response. Or ignore tcp windo limit for headers frame
.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added fix for this as well.

Copy link
Copy Markdown
Contributor

@EvgeniiMekhanik EvgeniiMekhanik left a comment

Choose a reason for hiding this comment

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

I think we should not prepare response if there is no enough TCP window or ignore tcp window during sending headers frame. Otherwise we need make incorrect change in stream state processing. @krizhanovsky what do you think?

@EvgeniiMekhanik
Copy link
Copy Markdown
Contributor

I also found two problems in
f (frame_length < min_len)
ADJUST_BLOCKED_STREAMS_AND_EXIT(min_len, type);
optimization:

  • we stop making frames if stream http2 window is exceeded. But if we have other streams to make frames we should do it.
  • we stop making frames if http2 connection window is exceeded. But if we have other streams with headers frames
    we should continue make frames for them.

@const-t const-t marked this pull request as draft April 9, 2026 12:46
@const-t const-t force-pushed the kt-fix-hpack-desync branch 3 times, most recently from 723a84e to 2912200 Compare May 1, 2026 10:43
@const-t const-t linked an issue May 1, 2026 that may be closed by this pull request
const-t added 9 commits May 1, 2026 15:16
Advertise the selected dynamic table size if the client's table size is
not equal to Tempesta FW dynamic table size.

Details: when Tempesta receives SETTINGS frame and
SETTINGS_HEADER_TABLE_SIZE in this frame is greater than default
Tempesta's 4096, Tempesta advertise the size that it will be used.
Before this patch Tempesta ignored it when Tempesta's table size is
equal to default 4096 and size advertised by the client is greater than
4096. In other cases Tempesta did send updates.

For reference see: RFC7541 Section 4.2
Tempesta cause dynamic table desynchronization between client and
Tempesta, the reason is wrong encoding and sending order. When response
is ready for sending to the client Tempesta do:
1. Select HTTP2 stream
2. Encode headers
3. Check TCP window
4. If window is enough - sends the headers
however in case when TCP window is too small to send the encoded headers
Tempesta postpone sending. It begins from point (1) when TCP window
update is received, and issue is here, stream scheduler *may select*
another stream to send thereby postponing the previous stream with
already encoded headers [1]. To fix the issue in this patch we call
`tfw_h2_stream_fsm_ignore_err()` with 0 flags, that leads to set
`ctx->cur_send_headers` and forces stream scheduler to select this
stream on the next scheduling cycle. It is little bit tricky, but seems
a good solution it terms of latency. Alternative way is not encode
headers if we lack of TCP window, however in this case we introduce
some latency postponing encoding, but also there is a pros, if priority
if the stream is changed and there is the stream with higher priority
the stream with higher priority will be selected by scheduler that looks
more accurate.

[1] By design in HTTP/2, the stream for which the headers are encoded
must be sent first. Otherwise, when encoding the next stream, new
headers may be added to the HPACK dynamic table, which could invalidate
the header indexes for the previous stream.
This patch fixes two issues with HPACK desync. The first one appears
when we encode trailer headers at the moment of encoding regular headers
For example: Tempesta encoded headers and trailers and proceed to
sending the body. During sending the body scheduler few times preemt
the current stream to send headers for another stream, therefore HPACK
dynamic table contains the new headers and indexes used for trailers are
invalid. To fix this we decided to not use HPACK dynamic table for
trailers, it is a good trade-off between complexity and efficiency.

The reason of the second is not sending new size of the dynamic table
in trailer HEADERS frame. When client advertised the new size of the
dynamic table Tempesta must respond with selected size in the first
HEADERS it doesn't matter whether they are trailers or regular headers.

Small refactoring to improve readability
By the standart during sending headers block any others frames are
forbidden except CONTINUATION frame
When frame is prepared and window is enough to make one more frame
we call `FRAME_XMIT_FSM_NEXT()` which decreases `snd_wnd` by
`frame_length` that is correct, however in the macro
`CALC_FRAME_LENGTH_AND_SET_FRAME_TYPE_OR_EXIT()` we check `snd_wnd`
for `min_to_send` regardless we have prepared frame or not, and if
the last prepared frame is smaller than `min_to_send` we don't send
this frame but send only the first prepared frame. This is looks
incorrect because if we have 2048 window and the first prepared
frame 1536 we don't send remaining 512 bytes for 1500 mtu.

I also encountered socket hangs during our tests when using a small receive
window.

Performance difference.

This patch:
finished in 50.05s, 1920200.18 req/s, 1.45GB/s
requests: 96010009 total, 96020009 started, 96010009 done,
          96010009 succeeded, 0 failed, 0 errored, 0 timeout
status codes: 96010009 2xx, 0 3xx, 0 4xx, 0 5xx
traffic: 72.69GB (78047130589) total, 16.09GB (17272778551)
         headers (space savings 23.09%), 54.99GB (59046155535) data
                     min         max         mean         sd        +/- sd
time for request:      354us     38.71ms      6.27ms      3.62ms    73.81%
time for connect:    12.65ms     56.41ms     29.05ms     11.35ms    61.00%
time to 1st byte:    16.81ms     75.64ms     36.31ms     13.33ms    63.00%
req/s           :    5685.92    36745.74    19201.81     7963.58    69.00%

Master:
finished in 50.04s, 1755808.42 req/s, 1.33GB/s
requests: 87790421 total, 87800421 started, 87790421 done,
          87790421 succeeded, 0 failed, 0 errored, 0 timeout
status codes: 87790421 2xx, 0 3xx, 0 4xx, 0 5xx
traffic: 66.39GB (71285828311) total, 14.64GB (15714485359)
                 headers (space savings 23.18%), 50.28GB (53991108915) data
                     min         max         mean         sd        +/- sd
time for request:      431us     38.01ms      7.03ms      4.17ms    73.04%
time for connect:    13.60ms     45.94ms     26.00ms      6.08ms    75.00%
time to 1st byte:    18.68ms     61.10ms     33.66ms      8.77ms    66.00%
req/s           :    6335.27    36376.15    17557.90     7980.11    68.00%
Before this patch Tempesta stopped to send headers block when
RST_STREAM frame was received or sent, that broke HPACK state. In
this patch we sending all encoded headers to maintain synchronized
HPACK dynamic table.
The case when Tempesta receives obviously broken HEADERS or PRIORITY
frame Tempesta treats it as suspicios and do disconnect instead of
handling this case with RST_STREAM. It looks that it doesn't makes
sense to continue service this connection. Also this removes the attack
vector for RST_STREAM flood.

RFC 9113 5.4.1. Connection Error Handling:
An endpoint can end a connection at any time. In particular, an endpoint
MAY choose to treat a stream error as a connection error.
const-t added 2 commits May 1, 2026 18:19
Do this to ensure that the function calculating frame size accounts for
the dynamic table size, not just the headers, and does not produce
CONTINUATION frame if the data fits within a single HEADERS frame.

The main reason to fix this:
It seems firefox has a bug because of that it can't proccess
CONTINUATION frame for closed stream and resets connection breaking
page loading.
The function must have only one responsibility insert frame headers,
however before this patch it also sent data. In this patch we
extract data sending logic into dedicated state to make code more
understandable and explicit
@const-t const-t force-pushed the kt-fix-hpack-desync branch from 2912200 to f9687bb Compare May 1, 2026 15:38
@const-t const-t marked this pull request as ready for review May 1, 2026 15:38
@const-t
Copy link
Copy Markdown
Contributor Author

const-t commented May 4, 2026

I think we should not prepare response if there is no enough TCP window or ignore tcp window during sending headers frame. Otherwise we need make incorrect change in stream state processing. @krizhanovsky what do you think?

I've updated PR. Added fixes for #2640. But I didn't implement I think we should not prepare response if there is no enough TCP window. From my point of view, if we reached framing function we can prepare headers even if tcp window is not enough, to have prepared headers on the next shot of calling sending function and don't call stream scheduler one more time. However I attached the version where we don't prepare response if there is no enough TCP window in the comment to see the difference.

Actually this patch allows to call tfw_h2_stream_fsm_ignore_err() multiple times for HEADERS frame in the state HTTP2_STREAM_REM_HALF_CLOSED only during sending that doesn't looks like issue, because we only assign ctx->cur_send_headers in this case and not change state of the stream, frames received from the client will not affect this change only sending side.

@const-t const-t requested a review from EvgeniiMekhanik May 4, 2026 16:23
Comment thread fw/hpack.c
*/
if (tbl->window != new_size && (likely(!tbl->wnd_changed)
|| unlikely(!tbl->window) || new_size < tbl->window))
if (tbl->window != requested_size && (likely(!tbl->wnd_changed)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I read RFC again and look for nghtt2 realization - in some cases we should apply and send two hpack dynamic changes!
For example
4096 - > 1 -> 100 we should send 1 and 100 (minimum and final)
4096 -> 1 -> 0 -> 2 -> 100 -> 3 we should send 0 and 3 (minimum and final)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, another one bug. Tempesta must be able to send min and max size. RFC cite:

Multiple updates to the maximum table size can occur between the
transmission of two header blocks. In the case that this size is
changed more than once in this interval, the smallest maximum table
size that occurs in that interval MUST be signaled in a dynamic table
size update. The final maximum size is always signaled, resulting in
at most two dynamic table size updates. This ensures that the
decoder is able to perform eviction based on reductions in dynamic
table size

However maybe it makes sense to implement it in other PR, to keep this PR small.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yes also look nghttp2_hd_deflate_hd_bufs in nghttp2

if (deflater->ctx.hd_table_bufsize_max > min_hd_table_bufsize_max) {

      rv = emit_table_size(bufs, min_hd_table_bufsize_max);

      if (rv != 0) {
        goto fail;
      }
    }

    rv = emit_table_size(bufs, deflater->ctx.hd_table_bufsize_max);

@EvgeniiMekhanik
Copy link
Copy Markdown
Contributor

EvgeniiMekhanik commented May 8, 2026

There are some questions about patch:

  1. If we can't send any other frames for other streams after sending HEADERS FRAME without END_STREAM flag for current stream may be it have sense to don't calculate snd_wnd during sending headers at all? In this case we don't need some commits from this patch. (UPDATE. I check h20 implementation they don't send all headers to socket immediately< also we rely on window when we account client mem, so we still should calculate snd_wnd during sending headers, but may be not do it so accuracy).
  2. We should check how another implementations works with RST STREAM receiving. It seems that now be make some changes not according RFC. It looks like we are trying to simply hide the current problem rather than solve it globally. (I mean that we continue to send headers frames after receiving RST STREAM and more over we don't process it now at all!)

Comment thread fw/http_frame.c
} else if (res == STREAM_FSM_RES_TERM_STREAM) { \
WARN_ON_ONCE(hdr->stream_id != ctx->cur_stream->id); \
return tfw_h2_current_stream_send_rst((ctx), err); \
return tfw_h2_send_rst_stream(ctx, ctx->cur_stream->id,\
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

May we can implement tfw_h2_on_tcp_entail_rst (like tfw_h2_on_tcp_entail_ack). to move stream to closed queue?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Currently it will be moved to closed queue during making data frames (because of error in tfw_h2_insert_frame_header->tfw_h2_stream_fsm_ignore_err but it is not clear (as for me)

Copy link
Copy Markdown
Contributor Author

@const-t const-t May 12, 2026

Choose a reason for hiding this comment

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

Currently it will be moved to closed queue during making data frames (because of error in tfw_h2_insert_frame_header->tfw_h2_stream_fsm_ignore_err but it is not clear (as for me)

This is correct for requests for that we will prepare response, however if response will not be prepared we will not add stream to closed and don't clean before connection closing. I will fix this.

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.

Corrupted content error

2 participants