mirror of
https://github.com/Atmosphere-NX/Atmosphere
synced 2024-12-22 20:31:14 +00:00
kern: cleanup KThread, optimize/normalize KThreadQueue/KWaitObject
This commit is contained in:
parent
a4c4cf22c9
commit
72671d39ab
10 changed files with 58 additions and 168 deletions
|
@ -24,7 +24,6 @@ namespace ams::kern {
|
|||
struct InfoCreateThread {
|
||||
u32 thread_id;
|
||||
uintptr_t tls_address;
|
||||
uintptr_t entrypoint;
|
||||
};
|
||||
|
||||
struct InfoExitProcess {
|
||||
|
|
|
@ -31,7 +31,7 @@ namespace ams::kern {
|
|||
|
||||
using KThreadFunction = void (*)(uintptr_t);
|
||||
|
||||
class KThread final : public KAutoObjectWithSlabHeapAndContainer<KThread, KSynchronizationObject>, public KTimerTask, public KWorkerTask {
|
||||
class KThread final : public KAutoObjectWithSlabHeapAndContainer<KThread, KSynchronizationObject>, public util::IntrusiveListBaseNode<KThread>, public KTimerTask, public KWorkerTask {
|
||||
MESOSPHERE_AUTOOBJECT_TRAITS(KThread, KSynchronizationObject);
|
||||
private:
|
||||
friend class KProcess;
|
||||
|
@ -111,6 +111,8 @@ namespace ams::kern {
|
|||
constexpr void SetPrev(KThread *t) { this->prev = t; }
|
||||
constexpr void SetNext(KThread *t) { this->next = t; }
|
||||
};
|
||||
|
||||
using WaiterList = util::IntrusiveListBaseTraits<KThread>::ListType;
|
||||
private:
|
||||
static constexpr size_t PriorityInheritanceCountMax = 10;
|
||||
union SyncObjectBuffer {
|
||||
|
@ -141,13 +143,19 @@ namespace ams::kern {
|
|||
static inline std::atomic<u64> s_next_thread_id = 0;
|
||||
private:
|
||||
alignas(16) KThreadContext thread_context{};
|
||||
util::IntrusiveListNode process_list_node{};
|
||||
util::IntrusiveRedBlackTreeNode condvar_arbiter_tree_node{};
|
||||
s32 priority{};
|
||||
|
||||
using ConditionVariableThreadTreeTraits = util::IntrusiveRedBlackTreeMemberTraitsDeferredAssert<&KThread::condvar_arbiter_tree_node>;
|
||||
using ConditionVariableThreadTree = ConditionVariableThreadTreeTraits::TreeType<ConditionVariableComparator>;
|
||||
|
||||
ConditionVariableThreadTree *condvar_tree{};
|
||||
uintptr_t condvar_key{};
|
||||
KAffinityMask affinity_mask{};
|
||||
u64 thread_id{};
|
||||
std::atomic<s64> cpu_time{};
|
||||
KSynchronizationObject *synced_object{};
|
||||
KLightLock *waiting_lock{};
|
||||
uintptr_t condvar_key{};
|
||||
uintptr_t entrypoint{};
|
||||
KProcessAddress address_key{};
|
||||
KProcess *parent{};
|
||||
void *kernel_stack_top{};
|
||||
|
@ -159,43 +167,31 @@ namespace ams::kern {
|
|||
s64 schedule_count{};
|
||||
s64 last_scheduled_tick{};
|
||||
QueueEntry per_core_priority_queue_entry[cpu::NumCores]{};
|
||||
QueueEntry sleeping_queue_entry{};
|
||||
KLightLock *waiting_lock{};
|
||||
|
||||
KThreadQueue *sleeping_queue{};
|
||||
util::IntrusiveListNode waiter_list_node{};
|
||||
util::IntrusiveRedBlackTreeNode condvar_arbiter_tree_node{};
|
||||
util::IntrusiveListNode process_list_node{};
|
||||
|
||||
using WaiterListTraits = util::IntrusiveListMemberTraitsDeferredAssert<&KThread::waiter_list_node>;
|
||||
using WaiterList = WaiterListTraits::ListType;
|
||||
|
||||
using ConditionVariableThreadTreeTraits = util::IntrusiveRedBlackTreeMemberTraitsDeferredAssert<&KThread::condvar_arbiter_tree_node>;
|
||||
using ConditionVariableThreadTree = ConditionVariableThreadTreeTraits::TreeType<ConditionVariableComparator>;
|
||||
|
||||
WaiterList waiter_list{};
|
||||
WaiterList pinned_waiter_list{};
|
||||
KThread *lock_owner{};
|
||||
ConditionVariableThreadTree *condvar_tree{};
|
||||
uintptr_t debug_params[3]{};
|
||||
u32 address_key_value{};
|
||||
u32 suspend_request_flags{};
|
||||
u32 suspend_allowed_flags{};
|
||||
Result wait_result;
|
||||
Result debug_exception_result;
|
||||
s32 priority{};
|
||||
s32 current_core_id{};
|
||||
s32 core_id{};
|
||||
s32 base_priority{};
|
||||
s32 ideal_core_id{};
|
||||
s32 num_kernel_waiters{};
|
||||
s32 current_core_id{};
|
||||
s32 core_id{};
|
||||
KAffinityMask original_affinity_mask{};
|
||||
s32 original_ideal_core_id{};
|
||||
s32 num_core_migration_disables{};
|
||||
ThreadState thread_state{};
|
||||
std::atomic<bool> termination_requested{};
|
||||
bool ipc_cancelled{};
|
||||
bool wait_cancelled{};
|
||||
bool cancellable{};
|
||||
bool registered{};
|
||||
bool signaled{};
|
||||
bool initialized{};
|
||||
bool debug_attached{};
|
||||
|
@ -393,8 +389,6 @@ namespace ams::kern {
|
|||
constexpr QueueEntry &GetPriorityQueueEntry(s32 core) { return this->per_core_priority_queue_entry[core]; }
|
||||
constexpr const QueueEntry &GetPriorityQueueEntry(s32 core) const { return this->per_core_priority_queue_entry[core]; }
|
||||
|
||||
constexpr QueueEntry &GetSleepingQueueEntry() { return this->sleeping_queue_entry; }
|
||||
constexpr const QueueEntry &GetSleepingQueueEntry() const { return this->sleeping_queue_entry; }
|
||||
constexpr void SetSleepingQueue(KThreadQueue *q) { this->sleeping_queue = q; }
|
||||
|
||||
constexpr ConditionVariableThreadTree *GetConditionVariableTree() const { return this->condvar_tree; }
|
||||
|
@ -459,8 +453,6 @@ namespace ams::kern {
|
|||
constexpr KProcess *GetOwnerProcess() const { return this->parent; }
|
||||
constexpr bool IsUserThread() const { return this->parent != nullptr; }
|
||||
|
||||
constexpr uintptr_t GetEntrypoint() const { return this->entrypoint; }
|
||||
|
||||
constexpr KProcessAddress GetThreadLocalRegionAddress() const { return this->tls_address; }
|
||||
constexpr void *GetThreadLocalRegionHeapAddress() const { return this->tls_heap_address; }
|
||||
|
||||
|
@ -541,10 +533,6 @@ namespace ams::kern {
|
|||
virtual void OnTimer() override;
|
||||
virtual void DoWorkerTask() override;
|
||||
public:
|
||||
static constexpr bool IsWaiterListValid() {
|
||||
return WaiterListTraits::IsValid();
|
||||
}
|
||||
|
||||
static constexpr bool IsConditionVariableThreadTreeValid() {
|
||||
return ConditionVariableThreadTreeTraits::IsValid();
|
||||
}
|
||||
|
@ -555,7 +543,6 @@ namespace ams::kern {
|
|||
using ConditionVariableThreadTreeType = ConditionVariableThreadTree;
|
||||
};
|
||||
static_assert(alignof(KThread) == 0x10);
|
||||
static_assert(KThread::IsWaiterListValid());
|
||||
static_assert(KThread::IsConditionVariableThreadTreeValid());
|
||||
|
||||
class KScopedDisableDispatch {
|
||||
|
|
|
@ -21,92 +21,38 @@ namespace ams::kern {
|
|||
|
||||
class KThreadQueue {
|
||||
private:
|
||||
using Entry = KThread::QueueEntry;
|
||||
private:
|
||||
Entry root;
|
||||
KThread::WaiterList wait_list;
|
||||
public:
|
||||
constexpr ALWAYS_INLINE KThreadQueue() : root() { /* ... */ }
|
||||
constexpr ALWAYS_INLINE KThreadQueue() : wait_list() { /* ... */ }
|
||||
|
||||
constexpr ALWAYS_INLINE bool IsEmpty() const { return this->root.GetNext() == nullptr; }
|
||||
bool IsEmpty() const { return this->wait_list.empty(); }
|
||||
|
||||
constexpr ALWAYS_INLINE KThread *GetFront() const { return this->root.GetNext(); }
|
||||
constexpr ALWAYS_INLINE KThread *GetNext(KThread *t) const { return t->GetSleepingQueueEntry().GetNext(); }
|
||||
private:
|
||||
constexpr ALWAYS_INLINE KThread *GetBack() const { return this->root.GetPrev(); }
|
||||
|
||||
constexpr ALWAYS_INLINE void Enqueue(KThread *add) {
|
||||
/* Get the entry associated with the added thread. */
|
||||
Entry &add_entry = add->GetSleepingQueueEntry();
|
||||
|
||||
/* Get the entry associated with the end of the queue. */
|
||||
KThread *tail = this->GetBack();
|
||||
Entry &tail_entry = (tail != nullptr) ? tail->GetSleepingQueueEntry() : this->root;
|
||||
|
||||
/* Link the entries. */
|
||||
add_entry.SetPrev(tail);
|
||||
add_entry.SetNext(nullptr);
|
||||
tail_entry.SetNext(add);
|
||||
this->root.SetPrev(add);
|
||||
}
|
||||
|
||||
constexpr ALWAYS_INLINE void Remove(KThread *remove) {
|
||||
/* Get the entry associated with the thread. */
|
||||
Entry &remove_entry = remove->GetSleepingQueueEntry();
|
||||
|
||||
/* Get the entries associated with next and prev. */
|
||||
KThread *prev = remove_entry.GetPrev();
|
||||
KThread *next = remove_entry.GetNext();
|
||||
Entry &prev_entry = (prev != nullptr) ? prev->GetSleepingQueueEntry() : this->root;
|
||||
Entry &next_entry = (next != nullptr) ? next->GetSleepingQueueEntry() : this->root;
|
||||
|
||||
/* Unlink. */
|
||||
prev_entry.SetNext(next);
|
||||
next_entry.SetPrev(prev);
|
||||
}
|
||||
public:
|
||||
constexpr ALWAYS_INLINE void Dequeue() {
|
||||
/* Get the front of the queue. */
|
||||
KThread *head = this->GetFront();
|
||||
if (head == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
MESOSPHERE_ASSERT(head->GetState() == KThread::ThreadState_Waiting);
|
||||
|
||||
/* Get the entry for the next head. */
|
||||
KThread *next = GetNext(head);
|
||||
Entry &next_entry = (next != nullptr) ? next->GetSleepingQueueEntry() : this->root;
|
||||
|
||||
/* Link the entries. */
|
||||
this->root.SetNext(next);
|
||||
next_entry.SetPrev(nullptr);
|
||||
|
||||
/* Clear the head's queue. */
|
||||
head->SetSleepingQueue(nullptr);
|
||||
}
|
||||
KThread::WaiterList::iterator begin() { return this->wait_list.begin(); }
|
||||
KThread::WaiterList::iterator end() { return this->wait_list.end(); }
|
||||
|
||||
bool SleepThread(KThread *t) {
|
||||
KScopedSchedulerLock sl;
|
||||
|
||||
/* If the thread needs terminating, don't enqueue it. */
|
||||
if (t->IsTerminationRequested()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Set the thread's queue and mark it as waiting. */
|
||||
t->SetSleepingQueue(this);
|
||||
t->SetState(KThread::ThreadState_Waiting);
|
||||
|
||||
/* Add the thread to the queue. */
|
||||
this->Enqueue(t);
|
||||
|
||||
/* If the thread needs terminating, undo our work. */
|
||||
if (t->IsTerminationRequested()) {
|
||||
this->WakeupThread(t);
|
||||
return false;
|
||||
}
|
||||
this->wait_list.push_back(*t);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void WakeupThread(KThread *t) {
|
||||
MESOSPHERE_ASSERT(t->GetState() == KThread::ThreadState_Waiting);
|
||||
KScopedSchedulerLock sl;
|
||||
|
||||
/* Remove the thread from the queue. */
|
||||
this->Remove(t);
|
||||
this->wait_list.erase(this->wait_list.iterator_to(*t));
|
||||
|
||||
/* Mark the thread as no longer sleeping. */
|
||||
t->SetState(KThread::ThreadState_Runnable);
|
||||
|
@ -114,18 +60,24 @@ namespace ams::kern {
|
|||
}
|
||||
|
||||
KThread *WakeupFrontThread() {
|
||||
KThread *front = this->GetFront();
|
||||
if (front != nullptr) {
|
||||
MESOSPHERE_ASSERT(front->GetState() == KThread::ThreadState_Waiting);
|
||||
KScopedSchedulerLock sl;
|
||||
|
||||
if (this->wait_list.empty()) {
|
||||
return nullptr;
|
||||
} else {
|
||||
/* Remove the thread from the queue. */
|
||||
this->Dequeue();
|
||||
auto it = this->wait_list.begin();
|
||||
KThread *thread = std::addressof(*it);
|
||||
this->wait_list.erase(it);
|
||||
|
||||
MESOSPHERE_ASSERT(thread->GetState() == KThread::ThreadState_Waiting);
|
||||
|
||||
/* Mark the thread as no longer sleeping. */
|
||||
front->SetState(KThread::ThreadState_Runnable);
|
||||
front->SetSleepingQueue(nullptr);
|
||||
thread->SetState(KThread::ThreadState_Runnable);
|
||||
thread->SetSleepingQueue(nullptr);
|
||||
|
||||
return thread;
|
||||
}
|
||||
return front;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -22,45 +22,13 @@ namespace ams::kern {
|
|||
|
||||
class KWaitObject : public KTimerTask {
|
||||
private:
|
||||
using Entry = KThread::QueueEntry;
|
||||
private:
|
||||
Entry root;
|
||||
KThread::WaiterList wait_list;
|
||||
bool timer_used;
|
||||
public:
|
||||
constexpr KWaitObject() : root(), timer_used() { /* ... */ }
|
||||
constexpr KWaitObject() : wait_list(), timer_used() { /* ... */ }
|
||||
|
||||
virtual void OnTimer() override;
|
||||
Result Synchronize(s64 timeout);
|
||||
private:
|
||||
constexpr ALWAYS_INLINE void Enqueue(KThread *add) {
|
||||
/* Get the entry associated with the added thread. */
|
||||
Entry &add_entry = add->GetSleepingQueueEntry();
|
||||
|
||||
/* Get the entry associated with the end of the queue. */
|
||||
KThread *tail = this->root.GetPrev();
|
||||
Entry &tail_entry = (tail != nullptr) ? tail->GetSleepingQueueEntry() : this->root;
|
||||
|
||||
/* Link the entries. */
|
||||
add_entry.SetPrev(tail);
|
||||
add_entry.SetNext(nullptr);
|
||||
tail_entry.SetNext(add);
|
||||
this->root.SetPrev(add);
|
||||
}
|
||||
|
||||
constexpr ALWAYS_INLINE void Remove(KThread *remove) {
|
||||
/* Get the entry associated with the thread. */
|
||||
Entry &remove_entry = remove->GetSleepingQueueEntry();
|
||||
|
||||
/* Get the entries associated with next and prev. */
|
||||
KThread *prev = remove_entry.GetPrev();
|
||||
KThread *next = remove_entry.GetNext();
|
||||
Entry &prev_entry = (prev != nullptr) ? prev->GetSleepingQueueEntry() : this->root;
|
||||
Entry &next_entry = (next != nullptr) ? next->GetSleepingQueueEntry() : this->root;
|
||||
|
||||
/* Unlink. */
|
||||
prev_entry.SetNext(next);
|
||||
next_entry.SetPrev(prev);
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
|
|
|
@ -26,7 +26,7 @@ namespace ams::kern::arch::arm64 {
|
|||
/* Send KDebug event for this thread's creation. */
|
||||
{
|
||||
KScopedInterruptEnable ei;
|
||||
KDebug::OnDebugEvent(ams::svc::DebugEvent_CreateThread, GetCurrentThread().GetId(), GetInteger(GetCurrentThread().GetThreadLocalRegionAddress()), GetCurrentThread().GetEntrypoint());
|
||||
KDebug::OnDebugEvent(ams::svc::DebugEvent_CreateThread, GetCurrentThread().GetId(), GetInteger(GetCurrentThread().GetThreadLocalRegionAddress()));
|
||||
}
|
||||
|
||||
/* Handle any pending dpc. */
|
||||
|
|
|
@ -327,7 +327,7 @@ namespace ams::kern {
|
|||
it->SetDebugAttached();
|
||||
|
||||
/* Send the event. */
|
||||
this->PushDebugEvent(ams::svc::DebugEvent_CreateThread, it->GetId(), GetInteger(it->GetThreadLocalRegionAddress()), it->GetEntrypoint());
|
||||
this->PushDebugEvent(ams::svc::DebugEvent_CreateThread, it->GetId(), GetInteger(it->GetThreadLocalRegionAddress()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -682,7 +682,6 @@ namespace ams::kern {
|
|||
/* Set the thread creation info. */
|
||||
info->info.create_thread.thread_id = param0;
|
||||
info->info.create_thread.tls_address = param1;
|
||||
info->info.create_thread.entrypoint = param2;
|
||||
}
|
||||
break;
|
||||
case ams::svc::DebugEvent_ExitProcess:
|
||||
|
@ -842,7 +841,6 @@ namespace ams::kern {
|
|||
{
|
||||
out->info.create_thread.thread_id = info->info.create_thread.thread_id;
|
||||
out->info.create_thread.tls_address = info->info.create_thread.tls_address;
|
||||
out->info.create_thread.entrypoint = info->info.create_thread.entrypoint;
|
||||
}
|
||||
break;
|
||||
case ams::svc::DebugEvent_ExitProcess:
|
||||
|
|
|
@ -74,7 +74,7 @@ namespace ams::kern {
|
|||
/* If we can reply, do so. */
|
||||
if (!this->current_request->IsTerminationRequested()) {
|
||||
MESOSPHERE_ASSERT(this->current_request->GetState() == KThread::ThreadState_Waiting);
|
||||
MESOSPHERE_ASSERT(this->current_request == this->request_queue.GetFront());
|
||||
MESOSPHERE_ASSERT(this->request_queue.begin() != this->request_queue.end() && this->current_request == std::addressof(*this->request_queue.begin()));
|
||||
std::memcpy(this->current_request->GetLightSessionData(), server_thread->GetLightSessionData(), KLightSession::DataSize);
|
||||
this->request_queue.WakeupThread(this->current_request);
|
||||
}
|
||||
|
@ -110,8 +110,8 @@ namespace ams::kern {
|
|||
R_UNLESS(!this->parent->IsServerClosed(), svc::ResultSessionClosed());
|
||||
|
||||
/* If we have a request available, use it. */
|
||||
if (this->current_request == nullptr && this->request_queue.IsEmpty()) {
|
||||
this->current_request = this->request_queue.GetFront();
|
||||
if (this->current_request == nullptr && !this->request_queue.IsEmpty()) {
|
||||
this->current_request = std::addressof(*this->request_queue.begin());
|
||||
this->current_request->Open();
|
||||
this->server_thread = server_thread;
|
||||
break;
|
||||
|
@ -148,7 +148,7 @@ namespace ams::kern {
|
|||
/* Reply to the current request. */
|
||||
if (!this->current_request->IsTerminationRequested()) {
|
||||
MESOSPHERE_ASSERT(this->current_request->GetState() == KThread::ThreadState_Waiting);
|
||||
MESOSPHERE_ASSERT(this->current_request == this->request_queue.GetFront());
|
||||
MESOSPHERE_ASSERT(this->request_queue.begin() != this->request_queue.end() && this->current_request == std::addressof(*this->request_queue.begin()));
|
||||
this->request_queue.WakeupThread(this->current_request);
|
||||
this->current_request->SetSyncedObject(nullptr, svc::ResultSessionClosed());
|
||||
}
|
||||
|
|
|
@ -98,7 +98,6 @@ namespace ams::kern {
|
|||
|
||||
/* Set sync booleans. */
|
||||
this->signaled = false;
|
||||
this->ipc_cancelled = false;
|
||||
this->termination_requested = false;
|
||||
this->wait_cancelled = false;
|
||||
this->cancellable = false;
|
||||
|
@ -119,7 +118,6 @@ namespace ams::kern {
|
|||
this->waiting_lock = nullptr;
|
||||
|
||||
/* Initialize sleeping queue. */
|
||||
this->sleeping_queue_entry.Initialize();
|
||||
this->sleeping_queue = nullptr;
|
||||
|
||||
/* Set suspend flags. */
|
||||
|
@ -141,7 +139,6 @@ namespace ams::kern {
|
|||
|
||||
/* We have no waiters, but we do have an entrypoint. */
|
||||
this->num_kernel_waiters = 0;
|
||||
this->entrypoint = reinterpret_cast<uintptr_t>(func);
|
||||
|
||||
/* Set our current core id. */
|
||||
this->current_core_id = core;
|
||||
|
@ -172,7 +169,7 @@ namespace ams::kern {
|
|||
const bool is_64_bit = this->parent ? this->parent->Is64Bit() : IsDefault64Bit;
|
||||
const bool is_user = (type == ThreadType_User);
|
||||
const bool is_main = (type == ThreadType_Main);
|
||||
this->thread_context.Initialize(this->entrypoint, reinterpret_cast<uintptr_t>(this->GetStackTop()), GetInteger(user_stack_top), arg, is_user, is_64_bit, is_main);
|
||||
this->thread_context.Initialize(reinterpret_cast<uintptr_t>(func), reinterpret_cast<uintptr_t>(this->GetStackTop()), GetInteger(user_stack_top), arg, is_user, is_64_bit, is_main);
|
||||
|
||||
/* Setup the stack parameters. */
|
||||
StackParameters &sp = this->GetStackParameters();
|
||||
|
|
|
@ -21,19 +21,8 @@ namespace ams::kern {
|
|||
MESOSPHERE_ASSERT(KScheduler::IsSchedulerLockedByCurrentThread());
|
||||
|
||||
/* Wake up all the waiting threads. */
|
||||
Entry *entry = std::addressof(this->root);
|
||||
while (true) {
|
||||
/* Get the next thread. */
|
||||
KThread *thread = entry->GetNext();
|
||||
if (thread == nullptr) {
|
||||
break;
|
||||
}
|
||||
|
||||
/* Wake it up. */
|
||||
thread->Wakeup();
|
||||
|
||||
/* Advance. */
|
||||
entry = std::addressof(thread->GetSleepingQueueEntry());
|
||||
for (KThread &thread : this->wait_list) {
|
||||
thread.Wakeup();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -73,7 +62,7 @@ namespace ams::kern {
|
|||
this->OnTimer();
|
||||
} else {
|
||||
/* Otherwise, sleep until the timeout occurs. */
|
||||
this->Enqueue(cur_thread);
|
||||
this->wait_list.push_back(GetCurrentThread());
|
||||
cur_thread->SetState(KThread::ThreadState_Waiting);
|
||||
cur_thread->SetSyncedObject(nullptr, svc::ResultTimedOut());
|
||||
}
|
||||
|
@ -93,7 +82,7 @@ namespace ams::kern {
|
|||
|
||||
/* Remove the thread from our queue. */
|
||||
if (timeout != 0) {
|
||||
this->Remove(cur_thread);
|
||||
this->wait_list.erase(this->wait_list.iterator_to(GetCurrentThread()));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -31,7 +31,7 @@ namespace ams::svc {
|
|||
struct DebugInfoCreateThread {
|
||||
u64 thread_id;
|
||||
u64 tls_address;
|
||||
u64 entrypoint;
|
||||
/* Removed in 11.0.0 u64 entrypoint; */
|
||||
};
|
||||
|
||||
struct DebugInfoExitProcess {
|
||||
|
|
Loading…
Reference in a new issue