From 680148f2b34add22f7b97f7810206a807c26d44e Mon Sep 17 00:00:00 2001 From: Jiankun Yu Date: Sat, 3 May 2025 10:51:13 -0700 Subject: [PATCH 1/6] Attempt to expand listener to multi-threaded This commit resolves issue #1312. Listener now supports multiple "acceptor" threads handling new client socket connection. In http this has no benefit, as there is only one `accept()` syscall involved. However, if the server is armed with ssl, this significantly increases the server performance regarding tps. The single server thread impedes the tps in two ways: 1. Regular tcp handshake is "non-blocking" to server, as long as server sends the syn+ack frame, server side is considered established, the `accept()` returns. Whereas with ssl, the server needs to send the its cert and wait for client's response, this blocks the `SSL_accept()` call until the handshake completes. If the client comes from a remote data center with large latency, the latency contributes to the blocking of listener thread, resulting very low tps. 2. The ssl handshake involves cryptographic arithmetic, which is CPU bound. Even if client to server latency can be ignored, one listening thread is only able to handle ~1000 tps on a Intel Xeron core @2.1GHz. In this commit, we add two more options to Endpoint::Options that specifies the number of "acceptor" threads and the thread name, Listener::runThreaded() consumes them and initializes this number of threads. To make every thread has a equal chance to receive epoll events, the lock operation is removed before calling `poller.poll()`, there are only two fds registered to Listener's poller, the listen_fd and shutdownFd, there is no scenario regarding fd being removed while being handled. --- include/pistache/config.h | 1 + include/pistache/endpoint.h | 6 ++ include/pistache/listener.h | 15 ++-- src/server/endpoint.cc | 19 ++++- src/server/listener.cc | 135 ++++++++++++++++++++++++++++-------- version.txt | 2 +- 6 files changed, 144 insertions(+), 34 deletions(-) diff --git a/include/pistache/config.h b/include/pistache/config.h index 4ef164610..d445036a0 100644 --- a/include/pistache/config.h +++ b/include/pistache/config.h @@ -18,6 +18,7 @@ namespace Pistache::Const static constexpr size_t MaxBacklog = 128; static constexpr size_t MaxEvents = 1024; static constexpr size_t MaxBuffer = 4096; + static constexpr size_t DefaultAcceptors = 1; static constexpr size_t DefaultWorkers = 1; static constexpr size_t DefaultTimerPoolSize = 128; diff --git a/include/pistache/endpoint.h b/include/pistache/endpoint.h index b63a7a49b..eaf186a1b 100644 --- a/include/pistache/endpoint.h +++ b/include/pistache/endpoint.h @@ -29,6 +29,8 @@ namespace Pistache::Http { friend class Endpoint; + Options& acceptThreads(int val); + Options& acceptThreadsName(const std::string& val); Options& threads(int val); Options& threadsName(const std::string& val); @@ -78,6 +80,10 @@ namespace Pistache::Http maxPayload(size_t val); private: + // Accept thread options + int acceptThreads_; + std::string acceptThreadName_; + // Thread options int threads_; std::string threadsName_; diff --git a/include/pistache/listener.h b/include/pistache/listener.h index c86604f03..7391a1e06 100644 --- a/include/pistache/listener.h +++ b/include/pistache/listener.h @@ -62,10 +62,12 @@ namespace Pistache::Tcp explicit Listener(const Address& address); void init(size_t workers, - Flags options = Flags(Options::None), - const std::string& workersName = "", - int backlog = Const::MaxBacklog, - PISTACHE_STRING_LOGGER_T logger = PISTACHE_NULL_STRING_LOGGER); + Flags options = Flags(Options::None), + const std::string& workersName = "", + int backlog = Const::MaxBacklog, + size_t acceptors = Const::DefaultAcceptors, + const std::string& acceptorsName = "", + PISTACHE_STRING_LOGGER_T logger = PISTACHE_NULL_STRING_LOGGER); void setTransportFactory(TransportFactory factory); void setHandler(const std::shared_ptr& handler); @@ -105,6 +107,10 @@ namespace Pistache::Tcp Flags options_; std::thread acceptThread; + size_t acceptors_ = Const::DefaultAcceptors; + std::string acceptorsName_; + std::vector acceptWorkers; + size_t workers_ = Const::DefaultWorkers; std::string workersName_; std::shared_ptr handler_; @@ -117,6 +123,7 @@ namespace Pistache::Tcp TransportFactory defaultTransportFactory() const; bool bindListener(const struct addrinfo* addr); + void acceptWorkerFn(); void handleNewConnection(); em_socket_t acceptConnection(struct sockaddr_storage& peer_addr) const; diff --git a/src/server/endpoint.cc b/src/server/endpoint.cc index 7510e25ab..a28279400 100644 --- a/src/server/endpoint.cc +++ b/src/server/endpoint.cc @@ -251,7 +251,8 @@ namespace Pistache::Http } Endpoint::Options::Options() - : threads_(1) + : acceptThreads_(1) + , threads_(1) , flags_() , backlog_(Const::MaxBacklog) , maxRequestSize_(Const::DefaultMaxRequestSize) @@ -264,6 +265,18 @@ namespace Pistache::Http , sslHandshakeTimeout_(Const::DefaultSSLHandshakeTimeout) { } + Endpoint::Options& Endpoint::Options::acceptThreads(int val) + { + acceptThreads_ = val; + return *this; + } + + Endpoint::Options& Endpoint::Options::acceptThreadsName(const std::string& val) + { + acceptThreadName_ = val; + return *this; + } + Endpoint::Options& Endpoint::Options::threads(int val) { threads_ = val; @@ -319,7 +332,9 @@ namespace Pistache::Http void Endpoint::init(const Endpoint::Options& options) { - listener.init(options.threads_, options.flags_, options.threadsName_, options.backlog_); + listener.init(options.threads_, options.flags_, options.threadsName_, options.backlog_, + options.acceptThreads_, options.acceptThreadName_); + listener.setTransportFactory([this, options] { if (!handler_) throw std::runtime_error("Must call setHandler()"); diff --git a/src/server/listener.cc b/src/server/listener.cc index f38d11b44..cb6556353 100644 --- a/src/server/listener.cc +++ b/src/server/listener.cc @@ -306,6 +306,7 @@ namespace Pistache::Tcp void Listener::init(size_t workers, Flags options, const std::string& workersName, int backlog, + size_t acceptors, const std::string& acceptorsName, PISTACHE_STRING_LOGGER_T logger) { if (workers > hardware_concurrency()) @@ -313,12 +314,14 @@ namespace Pistache::Tcp // Log::warning() << "More workers than available cores" } - options_ = options; - backlog_ = backlog; - useSSL_ = false; - workers_ = workers; - workersName_ = workersName; - logger_ = logger; + options_ = options; + backlog_ = backlog; + useSSL_ = false; + workers_ = workers; + workersName_ = workersName; + acceptors_ = acceptors; + acceptorsName_ = acceptorsName; + logger_ = logger; } void Listener::setTransportFactory(TransportFactory factory) @@ -555,14 +558,101 @@ namespace Pistache::Tcp shutdownFd.bind(poller); reactor_->run(); + PS_LOG_DEBUG("shutdownFd.bind done"); + + for (size_t i = 0; i < acceptors_; i++) + { + acceptWorkers.emplace_back([this]() { + PS_TIMEDBG_START; + + if (!acceptorsName_.empty()) + { + PS_LOG_DEBUG("Setting thread name/description"); +#ifdef _IS_WINDOWS + const std::string threads_name(acceptorsName_.substr(0, 15)); + const std::wstring temp(threads_name.begin(), + threads_name.end()); + const LPCWSTR wide_threads_name = temp.c_str(); + + HRESULT hr = E_NOTIMPL; +#ifdef __MINGW32__ + TSetThreadDescription set_thread_description_ptr = getSetThreadDescriptionPtr(); + if (set_thread_description_ptr) + { + hr = set_thread_description_ptr( + GetCurrentThread(), wide_threads_name); + } +#else + hr = SetThreadDescription(GetCurrentThread(), + wide_threads_name); +#endif + if ((FAILED(hr)) && (!lLoggedSetThreadDescriptionFail)) + { + lLoggedSetThreadDescriptionFail = true; + // Log it just once + PS_LOG_INFO("SetThreadDescription failed"); + } +#else +#if defined _IS_BSD && !defined __NetBSD__ + pthread_set_name_np( +#else + pthread_setname_np( +#endif +#ifndef __APPLE__ + // Apple's macOS version of pthread_setname_np + // takes only "const char * name" as parm + // (Nov/2023), and assumes that the thread is the + // calling thread. Note that pthread_self returns + // calling thread in Linux, so this amounts to + // the same thing in the end + // It appears older FreeBSD (2003 ?) also behaves + // as per macOS, while newer FreeBSD (2021 ?) + // behaves as per Linux + pthread_self(), +#endif +#ifdef __NetBSD__ + "%s", // NetBSD has 3 parms for pthread_setname_np + (void*)/*cast away const for NetBSD*/ +#endif + acceptorsName_.substr(0, 15) + .c_str()); +#endif // of ifdef _IS_WINDOWS... else... + } + PS_LOG_DEBUG("Calling this->run()"); + this->acceptWorkerFn(); + }); + } + + for (auto& t : acceptWorkers) + t.join(); + } + + void Listener::runThreaded() + { + PS_TIMEDBG_START; + + shutdownFd.bind(poller); + PS_LOG_DEBUG("shutdownFd.bind done"); + + acceptThread = std::thread([this]() { + PS_TIMEDBG_START; + this->run(); + }); + } + + void Listener::acceptWorkerFn() + { for (;;) { - { // encapsulate l_guard(poller.reg_unreg_mutex_) - // See comment in class Epoll regarding reg_unreg_mutex_ + { PS_TIMEDBG_START; - std::mutex& poller_reg_unreg_mutex(poller.reg_unreg_mutex_); - GUARD_AND_DBG_LOG(poller_reg_unreg_mutex); + // poller only has 2 fds added/removed in its life time: + // 1. The listening socket + // 2. The shutdown fd + // There won't be any case a fd being processed is removed + // from poller, we don't need this lock + // std::mutex& poller_reg_unreg_mutex(poller.reg_unreg_mutex_); std::vector events; int ready_fds = poller.poll(events); @@ -572,6 +662,7 @@ namespace Pistache::Tcp PS_LOG_DEBUG("Polling failed"); throw Error::system("Polling"); } + for (const auto& event : events) { if (event.tag == shutdownFd.tag()) @@ -605,26 +696,17 @@ namespace Pistache::Tcp } } - void Listener::runThreaded() - { - PS_TIMEDBG_START; - - shutdownFd.bind(poller); - PS_LOG_DEBUG("shutdownFd.bind done"); - - acceptThread = std::thread([this]() { - PS_TIMEDBG_START; - this->run(); - }); - } - void Listener::shutdown() { if (shutdownFd.isBound()) { PS_TIMEDBG_START_CURLY; - shutdownFd.notify(); + for (size_t i = 0; i < acceptors_; i++) + { + shutdownFd.notify(); + std::this_thread::yield(); + } } if (reactor_) @@ -731,9 +813,8 @@ namespace Pistache::Tcp #ifdef _IS_WINDOWS - unsigned long int timeout_in_ms = - static_cast( - std::chrono::duration_cast< + unsigned long int timeout_in_ms = static_cast( + std::chrono::duration_cast< std::chrono::milliseconds>(sslHandshakeTimeout_) .count()); diff --git a/version.txt b/version.txt index 7f67c6c45..b1a651a17 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.5.6.20250328 +0.5.6.20250504 From 27490dd908366e582824ba649798347ce15e15bc Mon Sep 17 00:00:00 2001 From: Jiankun Yu Date: Mon, 5 May 2025 19:34:09 -0700 Subject: [PATCH 2/6] code style: fix alignment issue --- include/pistache/config.h | 8 ++++---- version.txt | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/include/pistache/config.h b/include/pistache/config.h index d445036a0..faaee662e 100644 --- a/include/pistache/config.h +++ b/include/pistache/config.h @@ -15,11 +15,11 @@ // Allow compile-time overload namespace Pistache::Const { - static constexpr size_t MaxBacklog = 128; - static constexpr size_t MaxEvents = 1024; - static constexpr size_t MaxBuffer = 4096; + static constexpr size_t MaxBacklog = 128; + static constexpr size_t MaxEvents = 1024; + static constexpr size_t MaxBuffer = 4096; static constexpr size_t DefaultAcceptors = 1; - static constexpr size_t DefaultWorkers = 1; + static constexpr size_t DefaultWorkers = 1; static constexpr size_t DefaultTimerPoolSize = 128; diff --git a/version.txt b/version.txt index b1a651a17..185b6e71b 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.5.6.20250504 +0.5.6.20250505 From 210743d99dd004f450f8dc0043e3e2eb659dc310 Mon Sep 17 00:00:00 2001 From: Jiankun Yu Date: Tue, 6 May 2025 10:12:46 -0700 Subject: [PATCH 3/6] issue-1312: bump patch version for adding new interfaces --- version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.txt b/version.txt index 185b6e71b..c80c93e4b 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.5.6.20250505 +0.5.7.20250506 From 4f52848e62175ef3c06bd9056aa6a38f8c932b93 Mon Sep 17 00:00:00 2001 From: Jiankun Yu Date: Tue, 6 May 2025 10:35:00 -0700 Subject: [PATCH 4/6] issue-1312: bump minor version Should bump minor version as this PR addes new interfaces. --- version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.txt b/version.txt index c80c93e4b..f2924a34f 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.5.7.20250506 +0.6.0.20250506 From 299cc5eb3678654e6f12da8d0734ab6b79bc36ce Mon Sep 17 00:00:00 2001 From: Jiankun Yu Date: Tue, 6 May 2025 19:52:00 -0700 Subject: [PATCH 5/6] issue-1312: try to resolve windows build error --- src/common/reactor.cc | 55 ++++++++++++++++--------------------- src/server/listener.cc | 62 ++++++++++++++++++++++++++++++++++++++++++ version.txt | 2 +- 3 files changed, 86 insertions(+), 33 deletions(-) diff --git a/src/common/reactor.cc b/src/common/reactor.cc index 1645f8499..539ed6c9c 100644 --- a/src/common/reactor.cc +++ b/src/common/reactor.cc @@ -40,22 +40,22 @@ static std::atomic_bool lLoggedSetThreadDescriptionFail = false; #ifdef __MINGW32__ -#include #include // for GetProcAddress and GetModuleHandleA -typedef HRESULT (WINAPI *TSetThreadDescription)(HANDLE, PCWSTR); +#include +typedef HRESULT(WINAPI* TSetThreadDescription)(HANDLE, PCWSTR); static std::atomic_bool lSetThreadDescriptionLoaded = false; static std::mutex lSetThreadDescriptionLoadMutex; static TSetThreadDescription lSetThreadDescriptionPtr = nullptr; -TSetThreadDescription getSetThreadDescriptionPtr() +static TSetThreadDescription getSetThreadDescriptionPtr() { if (lSetThreadDescriptionLoaded) - return(lSetThreadDescriptionPtr); + return (lSetThreadDescriptionPtr); GUARD_AND_DBG_LOG(lSetThreadDescriptionLoadMutex); if (lSetThreadDescriptionLoaded) - return(lSetThreadDescriptionPtr); + return (lSetThreadDescriptionPtr); HMODULE hKernelBase = GetModuleHandleA("KernelBase.dll"); @@ -64,18 +64,15 @@ TSetThreadDescription getSetThreadDescriptionPtr() PS_LOG_WARNING( "Failed to get KernelBase.dll for SetThreadDescription"); lSetThreadDescriptionLoaded = true; - return(nullptr); + return (nullptr); } - FARPROC set_thread_desc_fpptr = - GetProcAddress(hKernelBase, "SetThreadDescription"); + FARPROC set_thread_desc_fpptr = GetProcAddress(hKernelBase, "SetThreadDescription"); // We do the cast in two steps, otherwise mingw-gcc complains about // incompatible types - void * set_thread_desc_vptr = - reinterpret_cast(set_thread_desc_fpptr); - lSetThreadDescriptionPtr = - reinterpret_cast(set_thread_desc_vptr); + void* set_thread_desc_vptr = reinterpret_cast(set_thread_desc_fpptr); + lSetThreadDescriptionPtr = reinterpret_cast(set_thread_desc_vptr); lSetThreadDescriptionLoaded = true; if (!lSetThreadDescriptionPtr) @@ -83,7 +80,7 @@ TSetThreadDescription getSetThreadDescriptionPtr() PS_LOG_WARNING( "Failed to get SetThreadDescription from KernelBase.dll"); } - return(lSetThreadDescriptionPtr); + return (lSetThreadDescriptionPtr); } #endif // of ifdef __MINGW32__ #endif // of ifdef _IS_WINDOWS @@ -299,17 +296,16 @@ namespace Pistache::Aio break; case 0: break; - default: - { - if (shutdown_) - return; + default: { + if (shutdown_) + return; - GUARD_AND_DBG_LOG(shutdown_mutex_); - if (shutdown_) - return; + GUARD_AND_DBG_LOG(shutdown_mutex_); + if (shutdown_) + return; - handleFds(std::move(events)); - } + handleFds(std::move(events)); + } } } } @@ -454,11 +450,8 @@ namespace Pistache::Aio // encode the index of the handler is that in the fast path, we // won't need to shift the value to retrieve the fd if there is // only one handler as all the bits will already be set to 0. - auto encodedValue = - (index << HandlerShift) | - PS_FD_CAST_TO_UNUM(uint64_t, static_cast(value)); - Polling::TagValue encodedValueTV = - static_cast(PS_NUM_CAST_TO_FD(encodedValue)); + auto encodedValue = (index << HandlerShift) | PS_FD_CAST_TO_UNUM(uint64_t, static_cast(value)); + Polling::TagValue encodedValueTV = static_cast(PS_NUM_CAST_TO_FD(encodedValue)); return Polling::Tag(encodedValueTV); } @@ -468,8 +461,7 @@ namespace Pistache::Aio auto value = tag.valueU64(); size_t index = value >> HandlerShift; uint64_t maskedValue = value & DataMask; - Polling::TagValue maskedValueTV = - static_cast(PS_NUM_CAST_TO_FD(maskedValue)); + Polling::TagValue maskedValueTV = static_cast(PS_NUM_CAST_TO_FD(maskedValue)); return std::make_pair(index, maskedValueTV); } @@ -720,13 +712,12 @@ namespace Pistache::Aio #ifdef _IS_WINDOWS const std::string threads_name(threadsName_.substr(0, 15)); const std::wstring temp(threads_name.begin(), - threads_name.end()); + threads_name.end()); const LPCWSTR wide_threads_name = temp.c_str(); HRESULT hr = E_NOTIMPL; #ifdef __MINGW32__ - TSetThreadDescription set_thread_description_ptr = - getSetThreadDescriptionPtr(); + TSetThreadDescription set_thread_description_ptr = getSetThreadDescriptionPtr(); if (set_thread_description_ptr) { hr = set_thread_description_ptr( diff --git a/src/server/listener.cc b/src/server/listener.cc index cb6556353..6def4bf20 100644 --- a/src/server/listener.cc +++ b/src/server/listener.cc @@ -59,6 +59,68 @@ #endif /* PISTACHE_USE_SSL */ +#ifdef _IS_BSD +// For pthread_set_name_np +#include PST_THREAD_HDR +#ifndef __NetBSD__ +#include +#endif +#endif + +#ifdef _IS_WINDOWS +#include // Needed for PST_THREAD_HDR (processthreadsapi.h) +#include PST_THREAD_HDR // for SetThreadDescription +#endif + +#ifdef _IS_WINDOWS +static std::atomic_bool lLoggedSetThreadDescriptionFail = false; +#ifdef __MINGW32__ + +#include // for GetProcAddress and GetModuleHandleA +#include +typedef HRESULT(WINAPI* TSetThreadDescription)(HANDLE, PCWSTR); + +static std::atomic_bool lSetThreadDescriptionLoaded = false; +static std::mutex lSetThreadDescriptionLoadMutex; +static TSetThreadDescription lSetThreadDescriptionPtr = nullptr; + +static TSetThreadDescription getSetThreadDescriptionPtr() +{ + if (lSetThreadDescriptionLoaded) + return (lSetThreadDescriptionPtr); + + GUARD_AND_DBG_LOG(lSetThreadDescriptionLoadMutex); + if (lSetThreadDescriptionLoaded) + return (lSetThreadDescriptionPtr); + + HMODULE hKernelBase = GetModuleHandleA("KernelBase.dll"); + + if (!hKernelBase) + { + PS_LOG_WARNING( + "Failed to get KernelBase.dll for SetThreadDescription"); + lSetThreadDescriptionLoaded = true; + return (nullptr); + } + + FARPROC set_thread_desc_fpptr = GetProcAddress(hKernelBase, "SetThreadDescription"); + + // We do the cast in two steps, otherwise mingw-gcc complains about + // incompatible types + void* set_thread_desc_vptr = reinterpret_cast(set_thread_desc_fpptr); + lSetThreadDescriptionPtr = reinterpret_cast(set_thread_desc_vptr); + + lSetThreadDescriptionLoaded = true; + if (!lSetThreadDescriptionPtr) + { + PS_LOG_WARNING( + "Failed to get SetThreadDescription from KernelBase.dll"); + } + return (lSetThreadDescriptionPtr); +} +#endif // of ifdef __MINGW32__ +#endif // of ifdef _IS_WINDOWS + using namespace std::chrono_literals; namespace Pistache::Tcp diff --git a/version.txt b/version.txt index f2924a34f..129befabb 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.6.0.20250506 +0.6.0.20250507 From 10f359811e59e7a5bbce1aaed7a10e7c5bf5cd40 Mon Sep 17 00:00:00 2001 From: Jiankun Yu Date: Thu, 8 May 2025 17:32:45 -0700 Subject: [PATCH 6/6] issue-1312: more robust Listener::shutdown() The shutdownFd is used to tell the acceptor worker thread to exit, when there is multiple threads, the current implementation is not enough to ensure every worker has a chance to get the event, if it's busy doing something, it will miss the notification. Without changing the NotifyFd interface, the guaranteed way is to do a loop notification and check the number of alive worker threads, keep notifying them until all the threads are done. --- include/pistache/listener.h | 1 + src/server/listener.cc | 15 ++++++++++++--- version.txt | 2 +- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/include/pistache/listener.h b/include/pistache/listener.h index 7391a1e06..9d099f549 100644 --- a/include/pistache/listener.h +++ b/include/pistache/listener.h @@ -110,6 +110,7 @@ namespace Pistache::Tcp size_t acceptors_ = Const::DefaultAcceptors; std::string acceptorsName_; std::vector acceptWorkers; + std::atomic_int activeAcceptors { 0 }; size_t workers_ = Const::DefaultWorkers; std::string workersName_; diff --git a/src/server/listener.cc b/src/server/listener.cc index 6def4bf20..3e6f1add3 100644 --- a/src/server/listener.cc +++ b/src/server/listener.cc @@ -683,6 +683,7 @@ namespace Pistache::Tcp PS_LOG_DEBUG("Calling this->run()"); this->acceptWorkerFn(); }); + ++activeAcceptors; } for (auto& t : acceptWorkers) @@ -728,7 +729,10 @@ namespace Pistache::Tcp for (const auto& event : events) { if (event.tag == shutdownFd.tag()) + { + --activeAcceptors; return; + } if (event.flags.hasFlag(Polling::NotifyOn::Read)) { @@ -749,6 +753,7 @@ namespace Pistache::Tcp PS_LOG_WARNING("Server error"); PISTACHE_LOG_STRING_FATAL( logger_, "Server error: " << ex.what()); + --activeAcceptors; throw; } } @@ -764,10 +769,14 @@ namespace Pistache::Tcp { PS_TIMEDBG_START_CURLY; - for (size_t i = 0; i < acceptors_; i++) + while (activeAcceptors) { - shutdownFd.notify(); - std::this_thread::yield(); + for (size_t i = 0; i < activeAcceptors; i++) + { + shutdownFd.notify(); + std::this_thread::yield(); + } + std::this_thread::sleep_for(std::chrono::milliseconds(1)); } } diff --git a/version.txt b/version.txt index 129befabb..6a9f81f6b 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.6.0.20250507 +0.6.0.20250508