Repository navigation
Destroy keytable pool and tls key when Server::Start() fails early - #3590
chenBright merged 4 commits into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Destroy the keytable pool before deleting the TLS key and expand tests to exercise TLS cleanup.
2 open findings
What changed in this PR
This PR fixes leaks from server startup failures before reaching RUNNING and adds retry regression coverage.
Changes:
- Adds pre-start allocation cleanup during rollback and destruction.
- Adds port-conflict and retry regression tests.
- Declares cleanup support for startup rollback.
| File | Summary |
|---|---|
test/brpc_server_unittest.cpp |
Adds failed-start retry coverage, but does not exercise TLS cleanup. |
src/brpc/server.h |
Declares the pre-start cleanup helper and rollback access. |
src/brpc/server.cpp |
Implements startup-failure cleanup; cleanup ordering can leak TLS data. |
🧠 Review effort: Lite
Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.
StartInternal() allocates _keytable_pool (and the tls key when a thread_local_data_factory is set) unconditionally, but on failures before _status reaches RUNNING - e.g. tcp_listen() fails because the port is in use - RevertServerStatus runs Stop(0)+Join(), both of which return immediately when the server is not RUNNING. The pool and the key leaked on every failed attempt, and a retry overwrote _keytable_pool with a fresh pool, permanently leaking the previous one. ~Server() had the same problem for a failed-start server. Add Server::DestroyPreStartAllocations() (no-op once RUNNING/STOPPING, where Join() handles cleanup) and call it from RevertServerStatus and ~Server(). Unlike Join() the pool struct is deleted too, since no connection can exist before RUNNING. Verified in ServerTest.sanity: after a port-conflict failure, _keytable_pool is nullptr and the tls key is invalid (on the old code both leak and the test fails), including across a failed retry. Full brpc_server_unittest suite shows no new failures.
ea780aa to
d353964
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
The pre-start keytable pool cleanup may leak platform resources by leaving its rwlock undestroyed.
0 open findings
2 resolved since last review
🧠 Review effort: Lite
Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.
|
LGTM |
|
The init bthreads themselves are joined inside StartInternal(), and task_runner() calls return_keytable() before bumping the version, so they're done with the pool by the time we reach a failure path. But anything spawned from bthread_init_fn with the inherited attr carries can outlive the failed Start(). It still calls borrow_keytable()/return_keytable() on that pool. With delete _keytable_pool that's a heap-use-after-free. ASan confirms it. Fixed by doing what Join() does: call bthread_keytable_pool_destroy() but don't delete the struct. A destroyed pool makes late return_keytable() calls just delete their KeyTable, and the struct stays valid. The TLS data and reserved KeyTables are still released, which was the main leak. I also added failed_start_keeps_keytable_pool_for_init_bthreads, which spawns such a child from bthread_init_fn and fails Start() with EADDRINUSE. It fails under ASan with the old code and passes now. |
(bthread_getattr(bthread_self(), ...)) carry keytable_pool == _keytable_pool and may outlive a failed Server::Start(). They still call return_keytable() on exit, so deleting the pool struct in DestroyPreStartAllocations() causes a heap-use-after-free (reported by ASan at key.cpp:394). Do the same as Join(): destroy the pool contents with bthread_keytable_pool_destroy() but don't delete the struct. A destroyed pool makes late return_keytable() calls delete their KeyTable directly. The leftover struct is small, only leaked on failed starts, and already annotated in StartInternal(). Add failed_start_keeps_keytable_pool_for_init_bthreads, which spawns such a child from bthread_init_fn and fails Start() with EADDRINUSE.
wwbmmm
left a comment
There was a problem hiding this comment.
[replied by brpc-oncall robot] The production-side fix is correct, but the new regression test relies on a fixed sleep and therefore does not deterministically exercise the use-after-free; please synchronize the child bthread with the main thread.
Replace the fixed sleep with a CountdownEvent so the child bthread continues only after the failed Start() returns and the Server is destroyed. This ensures that the regression test exercises access to the destroyed keytable pool. Use a nonfatal Start() assertion so the child is still signaled and joined if startup unexpectedly succeeds.
|
Addressed the feedback: the child now waits on a CountdownEvent signaled only after the failed Start() returns and the Server is destroyed, so it always touches the destroyed pool. @chenBright @wwbmmm PTAL. |
Centralize keytable pool and TLS key cleanup for normal shutdown and failed-start recovery while preserving their destruction order and pool lifetime constraints.
| if (_tl_options.tls_key != INVALID_BTHREAD_KEY) { | ||
| CHECK_EQ(0, bthread_keytable_pool_destroy_and_delete_key( | ||
| _keytable_pool, _tl_options.tls_key)); | ||
| _tl_options.tls_key = INVALID_BTHREAD_KEY; |
There was a problem hiding this comment.
thread_local_data_factory is non-owned and is only documented as needing to remain valid while the server is running. Keeping the TLS key valid does not keep its raw factory pointer alive. After a failed Start() or shutdown, the caller may destroy the factory, but an outliving bthread can still invoke DestroyServerTLS() through that pointer.
| if (m->attr.keytable_pool != nullptr) { | ||
| bthread_keytable_pool_release(m->attr.keytable_pool); | ||
| } |
There was a problem hiding this comment.
Pool destruction holds the pool write lock while invoking cached TLS destructors. If a destructor signals and joins a same-pool child that has no KeyTable, that child now needs the same write lock in bthread_keytable_pool_release() before it can finish. The cleanup thread waits for the child, while the child waits for the cleanup thread’s lock.
| if (pool == nullptr) { | ||
| return 0; | ||
| } | ||
| pthread_rwlock_wrlock(&pool->rwlock); |
There was a problem hiding this comment.
Every live-pool bthread now acquires the same pool write lock during creation and exit, including servers without a TLS factory. This adds serialization to normal processing, beyond startup-failure cleanup
|
Thanks for pointing these out. I agree this PR has grown beyond its original goal of cleaning up allocations when Server::Start() fails before RUNNING. The deferred TLS key deletion and pool reference tracking introduce broader lifecycle and performance concerns. I’ll move that work into a separate PR to address factory lifetime, teardown deadlocks, and normal-path locking overhead together. I’ll rework this PR around the original startup-failure cleanup and regression tests, and verify that the reduced scope is independently safe. |
4b5f71b to
2a9a2f0
Compare



What problem does this PR solve?
Problem Summary:
Server::StartInternal()allocates a keytable pool and, when thread-local data is configured, a TLS key before the server reachesRUNNING. If startup fails early—for example, because the requested port is already in use—RevertServerStatuscallsStop()andJoin(), but both are no-ops beforeRUNNING.As a result, the keytable pool and TLS key were leaked. Retrying
Start()on the sameServercould overwrite the pool pointer and make the previous allocation permanently unreachable. The same leak also affected destruction of a server whose startup had failed.What is changed and the side effects?
Changed:
Server::DestroyPreStartAllocations()to release the TLS key and keytable pool when startup fails beforeRUNNING.Serverdestruction.Side effects:
Check List:
cmake --build build --target brpc_server_unittest -j6./build/test/brpc_server_unittest --gtest_filter=ServerTest.sanity