Skip to content

Commit d6f566b

Browse files
codebytererichardlau
authored andcommitted
quic: give each BindingData its own allocator state
The ngtcp2 and nghttp3 allocators shared one thread_local state whose BindingData pointer was set by the last BindingData that handed out an allocator. With several Environments on a thread, memory allocated for a session in one Environment was accounted against another's BindingData and failed a CHECK when freed. Give each BindingData its own heap-allocated state. nghttp3 buffers backing external strings can be freed after the BindingData is gone, so the state counts live allocations and is deleted once the BindingData has been destroyed and the last of them is freed. Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com> PR-URL: #66411 Refs: #66239 Reviewed-By: Anna Henningsen <anna@addaleax.net>
1 parent 8d8af80 commit d6f566b

4 files changed

Lines changed: 81 additions & 45 deletions

File tree

‎src/quic/README.md‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -150,28 +150,30 @@ The Application is selected as soon as the ALPN protocol is known:
150150
immediately for clients, and for servers from the `OnClientHello` TLS
151151
callback (see [Server handshake ordering](#server-handshake-ordering)).
152152

153-
### Thread-Local Allocator
153+
### Allocator
154154

155155
Both ngtcp2 and nghttp3 require custom allocators (`ngtcp2_mem`,
156156
`nghttp3_mem`). These allocator structs must outlive every object they
157157
create. Some nghttp3 objects (notably `rcbuf`s backing V8 external strings)
158158
can survive past `BindingData` destruction during isolate teardown.
159159

160-
The solution uses `thread_local` storage:
160+
Each `BindingData` owns a heap-allocated `QuicAllocState` that holds both
161+
allocator structs and counts live allocations:
161162

162163
```cpp
163164
struct QuicAllocState {
164-
BindingData* binding = nullptr; // Nulled in ~BindingData
165+
BindingData* binding; // Nulled in ~BindingData
166+
size_t live_allocations = 0;
165167
ngtcp2_mem ngtcp2;
166168
nghttp3_mem nghttp3;
167169
};
168-
thread_local QuicAllocState quic_alloc_state;
169170
```
170171
171172
Each allocation prepends its size before the returned pointer. This allows
172173
`free` and `realloc` to report correct sizes for memory tracking. When
173174
`binding` is null (after `BindingData` destruction), allocations still
174-
succeed but memory tracking is silently skipped.
175+
succeed but memory tracking is silently skipped. The state is deleted once
176+
`binding` is null and the last allocation has been freed.
175177
176178
## Session Lifecycle
177179

‎src/quic/bindingdata.cc‎

Lines changed: 28 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -36,33 +36,30 @@ using v8::Value;
3636
namespace quic {
3737

3838
// ============================================================================
39-
// Thread-local QUIC allocator.
39+
// QUIC allocator.
4040
//
41-
// Both ngtcp2 and nghttp3 take an allocator struct (ngtcp2_mem /
42-
// nghttp3_mem) whose pointer is stored inside every object they
43-
// allocate. Some of those objects — notably nghttp3 rcbufs backing
44-
// V8 external strings — can outlive the BindingData that created them
45-
// (freed during V8 isolate teardown, after Environment cleanup).
46-
//
47-
// To handle this safely, both allocators live in a thread-local static
48-
// struct that is never destroyed. Memory tracking goes through the
49-
// BindingData pointer when it is alive and is silently skipped during
50-
// teardown (after ~BindingData nulls the pointer).
41+
// ngtcp2 and nghttp3 keep a pointer to their allocator struct in every object
42+
// they allocate, and nghttp3 rcbufs backing V8 external strings can be freed
43+
// after the BindingData is gone. A QuicAllocState is therefore deleted only
44+
// once its BindingData has been destroyed and its last allocation freed.
5145
//
5246
// The allocation functions use the same prepended-size-header scheme as
5347
// NgLibMemoryManager (node_mem-inl.h) so that frees always know the
5448
// allocation size regardless of whether BindingData is still around.
5549

56-
namespace {
5750
struct QuicAllocState {
58-
BindingData* binding = nullptr;
51+
BindingData* binding;
52+
size_t live_allocations = 0;
5953
ngtcp2_mem ngtcp2 = {};
6054
nghttp3_mem nghttp3 = {};
55+
56+
void OnFreed() {
57+
CHECK_GT(live_allocations, 0);
58+
if (--live_allocations == 0 && binding == nullptr) delete this;
59+
}
6160
};
62-
thread_local QuicAllocState quic_alloc_state;
6361

64-
// Core allocation functions shared by both ngtcp2 and nghttp3.
65-
// user_data always points to the thread-local QuicAllocState.
62+
namespace {
6663

6764
void* QuicRealloc(void* ptr, size_t size, void* user_data) {
6865
auto* state = static_cast<QuicAllocState*>(user_data);
@@ -77,6 +74,10 @@ void* QuicRealloc(void* ptr, size_t size, void* user_data) {
7774
previous_size = *reinterpret_cast<size_t*>(original_ptr);
7875
if (previous_size == 0) {
7976
char* ret = UncheckedRealloc(original_ptr, size);
77+
if (size == 0) {
78+
state->OnFreed();
79+
return nullptr;
80+
}
8081
if (ret != nullptr) ret += kReserveSizeAndAlign;
8182
return ret;
8283
}
@@ -95,6 +96,7 @@ void* QuicRealloc(void* ptr, size_t size, void* user_data) {
9596
state->binding->env()->external_memory_accounter()->Update(
9697
state->binding->env()->isolate(), new_size);
9798
}
99+
if (ptr == nullptr) state->live_allocations++;
98100
*reinterpret_cast<size_t*>(mem) = size;
99101
mem += kReserveSizeAndAlign;
100102
} else if (size == 0) {
@@ -103,6 +105,7 @@ void* QuicRealloc(void* ptr, size_t size, void* user_data) {
103105
state->binding->env()->external_memory_accounter()->Decrease(
104106
state->binding->env()->isolate(), previous_size);
105107
}
108+
if (ptr != nullptr) state->OnFreed();
106109
}
107110
return mem;
108111
}
@@ -231,35 +234,20 @@ BindingData& BindingData::Get(Environment* env) {
231234
}
232235

233236
BindingData::~BindingData() {
234-
quic_alloc_state.binding = nullptr;
237+
alloc_state_->binding = nullptr;
238+
if (alloc_state_->live_allocations == 0) delete alloc_state_;
235239
// flush_check_ is cleaned up by ~CheckWrapHandle() after the destructor
236240
// body completes. The inner CheckWrap (and its uv_check_t) will be freed
237241
// later by the uv_close callback, after CleanupHandles() runs uv_run().
238242
pending_flush_sessions_.clear();
239243
}
240244

241245
ngtcp2_mem* BindingData::ngtcp2_allocator() {
242-
quic_alloc_state.binding = this;
243-
quic_alloc_state.ngtcp2 = {
244-
&quic_alloc_state,
245-
Ngtcp2Malloc,
246-
Ngtcp2Free,
247-
Ngtcp2Calloc,
248-
Ngtcp2Realloc,
249-
};
250-
return &quic_alloc_state.ngtcp2;
246+
return &alloc_state_->ngtcp2;
251247
}
252248

253249
nghttp3_mem* BindingData::nghttp3_allocator() {
254-
quic_alloc_state.binding = this;
255-
quic_alloc_state.nghttp3 = {
256-
&quic_alloc_state,
257-
Nghttp3Malloc,
258-
Nghttp3Free,
259-
Nghttp3Calloc,
260-
Nghttp3Realloc,
261-
};
262-
return &quic_alloc_state.nghttp3;
250+
return &alloc_state_->nghttp3;
263251
}
264252

265253
void BindingData::CheckAllocatedSize(size_t previous_size) const {
@@ -348,7 +336,12 @@ JS_METHOD_IMPL(BindingData::SetHeadersInterest) {
348336

349337
BindingData::BindingData(Realm* realm, Local<Object> object)
350338
: BaseObject(realm, object),
339+
alloc_state_(new QuicAllocState{this}),
351340
flush_check_(env(), [this]() { OnFlushCheck(); }) {
341+
alloc_state_->ngtcp2 = {
342+
alloc_state_, Ngtcp2Malloc, Ngtcp2Free, Ngtcp2Calloc, Ngtcp2Realloc};
343+
alloc_state_->nghttp3 = {
344+
alloc_state_, Nghttp3Malloc, Nghttp3Free, Nghttp3Calloc, Nghttp3Realloc};
352345
MakeWeak();
353346
// Unref so the check handle doesn't keep the event loop alive on its own.
354347
flush_check_.Unref();

‎src/quic/bindingdata.h‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class Endpoint;
2424
class Packet;
2525
class Session;
2626
class SessionManager;
27+
struct QuicAllocState;
2728

2829
// ============================================================================
2930

@@ -281,15 +282,12 @@ class BindingData final
281282

282283
// NgLibMemoryManager — the base class provides CheckAllocatedSize,
283284
// IncreaseAllocatedSize, DecreaseAllocatedSize, and StopTrackingMemory.
284-
// Actual allocations go through the thread-local allocators below.
285+
// Actual allocations go through the allocators below.
285286
void CheckAllocatedSize(size_t previous_size) const;
286287
void IncreaseAllocatedSize(size_t size);
287288
void DecreaseAllocatedSize(size_t size);
288289

289-
// Thread-local allocators that outlive BindingData destruction.
290-
// Both ngtcp2 and nghttp3 store the allocator pointer inside every
291-
// object they allocate; some of those objects (e.g., nghttp3 rcbufs
292-
// backing V8 external strings) can be freed after BindingData is gone.
290+
// The allocators can outlive the BindingData; see QuicAllocState.
293291
ngtcp2_mem* ngtcp2_allocator();
294292
nghttp3_mem* nghttp3_allocator();
295293

@@ -384,6 +382,8 @@ class BindingData final
384382
ArenaPtr endpoint_state_arena_{nullptr, +[](void*) {}};
385383
ArenaPtr endpoint_stats_arena_{nullptr, +[](void*) {}};
386384

385+
QuicAllocState* alloc_state_;
386+
387387
// Deferred send flush state. The CheckWrapHandle fires immediately after
388388
// the I/O poll phase in the same event loop tick, allowing batched
389389
// receive processing: all packets are read during poll, then

‎test/cctest/test_environment_shared_isolate.cc‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,15 @@
88
#include "cppgc/garbage-collected.h"
99
#include "env-inl.h"
1010
#include "node_test_fixture.h"
11+
#include "quic/guard.h"
1112
#include "v8-cppgc.h"
1213
#if HAVE_OPENSSL
1314
#include "crypto/crypto_context.h"
1415
#endif
16+
#ifndef OPENSSL_NO_QUIC
17+
#include "node_realm-inl.h"
18+
#include "quic/bindingdata.h"
19+
#endif
1520

1621
#include <string>
1722
#include <vector>
@@ -480,6 +485,42 @@ TEST_P(SharedIsolateTest, RootCertStoreIsPerEnvironment) {
480485
}
481486
#endif // HAVE_OPENSSL
482487

488+
#ifndef OPENSSL_NO_QUIC
489+
TEST_P(SharedIsolateTest, QuicAllocatorIsPerEnvironment) {
490+
const HandleScope handle_scope(isolate_);
491+
std::unique_ptr<Instance> first =
492+
CreateInstance(0, EnvironmentFlags::kNoCreateInspector);
493+
std::unique_ptr<Instance> second =
494+
CreateInstance(1, EnvironmentFlags::kNoCreateInspector);
495+
auto allocator = [this](Instance* instance) {
496+
HandleScope inner(isolate_);
497+
Local<Context> context = instance->context.Get(isolate_);
498+
Context::Scope context_scope(context);
499+
Local<Value> name = v8::String::NewFromUtf8Literal(isolate_, "quic");
500+
instance->env->principal_realm()
501+
->internal_binding_loader()
502+
->Call(context, v8::Undefined(isolate_), 1, &name)
503+
.ToLocalChecked();
504+
return node::quic::BindingData::Get(instance->env).ngtcp2_allocator();
505+
};
506+
507+
ngtcp2_mem* first_mem = allocator(first.get());
508+
void* first_ptr = first_mem->malloc(64, first_mem->user_data);
509+
ngtcp2_mem* second_mem = allocator(second.get());
510+
void* tracked = second_mem->malloc(16, second_mem->user_data);
511+
void* untracked = second_mem->malloc(16, second_mem->user_data);
512+
node::quic::BindingData::Get(second->env).StopTrackingMemory(untracked);
513+
EXPECT_NE(first_mem, second_mem);
514+
first_mem->free(first_ptr, first_mem->user_data);
515+
516+
FreeInstance(std::move(second));
517+
second_mem->free(untracked, second_mem->user_data);
518+
tracked = second_mem->realloc(tracked, 32, second_mem->user_data);
519+
second_mem->free(tracked, second_mem->user_data);
520+
FreeInstance(std::move(first));
521+
}
522+
#endif // OPENSSL_NO_QUIC
523+
483524
INSTANTIATE_TEST_SUITE_P(
484525
EnvironmentTest,
485526
SharedIsolateTest,

0 commit comments

Comments
 (0)