Skip to content

Commit bc4d029

Browse files
committed
fix engine: support accessing local variables in destructor of another local variable
* Fixed UB in case a @ref engine::TaskLocalVariable is accessed in the destructor of another @ref engine::TaskLocalVariable. This use case is now fully supported, just like C++ supports this for `thread_local` variables. commit_hash:92859d762135387d41b26ea16d6fcee7e39fe28c
1 parent 07fef67 commit bc4d029

9 files changed

Lines changed: 271 additions & 13 deletions

File tree

core/include/userver/engine/impl/awaiter.hpp

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
#include <boost/intrusive/list_hook.hpp>
77

88
#include <userver/engine/impl/awaiter_fwd.hpp>
9-
#include <userver/engine/impl/epoch.hpp>
109
#include <userver/utils/assert.hpp>
1110

1211
USERVER_NAMESPACE_BEGIN

core/include/userver/engine/impl/task_local_storage.hpp

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,17 @@ class Storage final {
8888

8989
Storage(Storage&&) = delete;
9090
Storage& operator=(Storage&&) = delete;
91+
92+
// Requires that the variables have already been destroyed using
93+
// DestroyVariables, or that there are none.
9194
~Storage();
9295

96+
// Destroys the variables in reverse-initialization order.
97+
// Must be called before the destructor. Must be called in the coroutine
98+
// owning `this`: the destructors may want to sleep, and the storage must
99+
// still be usable (e.g. via GetCurrentStorage) while they run.
100+
void DestroyVariables() noexcept;
101+
93102
// Copies pointers to inherited variables from 'other'
94103
// 'this' must not contain any variables
95104
void InheritFrom(Storage& other);
@@ -99,8 +108,11 @@ class Storage final {
99108
// does nothing if there is nothing to copy
100109
void InheritNodeIfExists(Storage& other, Key key);
101110

102-
// Moves other's variables into 'this', leaving 'other' in an empty state
103-
// 'this' must not contain any variables
111+
// Moves other's variables into 'this', leaving 'other' with no variables.
112+
// 'this' must not contain inherited variables. It may contain normal
113+
// variables (e.g. task-locals initialized by engine plugin hooks before
114+
// the task payload starts); those are kept. In that case 'other' must not
115+
// contain normal variables.
104116
void InitializeFrom(Storage&& other) noexcept;
105117

106118
// Variable accessors must be called with the same T, Kind, key.

core/include/userver/engine/task/local_variable.hpp

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@ class TaskLocalVariable final {
3535
T* operator->();
3636

3737
/// @brief Get the variable instance for the current task.
38-
/// @returns the variable or `nullptr` if variable was not initialized.
38+
/// @returns the variable or `nullptr` if the variable was not initialized,
39+
/// was already destroyed, or is being destroyed right now. That is,
40+
/// a non-null result is guaranteed to point to a variable whose
41+
/// destruction has not started (the variable is unset before its
42+
/// destructor is invoked, as in POSIX `pthread_getspecific`).
3943
T* GetOptional() noexcept {
4044
return impl::task_local::GetCurrentStorage().GetOptional<T, kVariableKind>(impl_.GetKey());
4145
}

core/src/engine/impl/task_local_storage.cpp

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#include <userver/engine/impl/task_local_storage.hpp>
22

33
#include <ranges>
4+
#include <utility>
45

56
#include <fmt/format.h>
67
#include <boost/intrusive/list.hpp>
@@ -82,20 +83,42 @@ struct Storage::Impl final {
8283
Storage::Storage() { utils::impl::AssertStaticRegistrationFinished(); }
8384

8485
Storage::~Storage() {
86+
UASSERT_MSG(
87+
impl_->normal_data_storage.empty() && impl_->inherited_data_storage.empty(),
88+
"Storage::DestroyVariables must be called before the Storage destructor. The destructors of task-local "
89+
"variables may sleep and access the storage, so they must not run while the Storage (typically stored in "
90+
"a std::optional) is being destroyed"
91+
);
92+
}
93+
94+
void Storage::DestroyVariables() noexcept {
8595
const auto disposer = [](DataPtr* node_ptr) noexcept {
8696
UASSERT(node_ptr->ptr);
87-
node_ptr->ptr->DeleteSelf();
97+
// Unset the variable before running its destructor (POSIX
98+
// pthread_getspecific-style). This way GetOptional, when called from a
99+
// destructor of another task-local variable (they may sleep, letting
100+
// arbitrary engine code run), returns nullptr instead of a pointer to
101+
// a destroyed or currently-being-destroyed object.
102+
auto* const data = std::exchange(node_ptr->ptr, nullptr);
103+
data->DeleteSelf();
88104
};
89105

90106
// By default, boost::intrusive containers don't own their elements (nodes),
91107
// so we need to destroy them explicitly. The variables are destroyed
92108
// front-to-back, in reverse-initialization order.
93-
while (!impl_->normal_data_storage.empty()) {
94-
impl_->normal_data_storage.pop_front_and_dispose(disposer);
95-
}
96-
97-
while (!impl_->inherited_data_storage.empty()) {
98-
impl_->inherited_data_storage.pop_front_and_dispose(disposer);
109+
//
110+
// A destructor may initialize other task-local variables. Same as for
111+
// `thread_local` ([basic.stc.thread]/2): a variable initialized after
112+
// a destructor has started executing is destroyed after that destructor
113+
// completes. Freshly initialized variables are pushed to the list front,
114+
// so the inner loops pick them up; the outer loop handles a destructor of
115+
// a variable of one kind initializing a variable of the other kind.
116+
while (!impl_->normal_data_storage.empty() || !impl_->inherited_data_storage.empty()) {
117+
if (!impl_->normal_data_storage.empty()) {
118+
impl_->normal_data_storage.pop_front_and_dispose(disposer);
119+
} else {
120+
impl_->inherited_data_storage.pop_front_and_dispose(disposer);
121+
}
99122
}
100123
}
101124

core/src/engine/plugin.hpp

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ class PluginBase {
3737
/// Callback that is called in coroutine when the task finishes executing its
3838
/// payload (once per task, after completion or cancellation). The terminal
3939
/// state is available via task.GetPendingFinalState().
40+
/// Task-local variables are destroyed after this callback is called.
41+
/// Note: the task may sleep during the destruction of task-local variables
42+
/// after this callback is called.
4043
virtual void HookTaskStop(impl::TaskContext& /*task*/) noexcept {}
4144
};
4245

core/src/engine/task/async_benchmark.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ void WrapCallAndPerform(benchmark::State& state) {
111111
// Perform requires that task-local storage is empty, then fills it
112112
engine::impl::task_local::Storage discarded_storage;
113113
discarded_storage.InitializeFrom(std::move(engine::impl::task_local::GetCurrentStorage()));
114+
discarded_storage.DestroyVariables();
114115
}
115116
wrapped_call.Perform();
116117
}

core/src/engine/task/local_variable_test.cpp

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
#include <userver/utest/utest.hpp>
22

3+
#include <functional>
34
#include <optional>
45
#include <utility>
56

67
#include <userver/engine/async.hpp>
78
#include <userver/engine/single_use_event.hpp>
89
#include <userver/engine/sleep.hpp>
10+
#include <userver/engine/task/current_task.hpp>
11+
#include <userver/engine/task/inherited_variable.hpp>
912
#include <userver/engine/task/local_variable.hpp>
1013
#include <userver/utils/async.hpp>
1114

15+
#include <engine/task/task_processor.hpp>
16+
1217
USERVER_NAMESPACE_BEGIN
1318

1419
namespace {
@@ -198,4 +203,203 @@ UTEST(TaskLocalVariable, WaitInDestructorCancelled) {
198203
UEXPECT_THROW(task.Get(), engine::TaskCancelledException);
199204
}
200205

206+
namespace {
207+
208+
struct DtorCallback final {
209+
std::function<void()> on_destroy;
210+
211+
DtorCallback() = default;
212+
DtorCallback(const DtorCallback&) = delete;
213+
DtorCallback& operator=(const DtorCallback&) = delete;
214+
215+
~DtorCallback() {
216+
if (on_destroy) {
217+
on_destroy();
218+
}
219+
}
220+
};
221+
222+
engine::TaskLocalVariable<DtorCallback> kDtorCallback;
223+
engine::TaskLocalVariable<int> kObservedInt;
224+
225+
} // namespace
226+
227+
// Situation 1a: GetOptional on a live (not yet destroyed) variable.
228+
UTEST(TaskLocalVariable, GetOptionalAlive) {
229+
EXPECT_EQ(kObservedInt.GetOptional(), nullptr);
230+
231+
*kObservedInt = 42;
232+
ASSERT_NE(kObservedInt.GetOptional(), nullptr);
233+
EXPECT_EQ(*kObservedInt.GetOptional(), 42);
234+
}
235+
236+
// Situation 1b: a variable that is initialized earlier (thus destroyed later)
237+
// is still observable from the destructor of another task-local variable.
238+
UTEST(TaskLocalVariable, GetOptionalAliveFromOtherVariableDestructor) {
239+
bool checked = false;
240+
241+
engine::AsyncNoTracing([&] {
242+
// kObservedInt is initialized first => destroyed after kDtorCallback.
243+
*kObservedInt = 42;
244+
kDtorCallback->on_destroy = [&] {
245+
auto* const observed = kObservedInt.GetOptional();
246+
ASSERT_NE(observed, nullptr);
247+
EXPECT_EQ(*observed, 42);
248+
checked = true;
249+
};
250+
}).Get();
251+
252+
EXPECT_TRUE(checked);
253+
}
254+
255+
// Situation 2: GetOptional on an already-destroyed variable must return
256+
// nullptr, not a dangling pointer.
257+
UTEST(TaskLocalVariable, GetOptionalAfterVariableDestroyed) {
258+
bool checked = false;
259+
260+
engine::AsyncNoTracing([&] {
261+
kDtorCallback->on_destroy = [&] {
262+
EXPECT_EQ(kObservedInt.GetOptional(), nullptr);
263+
checked = true;
264+
};
265+
// kObservedInt is initialized after kDtorCallback => destroyed before
266+
// it, so ~DtorCallback observes an already-destroyed variable.
267+
*kObservedInt = 42;
268+
}).Get();
269+
270+
EXPECT_TRUE(checked);
271+
}
272+
273+
// Situation 3: GetOptional on a variable whose destructor is currently
274+
// running must return nullptr (POSIX pthread_getspecific-style: the variable
275+
// is unset before its destructor is invoked). The destructor itself can use
276+
// `this` if needed; other code must not observe a half-destroyed object.
277+
UTEST(TaskLocalVariable, GetOptionalDuringOwnDestruction) {
278+
bool checked = false;
279+
280+
engine::AsyncNoTracing([&] {
281+
kDtorCallback->on_destroy = [&] {
282+
EXPECT_EQ(kDtorCallback.GetOptional(), nullptr);
283+
checked = true;
284+
};
285+
}).Get();
286+
287+
EXPECT_TRUE(checked);
288+
}
289+
290+
namespace {
291+
292+
struct CallbackHolder final {
293+
std::function<void()> on_destroy;
294+
295+
explicit CallbackHolder(std::function<void()> cb)
296+
: on_destroy(std::move(cb))
297+
{}
298+
299+
CallbackHolder(const CallbackHolder&) = delete;
300+
CallbackHolder& operator=(const CallbackHolder&) = delete;
301+
302+
~CallbackHolder() {
303+
if (on_destroy) {
304+
on_destroy();
305+
}
306+
}
307+
};
308+
309+
engine::TaskInheritedVariable<CallbackHolder> kInheritedCallback;
310+
311+
} // namespace
312+
313+
// A task-local variable initialized from a destructor of another task-local
314+
// variable is destroyed after that destructor completes. Same as for
315+
// `thread_local`, [basic.stc.thread]/2: "If a variable with thread storage
316+
// duration is initialized after a thread-local destructor has started
317+
// executing, its destructor is scheduled to run after that destructor
318+
// completes".
319+
UTEST(TaskLocalVariable, DtorInitializesLocalVariable) {
320+
std::string order;
321+
322+
engine::AsyncNoTracing([&] {
323+
kDtorCallback->on_destroy = [&] {
324+
kGuardX->emplace(order, "x");
325+
order += "a";
326+
};
327+
}).Get();
328+
329+
// "a" (the initializing destructor completes) strictly before "x"
330+
EXPECT_EQ(order, "ax");
331+
}
332+
333+
// A task-inherited variable initialized from a destructor of a task-local
334+
// variable is destroyed after that destructor completes.
335+
UTEST(TaskLocalVariable, LocalDtorInitializesInheritedVariable) {
336+
std::string order;
337+
338+
engine::AsyncNoTracing([&] {
339+
kDtorCallback->on_destroy = [&] {
340+
kInheritedCallback.Emplace([&order] { order += "i"; });
341+
order += "a";
342+
};
343+
}).Get();
344+
345+
EXPECT_EQ(order, "ai");
346+
}
347+
348+
// A task-local variable initialized from a destructor of a task-inherited
349+
// variable is destroyed after that destructor completes.
350+
UTEST(TaskLocalVariable, InheritedDtorInitializesLocalVariable) {
351+
std::string order;
352+
353+
engine::AsyncNoTracing([&] {
354+
kInheritedCallback.Emplace([&] {
355+
kGuardX->emplace(order, "x");
356+
order += "a";
357+
});
358+
}).Get();
359+
360+
EXPECT_EQ(order, "ax");
361+
}
362+
363+
// A task-inherited variable initialized from a destructor of another
364+
// task-inherited variable is destroyed after that destructor completes.
365+
UTEST(TaskLocalVariable, InheritedDtorInitializesInheritedVariable) {
366+
std::string order;
367+
368+
engine::AsyncNoTracing([&] {
369+
kInheritedCallback.Emplace([&] {
370+
kInheritedCallback.Emplace([&order] { order += "i"; });
371+
order += "a";
372+
});
373+
}).Get();
374+
375+
EXPECT_EQ(order, "ai");
376+
}
377+
378+
// A child task inherits a variable on construction and keeps it alive even if:
379+
// - the parent erases the variable before the child starts,
380+
// - the child is cancelled before start (finishes as State::kCancelled).
381+
// The variable is destroyed only after the child task finishes.
382+
UTEST(TaskInheritedVariable, ErasedInParentWhileChildCancelledBeforeStart) {
383+
// With more than 1 worker thread the child task would start running
384+
// concurrently, racing with RequestCancel and Erase below.
385+
ASSERT_EQ(engine::current_task::GetTaskProcessor().GetWorkerCount(), 1);
386+
387+
bool destroyed = false;
388+
kInheritedCallback.Emplace([&destroyed] { destroyed = true; });
389+
390+
// The child inherits the variable on construction...
391+
auto task = utils::Async("child", [] { FAIL() << "the task must not start"; });
392+
// ...and is cancelled before it gets a chance to start.
393+
task.RequestCancel();
394+
395+
// Hide the variable from the parent. The child still holds a reference.
396+
kInheritedCallback.Erase();
397+
EXPECT_FALSE(destroyed);
398+
399+
task.Wait();
400+
EXPECT_EQ(task.GetState(), engine::TaskBase::State::kCancelled);
401+
// The child dropped the last reference when it finished.
402+
EXPECT_TRUE(destroyed);
403+
}
404+
201405
USERVER_NAMESPACE_END

core/src/engine/task/task_context.cpp

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -518,7 +518,15 @@ class TaskContext::LocalStorageGuard {
518518
context_.local_storage_.emplace();
519519
}
520520

521-
~LocalStorageGuard() { context_.local_storage_.reset(); }
521+
~LocalStorageGuard() {
522+
// Destroy the variables while the storage is still alive and reachable
523+
// through GetCurrentStorage: the destructors may sleep, letting
524+
// arbitrary engine code (e.g. plugin hooks) access task-locals.
525+
// Calling this from ~Storage during optional::reset would make that
526+
// access UB.
527+
context_.local_storage_->DestroyVariables();
528+
context_.local_storage_.reset();
529+
}
522530

523531
private:
524532
TaskContext& context_;

core/src/utils/impl/span_wrap_call.cpp

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,11 @@ void SpanWrapCall::DoBeforeInvoke() {
4848
pimpl_->span.Get().AttachToCoroStack();
4949
}
5050

51-
SpanWrapCall::~SpanWrapCall() = default;
51+
SpanWrapCall::~SpanWrapCall() {
52+
// Non-empty if the task was never started (e.g. cancelled before Perform):
53+
// drop the references to the inherited variables.
54+
pimpl_->storage.DestroyVariables();
55+
}
5256

5357
} // namespace utils::impl
5458

0 commit comments

Comments
 (0)