Fix HPACK dynamic table desync#2628
Conversation
| * 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); |
There was a problem hiding this comment.
@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);
}
f48b3f2 to
c7e24c8
Compare
|
@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. |
c7e24c8 to
9cd2132
Compare
| do { \ | ||
| if ((ctx->cur_##op##_headers \ | ||
| && (type != HTTP2_CONTINUATION && type != HTTP2_RST_STREAM)) \ | ||
| && ((type == HTTP2_HEADERS && !is_send) || \ |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
I think it's better to check tcp window and do not prepare response. Or ignore tcp windo limit for headers frame
.
There was a problem hiding this comment.
Added fix for this as well.
There was a problem hiding this comment.
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 also found two problems in
|
723a84e to
2912200
Compare
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.
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
2912200 to
f9687bb
Compare
I've updated PR. Added fixes for #2640. But I didn't implement Actually this patch allows to call |
| */ | ||
| 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) |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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);
|
There are some questions about patch:
|
| } 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,\ |
There was a problem hiding this comment.
May we can implement tfw_h2_on_tcp_entail_rst (like tfw_h2_on_tcp_entail_ack). to move stream to closed queue?
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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.
No description provided.