Skip to content

Commit 2739f63

Browse files
committed
http2: finish empty trailers natively for compat streams
When the compat layer flushes response headers before the response is ended (writeHead(), write(), flushHeaders()), it must keep waitForTrailers so that trailers can still be added while streaming. As a result, every such response paid for a wantTrailers C++ -> JS callback, an empty sendTrailers() with its setImmediate(), and a trailers() call back into C++, even though most responses never register any trailers. Introduce STREAM_OPTION_AUTO_EMPTY_TRAILERS: when set and no trailers have been handed to the native side by the time the final DATA frame is sent, the stream is finished directly in C++ with the same empty DATA frame carrying END_STREAM that the JS path would have produced, without calling into JS at all. The compat layer enables this mode whenever it responds with waitForTrailers and no trailers registered yet; a later setTrailer() call flips the stream back to JS-managed trailers through a new disableAutoTrailers() binding, so streaming trailers keep working unchanged. The wire format is identical in all cases. h2load -c 4 -m 100, 1 KiB payload, mean of 8 alternating runs against the previous commit: compat writeHead()+end() 47.8k -> 50.2k req/s (+5.0%); multi-write streaming responses +1%. Signed-off-by: Matteo Collina <hello@matteocollina.com>
1 parent 47e8434 commit 2739f63

6 files changed

Lines changed: 203 additions & 27 deletions

File tree

lib/internal/http2/compat.js

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ const {
5252
validateObject,
5353
} = require('internal/validators');
5454
const {
55+
kAutoEmptyTrailers,
56+
kDisableAutoTrailers,
5557
kSocket,
5658
kRequest,
5759
kProxySocket,
@@ -592,7 +594,15 @@ class Http2ServerResponse extends Stream {
592594
name = name.trim().toLowerCase();
593595
assertValidHeader(name, value);
594596
this[kTrailers][name] = value;
595-
this[kState].hasTrailers = true;
597+
const state = this[kState];
598+
if (!state.hasTrailers) {
599+
state.hasTrailers = true;
600+
// If the response headers were already flushed with auto-empty
601+
// trailers enabled, tell the stream to hand the trailers back to JS.
602+
const stream = this[kStream];
603+
if (stream.headersSent)
604+
stream[kDisableAutoTrailers]();
605+
}
596606
}
597607

598608
addTrailers(headers) {
@@ -911,13 +921,18 @@ class Http2ServerResponse extends Stream {
911921
const state = this[kState];
912922
const headers = this[kHeaders];
913923
headers[HTTP2_HEADER_STATUS] = state.statusCode;
924+
// Only wait for trailers if some have been registered, or if the
925+
// headers are flushed before the response is ended (in which case
926+
// trailers may still be added during streaming). Trailers added after
927+
// end() are dropped, matching HTTP/1 addTrailers() semantics.
928+
const waitForTrailers = state.hasTrailers || !state.finishing;
914929
const options = {
915930
endStream: state.ending,
916-
// Only wait for trailers if some have been registered, or if the
917-
// headers are flushed before the response is ended (in which case
918-
// trailers may still be added during streaming). Trailers added after
919-
// end() are dropped, matching HTTP/1 addTrailers() semantics.
920-
waitForTrailers: state.hasTrailers || !state.finishing,
931+
waitForTrailers,
932+
// When no trailers have been registered yet, let the native side
933+
// finish the stream on its own if none show up by the time the last
934+
// DATA frame is sent (see setTrailer()).
935+
[kAutoEmptyTrailers]: waitForTrailers && !state.hasTrailers,
921936
sendDate: state.sendDate,
922937
};
923938
this[kStream].respond(headers, options);

lib/internal/http2/core.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@ const {
150150
getStreamState,
151151
isPayloadMeaningless,
152152
kAuthority,
153+
kAutoEmptyTrailers,
154+
kDisableAutoTrailers,
153155
kSensitiveHeaders,
154156
kStrictSingleValueFields,
155157
kSocket,
@@ -334,6 +336,7 @@ const {
334336

335337
STREAM_OPTION_EMPTY_PAYLOAD,
336338
STREAM_OPTION_GET_TRAILERS,
339+
STREAM_OPTION_AUTO_EMPTY_TRAILERS,
337340
} = constants;
338341

339342
const STREAM_FLAGS_PENDING = 0x0;
@@ -2520,6 +2523,17 @@ class Http2Stream extends Duplex {
25202523
}
25212524
}
25222525

2526+
// Called by the compat layer when trailers are registered after the
2527+
// response headers were already sent with auto-empty trailers enabled
2528+
// (see STREAM_OPTION_AUTO_EMPTY_TRAILERS); switches the stream back to
2529+
// JS-managed trailers.
2530+
[kDisableAutoTrailers]() {
2531+
const handle = this[kHandle];
2532+
if (this.destroyed || handle === undefined)
2533+
return;
2534+
handle.disableAutoTrailers();
2535+
}
2536+
25232537
sendTrailers(headers) {
25242538
if (this.destroyed || this.closed)
25252539
throw new ERR_HTTP2_INVALID_STREAM();
@@ -3187,6 +3201,11 @@ class ServerHttp2Stream extends Http2Stream {
31873201
if (options.waitForTrailers) {
31883202
streamOptions |= STREAM_OPTION_GET_TRAILERS;
31893203
state.flags |= STREAM_FLAGS_HAS_TRAILERS;
3204+
// Internal mode used by the compat layer: if no trailers have been
3205+
// registered by the time the final DATA frame is sent, the native
3206+
// side finishes the stream without calling back into JS at all.
3207+
if (options[kAutoEmptyTrailers])
3208+
streamOptions |= STREAM_OPTION_AUTO_EMPTY_TRAILERS;
31903209
}
31913210

31923211
const {

lib/internal/http2/util.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ const {
3838
} = require('internal/errors');
3939

4040
const kAuthority = Symbol('authority');
41+
const kAutoEmptyTrailers = Symbol('autoEmptyTrailers');
42+
const kDisableAutoTrailers = Symbol('disableAutoTrailers');
4143
const kSensitiveHeaders = Symbol('sensitiveHeaders');
4244
const kStrictSingleValueFields = Symbol('strictSingleValueFields');
4345
const kSocket = Symbol('socket');
@@ -991,6 +993,8 @@ module.exports = {
991993
getStreamState,
992994
isPayloadMeaningless,
993995
kAuthority,
996+
kAutoEmptyTrailers,
997+
kDisableAutoTrailers,
994998
kSensitiveHeaders,
995999
kStrictSingleValueFields,
9961000
kSocket,

src/node_http2.cc

Lines changed: 67 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2308,6 +2308,9 @@ Http2Stream::Http2Stream(Http2Session* session,
23082308
if (options & STREAM_OPTION_GET_TRAILERS)
23092309
set_has_trailers();
23102310

2311+
if (options & STREAM_OPTION_AUTO_EMPTY_TRAILERS)
2312+
set_auto_empty_trailers();
2313+
23112314
PushStreamListener(&stream_listener_);
23122315

23132316
if (options & STREAM_OPTION_EMPTY_PAYLOAD)
@@ -2435,6 +2438,9 @@ int Http2Stream::SubmitResponse(const Http2Headers& headers, int options) {
24352438
if (options & STREAM_OPTION_GET_TRAILERS)
24362439
set_has_trailers();
24372440

2441+
if (options & STREAM_OPTION_AUTO_EMPTY_TRAILERS)
2442+
set_auto_empty_trailers();
2443+
24382444
if (!is_writable())
24392445
options |= STREAM_OPTION_EMPTY_PAYLOAD;
24402446

@@ -2468,39 +2474,67 @@ int Http2Stream::SubmitInfo(const Http2Headers& headers) {
24682474
}
24692475

24702476
void Http2Stream::OnTrailers() {
2477+
CHECK(!this->is_destroyed());
2478+
set_has_trailers(false);
2479+
if (!auto_empty_trailers()) {
2480+
EmitWantTrailers();
2481+
return;
2482+
}
2483+
// The JS side has not registered any trailers, so the stream can be
2484+
// finished without calling into JS at all. The empty DATA frame cannot be
2485+
// submitted synchronously because OnTrailers() runs from inside the data
2486+
// source read callback for the final DATA frame; defer it to the next
2487+
// turn of the event loop, just like the JS sendTrailers() path does.
2488+
Debug(this, "auto-submitting empty trailers");
2489+
env()->SetImmediate(
2490+
[self = BaseObjectPtr<Http2Stream>(this)](Environment* env) {
2491+
if (self->is_destroyed()) return;
2492+
// Hand control back to the JS side if trailers were registered in
2493+
// the meantime or if submitting the empty DATA frame failed.
2494+
if (!self->auto_empty_trailers() || self->SubmitEmptyTrailers() != 0)
2495+
self->EmitWantTrailers();
2496+
});
2497+
}
2498+
2499+
void Http2Stream::EmitWantTrailers() {
24712500
Debug(this, "let javascript know we are ready for trailers");
24722501
CHECK(!this->is_destroyed());
24732502
Isolate* isolate = env()->isolate();
24742503
HandleScope scope(isolate);
24752504
Local<Context> context = env()->context();
24762505
Context::Scope context_scope(context);
2477-
set_has_trailers(false);
24782506
MakeCallback(env()->http2session_on_stream_trailers_function(), 0, nullptr);
24792507
}
24802508

2481-
// Submit informational headers for a stream.
2509+
// Sending an empty trailers frame poses problems in Safari, Edge & IE.
2510+
// Instead we can just send an empty data frame with NGHTTP2_FLAG_END_STREAM
2511+
// to indicate that the stream is ready to be closed.
2512+
int Http2Stream::SubmitEmptyTrailers() {
2513+
CHECK(!this->is_destroyed());
2514+
Http2Scope h2scope(this);
2515+
Debug(this, "sending empty trailers");
2516+
Http2Stream::Provider::Stream prov(this, 0);
2517+
int ret = nghttp2_submit_data(
2518+
session_->session(),
2519+
NGHTTP2_FLAG_END_STREAM,
2520+
id_,
2521+
*prov);
2522+
CHECK_NE(ret, NGHTTP2_ERR_NOMEM);
2523+
return ret;
2524+
}
2525+
2526+
// Submit trailing headers for a stream.
24822527
int Http2Stream::SubmitTrailers(const Http2Headers& headers) {
24832528
CHECK(!this->is_destroyed());
2529+
if (headers.length() == 0)
2530+
return SubmitEmptyTrailers();
24842531
Http2Scope h2scope(this);
24852532
Debug(this, "sending %d trailers", headers.length());
2486-
int ret;
2487-
// Sending an empty trailers frame poses problems in Safari, Edge & IE.
2488-
// Instead we can just send an empty data frame with NGHTTP2_FLAG_END_STREAM
2489-
// to indicate that the stream is ready to be closed.
2490-
if (headers.length() == 0) {
2491-
Http2Stream::Provider::Stream prov(this, 0);
2492-
ret = nghttp2_submit_data(
2493-
session_->session(),
2494-
NGHTTP2_FLAG_END_STREAM,
2495-
id_,
2496-
*prov);
2497-
} else {
2498-
ret = nghttp2_submit_trailer(
2499-
session_->session(),
2500-
id_,
2501-
headers.data(),
2502-
headers.length());
2503-
}
2533+
int ret = nghttp2_submit_trailer(
2534+
session_->session(),
2535+
id_,
2536+
headers.data(),
2537+
headers.length());
25042538
CHECK_NE(ret, NGHTTP2_ERR_NOMEM);
25052539
return ret;
25062540
}
@@ -3110,6 +3144,15 @@ void Http2Stream::Trailers(const FunctionCallbackInfo<Value>& args) {
31103144
stream->SubmitTrailers(Http2Headers(env, headers)));
31113145
}
31123146

3147+
// Called by the JS layer when trailers are registered after the response
3148+
// headers were already submitted with STREAM_OPTION_AUTO_EMPTY_TRAILERS set,
3149+
// so that the trailers are handed back to JS instead of being auto-emptied.
3150+
void Http2Stream::DisableAutoTrailers(const FunctionCallbackInfo<Value>& args) {
3151+
Http2Stream* stream;
3152+
ASSIGN_OR_RETURN_UNWRAP(&stream, args.This());
3153+
stream->set_auto_empty_trailers(false);
3154+
}
3155+
31133156
// Grab the numeric id of the Http2Stream
31143157
void Http2Stream::GetID(const FunctionCallbackInfo<Value>& args) {
31153158
Http2Stream* stream;
@@ -3547,6 +3590,10 @@ void Initialize(Local<Object> target,
35473590
SetProtoMethod(isolate, stream, "pushPromise", Http2Stream::PushPromise);
35483591
SetProtoMethod(isolate, stream, "info", Http2Stream::Info);
35493592
SetProtoMethod(isolate, stream, "trailers", Http2Stream::Trailers);
3593+
SetProtoMethod(isolate,
3594+
stream,
3595+
"disableAutoTrailers",
3596+
Http2Stream::DisableAutoTrailers);
35503597
SetProtoMethod(isolate, stream, "respond", Http2Stream::Respond);
35513598
SetProtoMethod(isolate, stream, "rstStream", Http2Stream::RstStream);
35523599
SetProtoMethod(isolate, stream, "refreshState", Http2Stream::RefreshState);

src/node_http2.h

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ constexpr int STREAM_OPTION_EMPTY_PAYLOAD = 0x1;
5757
// Stream might have trailing headers
5858
constexpr int STREAM_OPTION_GET_TRAILERS = 0x2;
5959

60+
// Stream may finish with an empty DATA frame carrying END_STREAM without
61+
// calling back into JS, unless trailers are registered before then
62+
constexpr int STREAM_OPTION_AUTO_EMPTY_TRAILERS = 0x4;
63+
6064
// Http2Stream internal states
6165
constexpr int kStreamStateNone = 0x0;
6266
constexpr int kStreamStateShut = 0x1;
@@ -66,6 +70,7 @@ constexpr int kStreamStateClosed = 0x8;
6670
constexpr int kStreamStateDestroyed = 0x10;
6771
constexpr int kStreamStateTrailers = 0x20;
6872
constexpr int kStreamStatePeerReset = 0x40;
73+
constexpr int kStreamStateAutoEmptyTrailers = 0x80;
6974

7075
// Http2Session internal states
7176
constexpr int kSessionStateNone = 0x0;
@@ -310,7 +315,9 @@ class Http2Stream : public AsyncWrap,
310315

311316
// Submit trailing headers for this stream
312317
int SubmitTrailers(const Http2Headers& headers);
318+
int SubmitEmptyTrailers();
313319
void OnTrailers();
320+
void EmitWantTrailers();
314321

315322
// Submit a PRIORITY frame for this stream
316323
int SubmitPriority(const Http2Priority& priority, bool silent = false);
@@ -368,6 +375,17 @@ class Http2Stream : public AsyncWrap,
368375
flags_ &= ~kStreamStateTrailers;
369376
}
370377

378+
bool auto_empty_trailers() const {
379+
return flags_ & kStreamStateAutoEmptyTrailers;
380+
}
381+
382+
void set_auto_empty_trailers(bool on = true) {
383+
if (on)
384+
flags_ |= kStreamStateAutoEmptyTrailers;
385+
else
386+
flags_ &= ~kStreamStateAutoEmptyTrailers;
387+
}
388+
371389
void set_closed() {
372390
flags_ |= kStreamStateClosed;
373391
}
@@ -463,6 +481,8 @@ class Http2Stream : public AsyncWrap,
463481
static void RefreshState(const v8::FunctionCallbackInfo<v8::Value>& args);
464482
static void Info(const v8::FunctionCallbackInfo<v8::Value>& args);
465483
static void Trailers(const v8::FunctionCallbackInfo<v8::Value>& args);
484+
static void DisableAutoTrailers(
485+
const v8::FunctionCallbackInfo<v8::Value>& args);
466486
static void Respond(const v8::FunctionCallbackInfo<v8::Value>& args);
467487
static void RstStream(const v8::FunctionCallbackInfo<v8::Value>& args);
468488

@@ -1119,7 +1139,8 @@ class Origins {
11191139
V(NGHTTP2_ERR_STREAM_CLOSED) \
11201140
V(NGHTTP2_ERR_NOMEM) \
11211141
V(STREAM_OPTION_EMPTY_PAYLOAD) \
1122-
V(STREAM_OPTION_GET_TRAILERS)
1142+
V(STREAM_OPTION_GET_TRAILERS) \
1143+
V(STREAM_OPTION_AUTO_EMPTY_TRAILERS)
11231144

11241145
#define HTTP2_ERROR_CODES(V) \
11251146
V(NGHTTP2_NO_ERROR) \
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
if (!common.hasCrypto)
5+
common.skip('missing crypto');
6+
const assert = require('assert');
7+
const h2 = require('http2');
8+
9+
// Regression test for the auto-empty-trailers optimization: when the
10+
// response headers are flushed before the response is ended (streaming
11+
// mode), trailers registered afterwards must still be sent, and responses
12+
// that never register trailers must complete normally.
13+
14+
const server = h2.createServer();
15+
server.listen(0, common.mustCall(() => {
16+
const port = server.address().port;
17+
server.on('request', (request, response) => {
18+
if (request.url === '/trailers') {
19+
response.writeHead(200);
20+
response.write('hello');
21+
// Trailers registered after the headers were already flushed.
22+
response.setTrailer('x-checksum', 'abc');
23+
response.addTrailers({ 'x-count': 2 });
24+
response.end('world');
25+
} else {
26+
response.writeHead(200);
27+
response.write('no');
28+
response.end('trailers');
29+
}
30+
});
31+
32+
const client = h2.connect(`http://localhost:${port}`);
33+
34+
{
35+
const request = client.request({ ':path': '/trailers' });
36+
let body = '';
37+
request.setEncoding('utf8');
38+
request.on('data', (chunk) => body += chunk);
39+
request.on('trailers', common.mustCall((trailers) => {
40+
assert.strictEqual(trailers['x-checksum'], 'abc');
41+
assert.strictEqual(trailers['x-count'], '2');
42+
}));
43+
request.on('end', common.mustCall(() => {
44+
assert.strictEqual(body, 'helloworld');
45+
maybeClose();
46+
}));
47+
request.end();
48+
}
49+
50+
{
51+
const request = client.request({ ':path': '/plain' });
52+
let body = '';
53+
request.setEncoding('utf8');
54+
request.on('data', (chunk) => body += chunk);
55+
request.on('trailers', common.mustNotCall());
56+
request.on('end', common.mustCall(() => {
57+
assert.strictEqual(body, 'notrailers');
58+
maybeClose();
59+
}));
60+
request.end();
61+
}
62+
63+
let remaining = 2;
64+
function maybeClose() {
65+
if (--remaining === 0) {
66+
client.close();
67+
server.close();
68+
}
69+
}
70+
}));

0 commit comments

Comments
 (0)