diff --git a/IntelPresentMon/AppCef/CMakeLists.txt b/IntelPresentMon/AppCef/CMakeLists.txt index b2d4e3282..9cf00b4ee 100644 --- a/IntelPresentMon/AppCef/CMakeLists.txt +++ b/IntelPresentMon/AppCef/CMakeLists.txt @@ -15,6 +15,8 @@ add_executable( source/util/FontEnumerator.cpp source/util/IpcInvocationManager.cpp source/util/KernelActionRegistration.cpp + source/util/KernelLink.cpp + source/util/KernelRelay.cpp source/util/LogSetup.cpp source/util/PathSanitaryCheck.cpp source/util/SignalManager.cpp diff --git a/IntelPresentMon/AppCef/CefNano.vcxproj b/IntelPresentMon/AppCef/CefNano.vcxproj index 5cd52fa55..1df05bbb0 100644 --- a/IntelPresentMon/AppCef/CefNano.vcxproj +++ b/IntelPresentMon/AppCef/CefNano.vcxproj @@ -25,6 +25,8 @@ + + @@ -52,6 +54,8 @@ + + diff --git a/IntelPresentMon/AppCef/source/DataBindAccessor.cpp b/IntelPresentMon/AppCef/source/DataBindAccessor.cpp index 296b69535..6f9ef8f41 100644 --- a/IntelPresentMon/AppCef/source/DataBindAccessor.cpp +++ b/IntelPresentMon/AppCef/source/DataBindAccessor.cpp @@ -2,16 +2,6 @@ // SPDX-License-Identifier: MIT #include "DataBindAccessor.h" #include "util/Logging.h" -#include -#include -#include -#include "util/CefValues.h" -#include -#include "util/cact/TargetLostAction.h" -#include "util/cact/OverlayDiedAction.h" -#include "util/cact/PresentmonInitFailedAction.h" -#include "util/cact/StalePidAction.h" -#include "util/SignalManager.h" namespace p2c::client::cef diff --git a/IntelPresentMon/AppCef/source/NanoCefBrowserClient.cpp b/IntelPresentMon/AppCef/source/NanoCefBrowserClient.cpp index 070beb83a..2a469f394 100644 --- a/IntelPresentMon/AppCef/source/NanoCefBrowserClient.cpp +++ b/IntelPresentMon/AppCef/source/NanoCefBrowserClient.cpp @@ -8,6 +8,7 @@ #include #include "util/AsyncEndpointManager.h" #include "NanoCefProcessHandler.h" +#include "util/KernelRelay.h" // implemented in winmain.cpp void AppQuitMessageLoop(); @@ -35,6 +36,11 @@ namespace p2c::client::cef pContextMenuHandler = new NullMenuHandler{}; } + void NanoCefBrowserClient::SetKernelCallRelay(util::kernel_relay::CallRelay* pRelay) + { + pKernelCallRelay = pRelay; + } + CefRefPtr NanoCefBrowserClient::GetBrowser() { return pBrowser; @@ -66,6 +72,9 @@ namespace p2c::client::cef bool NanoCefBrowserClient::OnProcessMessageReceived(CefRefPtr browser, CefRefPtr frame, CefProcessId source_process, CefRefPtr message) { + if (pKernelCallRelay && pKernelCallRelay->HandleMessage(*message, frame)) { + return true; + } if (message->GetName() == util::AsyncEndpointManager::GetDispatchMessageName()) { if (auto pEndpoint = endpoints.Find(message->GetArgumentList()->GetString(0))) diff --git a/IntelPresentMon/AppCef/source/NanoCefBrowserClient.h b/IntelPresentMon/AppCef/source/NanoCefBrowserClient.h index cfa0b5f87..a6c5f098f 100644 --- a/IntelPresentMon/AppCef/source/NanoCefBrowserClient.h +++ b/IntelPresentMon/AppCef/source/NanoCefBrowserClient.h @@ -8,6 +8,10 @@ #include #include "util/AsyncEndpointCollection.h" +namespace p2c::client::util::kernel_relay +{ + class CallRelay; +} namespace p2c::client::cef { @@ -18,6 +22,9 @@ namespace p2c::client::cef { public: NanoCefBrowserClient(); + // Kernel calls from the page are handed to this relay. Set before the browser is created, + // since only the page sends them; null leaves them unhandled. + void SetKernelCallRelay(util::kernel_relay::CallRelay* pRelay); CefRefPtr GetBrowser(); CefRefPtr GetLifeSpanHandler() override; CefRefPtr GetDisplayHandler() override; @@ -40,6 +47,7 @@ namespace p2c::client::cef CefRefPtr pContextMenuHandler; CefRefPtr pBrowser; util::AsyncEndpointCollection endpoints; + util::kernel_relay::CallRelay* pKernelCallRelay = nullptr; // Include the default reference counting implementation. IMPLEMENT_REFCOUNTING(NanoCefBrowserClient); diff --git a/IntelPresentMon/AppCef/source/NanoCefProcessHandler.cpp b/IntelPresentMon/AppCef/source/NanoCefProcessHandler.cpp index 1c8f340a0..ca05ab8ae 100644 --- a/IntelPresentMon/AppCef/source/NanoCefProcessHandler.cpp +++ b/IntelPresentMon/AppCef/source/NanoCefProcessHandler.cpp @@ -14,14 +14,26 @@ #include "util/AsyncEndpointManager.h" #include "util/CefValues.h" #include "util/CliOptions.h" +#include "util/KernelRelay.h" +#include using namespace pmon::util; -using namespace std::chrono_literals; namespace rn = std::ranges; namespace vi = std::views; namespace p2c::client::cef { + void NanoCefProcessHandler::OnBeforeCommandLineProcessing(const CefString& processType, CefRefPtr pCommandLine) + { + // An elevated kernel launches the UI at medium integrity with a token restricted from its + // own. Chromium reads that token as elevated, relaunches itself without the kernel pipe, + // and exits. Opt out only when we really are below high integrity, so a UI that did run + // elevated is still de-elevated by Chromium. + if (processType.empty() && ::pmon::util::win::WeAreBelowHighIntegrity()) { + pCommandLine->AppendSwitch("do-not-de-elevate"); + } + } + void NanoCefProcessHandler::OnContextInitialized() { #ifdef NDEBUG @@ -112,11 +124,14 @@ namespace p2c::client::cef void NanoCefProcessHandler::OnContextCreated(CefRefPtr browser, CefRefPtr frame, CefRefPtr context) { - std::this_thread::sleep_for(50ms); - pKernelWrapper->pClient = std::make_unique( - *util::cli::Options::Get().actName, util::cact::CefExecutionContext{.pSignalManager = &pKernelWrapper->signals} - ); - pKernelWrapper->pInvocationManager = std::make_unique(*pKernelWrapper->pClient); + // Only the main frame gets the kernel bridge. A subframe's context must not replace the + // invocation manager, which would drop the main frame's pending calls, and it has no + // business calling the kernel. A main-frame reload does start fresh: its old page is gone. + if (!frame->IsMain()) { + return; + } + // kernel calls are relayed through the browser process, which holds the kernel connection + pKernelWrapper->pInvocationManager = std::make_unique(); pAccessor = new DataBindAccessor{ pBrowser, pKernelWrapper.get() }; auto core = CefV8Value::CreateObject(nullptr, nullptr); @@ -138,11 +153,33 @@ namespace p2c::client::cef pBrowser = browser_; + // versions the kernel reported to the browser process when it opened its session + if (extra_info) { + namespace kr = util::kernel_relay; + auto& versions = pKernelWrapper->kernelVersions; + versions.serviceBuildId = extra_info->GetString(kr::kServiceBuildIdKey).ToString(); + versions.serviceBuildTime = extra_info->GetString(kr::kServiceBuildTimeKey).ToString(); + versions.serviceVersion = extra_info->GetString(kr::kServiceVersionKey).ToString(); + versions.middlewareApiVersion = extra_info->GetString(kr::kMiddlewareApiVersionKey).ToString(); + } + CefRenderProcessHandler::OnBrowserCreated(std::move(browser_), std::move(extra_info)); } bool NanoCefProcessHandler::OnProcessMessageReceived(CefRefPtr browser, CefRefPtr frame, CefProcessId source_process, CefRefPtr message) { + if (pKernelWrapper->pInvocationManager && pKernelWrapper->pInvocationManager->HandleReply(*message)) { + return true; + } + if (message->GetName() == util::kernel_relay::kSignalMessage) { + const auto args = message->GetArgumentList(); + std::optional arg; + if (args->GetType(1) == VTYPE_INT) { + arg = (uint32_t)args->GetInt(1); + } + pKernelWrapper->signals.SignalFromBrowser(args->GetString(0).ToString(), arg); + return true; + } if (message->GetName() == util::AsyncEndpointManager::GetResolveMessageName()) { pAccessor->ResolveAsyncEndpoint( diff --git a/IntelPresentMon/AppCef/source/NanoCefProcessHandler.h b/IntelPresentMon/AppCef/source/NanoCefProcessHandler.h index 82da332ad..c5fee62d2 100644 --- a/IntelPresentMon/AppCef/source/NanoCefProcessHandler.h +++ b/IntelPresentMon/AppCef/source/NanoCefProcessHandler.h @@ -15,6 +15,7 @@ namespace p2c::client::cef public CefBrowserProcessHandler { public: + void OnBeforeCommandLineProcessing(const CefString& processType, CefRefPtr pCommandLine) override; void OnContextInitialized() override; CefRefPtr GetBrowserProcessHandler() override; CefRefPtr GetRenderProcessHandler() override; diff --git a/IntelPresentMon/AppCef/source/util/ActionClientServer.cpp b/IntelPresentMon/AppCef/source/util/ActionClientServer.cpp index f3f9d7c5e..b020f429a 100644 --- a/IntelPresentMon/AppCef/source/util/ActionClientServer.cpp +++ b/IntelPresentMon/AppCef/source/util/ActionClientServer.cpp @@ -3,9 +3,9 @@ namespace p2c::client::util { - CefClient::CefClient(std::string pipeName, cact::CefExecutionContext context) + CefClient::CefClient(::pmon::util::win::Handle connectedPipe, cact::CefExecutionContext context) : - SymmetricActionClient{ std::move(pipeName), std::move(context) } + SymmetricActionClient{ std::move(connectedPipe), "kernel-ui", std::move(context) } { auto res = DispatchSync(kproc::kact::OpenSession::Params{ GetCurrentProcessId() }); serviceBuildId_ = std::move(res.serviceBuildId); diff --git a/IntelPresentMon/AppCef/source/util/ActionClientServer.h b/IntelPresentMon/AppCef/source/util/ActionClientServer.h index 489ebb534..aca6423eb 100644 --- a/IntelPresentMon/AppCef/source/util/ActionClientServer.h +++ b/IntelPresentMon/AppCef/source/util/ActionClientServer.h @@ -8,7 +8,8 @@ namespace p2c::client::util class CefClient : public act::SymmetricActionClient { public: - CefClient(std::string pipeName, cact::CefExecutionContext context = {}); + // connectedPipe: the client end of the pipe pair the kernel created for this UI + CefClient(::pmon::util::win::Handle connectedPipe, cact::CefExecutionContext context); const std::string& GetServiceBuildId() const; const std::string& GetServiceBuildTime() const; const std::string& GetServiceVersion() const; diff --git a/IntelPresentMon/AppCef/source/util/CliOptions.h b/IntelPresentMon/AppCef/source/util/CliOptions.h index 8b686c6f3..4e4cc039e 100644 --- a/IntelPresentMon/AppCef/source/util/CliOptions.h +++ b/IntelPresentMon/AppCef/source/util/CliOptions.h @@ -64,14 +64,15 @@ namespace p2c::client::util::cli private: Group gi_{ this, "Internal", "Internal options, do not supply manually" }; public: Option cefType{ this, "--type", "", "Type of the current chromium process" }; Option logPipeName{ this, "--p2c-log-pipe-name", "", "The postfix used to create the named pipe for logging source server" }; - Option actName{ this, "--p2c-act-name", "", "Base name for the named pipes connecting CEF and the overlay Kernel process" }; + Option actHandle{ this, "--p2c-act-handle", 0, "Inherited handle of the pipe connecting CEF and the overlay Kernel process" }; static constexpr const char* description = "PresentMon control UI"; static constexpr const char* name = "PresentMonUI.exe"; private: MutualExclusion excl_{ logDenyList, logAllowList }; - NoForward noForward_{ cefType, logPipeName, logVerboseModules }; + // actHandle is meaningful only in the browser process that inherited it + NoForward noForward_{ cefType, logPipeName, logVerboseModules, actHandle }; AllowExtras ext_{ this }; }; } diff --git a/IntelPresentMon/AppCef/source/util/IpcInvocationManager.cpp b/IntelPresentMon/AppCef/source/util/IpcInvocationManager.cpp index 0a8fe1eee..5600dde8e 100644 --- a/IntelPresentMon/AppCef/source/util/IpcInvocationManager.cpp +++ b/IntelPresentMon/AppCef/source/util/IpcInvocationManager.cpp @@ -1,9 +1,46 @@ -#pragma once #include "IpcInvocationManager.h" namespace p2c::client::util { - std::unordered_map, CefClient&)>> + std::unordered_map)>> IpcInvocationManager::dispatchBindings_; + int IpcInvocationManager::nextCallId_ = 0; + + bool IpcInvocationManager::HandleReply(CefProcessMessage& message) + { + CEF_REQUIRE_RENDERER_THREAD(); + if (message.GetName() != kernel_relay::kReplyMessage) { + return false; + } + const auto args = message.GetArgumentList(); + const auto callId = args->GetInt(0); + const auto i = pendingCalls_.find(callId); + if (i == pendingCalls_.end()) { + pmlog_dbg("Dropping kernel call reply for an unknown call").pmwatch(callId); + return true; + } + auto onReply = std::move(i->second); + pendingCalls_.erase(i); + // no binary value means the Response serialized to no bytes + onReply(args->GetBool(1), args->GetType(2) == VTYPE_BINARY ? args->GetBinary(2) : nullptr); + return true; + } + + void IpcInvocationManager::SendCall_(const std::string& action, CefRefPtr pParams, + CefRefPtr frame, ReplyHandler onReply) + { + CEF_REQUIRE_RENDERER_THREAD(); + const auto callId = nextCallId_++; + auto message = CefProcessMessage::Create(kernel_relay::kCallMessage); + const auto args = message->GetArgumentList(); + args->SetString(0, action); + args->SetInt(1, callId); + // null when Params serialized to no bytes, see kernel_relay::ToBinary + if (pParams) { + args->SetBinary(2, std::move(pParams)); + } + pendingCalls_.emplace(callId, std::move(onReply)); + frame->SendProcessMessage(PID_BROWSER, std::move(message)); + } } \ No newline at end of file diff --git a/IntelPresentMon/AppCef/source/util/IpcInvocationManager.h b/IntelPresentMon/AppCef/source/util/IpcInvocationManager.h index 24744d5fe..5b7e13e04 100644 --- a/IntelPresentMon/AppCef/source/util/IpcInvocationManager.h +++ b/IntelPresentMon/AppCef/source/util/IpcInvocationManager.h @@ -4,27 +4,24 @@ #include #include #include -#include -#include -#include #include #include #include -#include -#include -#include #include "AsyncEndpointCollection.h" -#include "ActionClientServer.h" +#include "KernelRelay.h" #include "V8Transfer.h" #include namespace p2c::client::util { - // this class exists to: + // Renderer: the page's calls to kernel actions. This class exists to: // A) bridge from key string to action // B) encode conversion from V8 to Params{} // C) encode conversion from Response{} to V8 + // The browser process owns the connection to the kernel, so each call is sent there as a + // kernel_relay::kCallMessage and resolved when its kReplyMessage comes back. Everything + // here runs on the renderer thread, so nothing is locked. class IpcInvocationManager { public: @@ -39,12 +36,14 @@ namespace p2c::client::util void DispatchInvocation(const std::string& key, CallbackContext&& ctx, CefRefPtr pArgs) { // TODO: explicit lookup and handle missing error - dispatchBindings_[key](std::move(ctx), std::move(pArgs), client_); + dispatchBindings_[key](*this, std::move(ctx), std::move(pArgs)); } bool HasHandler(const std::string& key) const { return dispatchBindings_.contains(key); } + // Handles a kernel_relay::kReplyMessage. False for any other message. + bool HandleReply(CefProcessMessage& message); template static void RegisterDispatchBinding() { @@ -52,30 +51,40 @@ namespace p2c::client::util using Response = typename A::Response; // register handler function for the endpoint identifer (e.g. SetCapture) that takes in V8 context and V8 action args // this handler will be called when the DBA receives an Execute call on the invokeEndpoint channel - dispatchBindings_[A::Identifier] = [](CallbackContext ctx, CefRefPtr pObj, CefClient& client) { - // convert the V8 args to the Params struct associated with the action A - // create a lambda which accepts the incoming result of this action and stores the V8 context - // lamba will be invoked after the response is received from the server, causing the response - // to be returned to the original V8 caller in js land - client.DispatchWithContinuation(FromV8(*pObj), [ctx = std::move(ctx)](Response&& res, std::exception_ptr pex) { - CefPostTask(TID_RENDERER, base::BindOnce( - &IpcInvocationManager::ResolveInvocation_, - std::move(ctx), std::move(res), std::move(pex) - )); - }); + dispatchBindings_[A::Identifier] = [](IpcInvocationManager& manager, CallbackContext ctx, CefRefPtr pObj) { + // the call goes out from the frame that made it, so the reply comes back to it + auto frame = ctx.pV8Context->GetFrame(); + manager.SendCall_(A::Identifier, kernel_relay::ToBinary(FromV8(*pObj)), std::move(frame), + [ctx = std::move(ctx)](bool succeeded, CefRefPtr pResponse) { + // a failed call reaches the page as a rejection + if (succeeded) { + try { + ResolveInvocation_(ctx, kernel_relay::FromBinary(pResponse.get()), true); + return; + } + catch (...) { + pmlog_error(::pmon::util::ReportException("Malformed kernel call reply")); + } + } + ResolveInvocation_(ctx, Response{}, false); + }); }; } - IpcInvocationManager(CefClient& client) : client_{ client } {} private: - // function + // types + // succeeded, and the Response payload (null when it serialized to no bytes) + using ReplyHandler = std::function)>; + // functions + void SendCall_(const std::string& action, CefRefPtr pParams, CefRefPtr frame, + ReplyHandler onReply); template - static void ResolveInvocation_(CallbackContext ctx, Response res, std::exception_ptr pex) + static void ResolveInvocation_(const CallbackContext& ctx, const Response& res, bool succeeded) { CEF_REQUIRE_RENDERER_THREAD(); // TODO: give error details in the failure path ctx.pV8Context->Enter(); - if (!pex) { + if (succeeded) { ctx.pV8ResolveFunction->ExecuteFunction(nullptr, { ToV8(res) }); } else { @@ -84,8 +93,11 @@ namespace p2c::client::util ctx.pV8Context->Exit(); } // data - static std::unordered_map, CefClient&)>> dispatchBindings_; - CefClient& client_; + static std::unordered_map)>> dispatchBindings_; + // unique across the renderer process, so a reply meant for a page that has since + // reloaded cannot match a call made by the new one + static int nextCallId_; + std::unordered_map pendingCalls_; }; template<> diff --git a/IntelPresentMon/AppCef/source/util/KernelActionRegistration.cpp b/IntelPresentMon/AppCef/source/util/KernelActionRegistration.cpp index 48b968b38..f7d2b9a8e 100644 --- a/IntelPresentMon/AppCef/source/util/KernelActionRegistration.cpp +++ b/IntelPresentMon/AppCef/source/util/KernelActionRegistration.cpp @@ -1,9 +1,16 @@ #include "IpcInvocationManager.h" +#include "KernelRelay.h" #include "../../../KernelProcess/kact/AllActions.h" namespace p2c::client::util::kact { + // registers both halves of a kernel action: the renderer binding called from the page, + // and the browser-side relay that forwards it to the kernel template struct IpcActionRegistrator { - IpcActionRegistrator() { ::p2c::client::util::IpcInvocationManager::RegisterDispatchBinding(); } + IpcActionRegistrator() + { + ::p2c::client::util::IpcInvocationManager::RegisterDispatchBinding(); + ::p2c::client::util::kernel_relay::CallRelay::RegisterAction(); + } }; IpcActionRegistrator reg_ibind_Introspect_; diff --git a/IntelPresentMon/AppCef/source/util/KernelLink.cpp b/IntelPresentMon/AppCef/source/util/KernelLink.cpp new file mode 100644 index 000000000..faa63fa0e --- /dev/null +++ b/IntelPresentMon/AppCef/source/util/KernelLink.cpp @@ -0,0 +1,59 @@ +#include "KernelLink.h" +#include + +namespace p2c::client::util +{ + ::pmon::util::win::Handle TakeKernelPipeHandle(uint64_t pipeHandleValue) + { + if (!pipeHandleValue) { + pmlog_error("No kernel pipe handle was passed; the UI has no kernel link"); + return {}; + } + ::pmon::util::win::Handle pipe{ reinterpret_cast((uintptr_t)pipeHandleValue) }; + // an inherited handle stays inheritable; it must never reach a process this one launches + if (!SetHandleInformation(pipe, HANDLE_FLAG_INHERIT, 0)) { + pmlog_error("Failed to clear inheritance on the kernel pipe handle; the UI has no kernel link").hr(); + return {}; + } + return pipe; + } + + KernelLink::KernelLink(::pmon::util::win::Handle pipe, kernel_relay::SignalRelay::BrowserGetter getBrowser) + : + signals_{ getBrowser }, + pClient_{ OpenSession_(std::move(pipe), signals_) }, + calls_{ pClient_.get() } + {} + + kernel_relay::CallRelay& KernelLink::GetCallRelay() + { + return calls_; + } + + CefRefPtr KernelLink::MakeBrowserExtraInfo() const + { + const std::string unknown = "unknown"; + auto info = CefDictionaryValue::Create(); + info->SetString(kernel_relay::kServiceBuildIdKey, pClient_ ? pClient_->GetServiceBuildId() : unknown); + info->SetString(kernel_relay::kServiceBuildTimeKey, pClient_ ? pClient_->GetServiceBuildTime() : unknown); + info->SetString(kernel_relay::kServiceVersionKey, pClient_ ? pClient_->GetServiceVersion() : unknown); + info->SetString(kernel_relay::kMiddlewareApiVersionKey, pClient_ ? pClient_->GetMiddlewareApiVersion() : unknown); + return info; + } + + std::unique_ptr KernelLink::OpenSession_(::pmon::util::win::Handle pipe, const kernel_relay::SignalRelay& signals) + { + if (!pipe) { + // TakeKernelPipeHandle has logged why + return {}; + } + try { + return std::make_unique(std::move(pipe), + cact::CefExecutionContext{ .pSignalRelay = &signals }); + } + catch (...) { + pmlog_error(::pmon::util::ReportException("Failed opening the kernel session; the UI has no kernel link")); + return {}; + } + } +} diff --git a/IntelPresentMon/AppCef/source/util/KernelLink.h b/IntelPresentMon/AppCef/source/util/KernelLink.h new file mode 100644 index 000000000..0f3d708df --- /dev/null +++ b/IntelPresentMon/AppCef/source/util/KernelLink.h @@ -0,0 +1,32 @@ +#pragma once +#include "KernelRelay.h" +#include +#include + +namespace p2c::client::util +{ + // Browser process: takes ownership of the kernel pipe handle passed on the command line and + // clears its inheritance. Call before CefInitialize, so no process CEF launches can inherit it. + // Returns an empty handle, and logs why, when the value is 0 or inheritance cannot be cleared; + // the UI then runs with no kernel link. + ::pmon::util::win::Handle TakeKernelPipeHandle(uint64_t pipeHandleValue); + + // Browser process: the UI's one connection to the kernel. It holds the client end of the pipe + // pair the kernel created for this UI, relays the page's calls to the kernel, and forwards the + // kernel's events to the page. Lives from just after CefInitialize until just before CefShutdown. + class KernelLink + { + public: + // pipe comes from TakeKernelPipeHandle. If it is empty or the session cannot be opened, the + // UI still runs with no kernel link and calls are refused. + KernelLink(::pmon::util::win::Handle pipe, kernel_relay::SignalRelay::BrowserGetter getBrowser); + kernel_relay::CallRelay& GetCallRelay(); + // service and middleware versions for the renderer, passed with browser creation + CefRefPtr MakeBrowserExtraInfo() const; + private: + static std::unique_ptr OpenSession_(::pmon::util::win::Handle pipe, const kernel_relay::SignalRelay& signals); + kernel_relay::SignalRelay signals_; + std::unique_ptr pClient_; + kernel_relay::CallRelay calls_; + }; +} diff --git a/IntelPresentMon/AppCef/source/util/KernelRelay.cpp b/IntelPresentMon/AppCef/source/util/KernelRelay.cpp new file mode 100644 index 000000000..c39ebaf1e --- /dev/null +++ b/IntelPresentMon/AppCef/source/util/KernelRelay.cpp @@ -0,0 +1,88 @@ +#include "KernelRelay.h" +#include +#include +#include +#include + +namespace p2c::client::util::kernel_relay +{ + bool CallRelay::HandleMessage(CefProcessMessage& message, CefRefPtr frame) + { + if (message.GetName() != kCallMessage) { + return false; + } + const auto args = message.GetArgumentList(); + const auto action = args->GetString(0).ToString(); + const auto callId = args->GetInt(1); + if (!pClient_) { + pmlog_error("Kernel call refused, the UI has no kernel link").pmwatch(action); + PostReply_(frame, callId, false, nullptr); + return true; + } + const auto i = Forwarders_().find(action); + if (i == Forwarders_().end()) { + pmlog_error("Kernel call refused, unknown action").pmwatch(action); + PostReply_(frame, callId, false, nullptr); + return true; + } + // no binary value means Params serialized to no bytes + const auto pParams = args->GetType(2) == VTYPE_BINARY ? args->GetBinary(2) : nullptr; + try { + i->second(*pClient_, callId, pParams, frame); + } + catch (...) { + pmlog_error(::pmon::util::ReportException(std::format("Failed relaying kernel call [{}]", action))); + PostReply_(frame, callId, false, nullptr); + } + return true; + } + + std::unordered_map& CallRelay::Forwarders_() + { + static std::unordered_map forwarders; + return forwarders; + } + + void CallRelay::PostReply_(CefRefPtr frame, int callId, bool succeeded, CefRefPtr pResponse) + { + CefPostTask(TID_UI, base::BindOnce(&CallRelay::SendReply_, std::move(frame), callId, succeeded, std::move(pResponse))); + } + + void CallRelay::SendReply_(CefRefPtr frame, int callId, bool succeeded, CefRefPtr pResponse) + { + // the page that made the call may have reloaded or closed since + if (!frame || !frame->IsValid()) { + pmlog_dbg("Dropping kernel call reply, its frame is gone").pmwatch(callId); + return; + } + auto message = CefProcessMessage::Create(kReplyMessage); + const auto args = message->GetArgumentList(); + args->SetInt(0, callId); + args->SetBool(1, succeeded); + if (pResponse) { + args->SetBinary(2, std::move(pResponse)); + } + frame->SendProcessMessage(PID_RENDERER, std::move(message)); + } + + void SignalRelay::Forward(const std::string& key, std::optional arg) const + { + CefPostTask(TID_UI, base::BindOnce(&SignalRelay::Send_, getBrowser_, key, arg.has_value(), arg.value_or(0))); + } + + void SignalRelay::Send_(BrowserGetter getBrowser, std::string key, bool hasArg, uint32_t arg) + { + const auto browser = getBrowser(); + if (!browser) { + pmlog_warn("Dropping kernel event, no browser yet").pmwatch(key); + return; + } + auto message = CefProcessMessage::Create(kSignalMessage); + const auto args = message->GetArgumentList(); + args->SetString(0, key); + if (hasArg) { + args->SetInt(1, (int)arg); + } + browser->GetMainFrame()->SendProcessMessage(PID_RENDERER, std::move(message)); + } +} diff --git a/IntelPresentMon/AppCef/source/util/KernelRelay.h b/IntelPresentMon/AppCef/source/util/KernelRelay.h new file mode 100644 index 000000000..f0c12d112 --- /dev/null +++ b/IntelPresentMon/AppCef/source/util/KernelRelay.h @@ -0,0 +1,117 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "ActionClientServer.h" +#include "Logging.h" + +// The browser process owns the pipe to the kernel; the page runs in a renderer process. +// Kernel calls and kernel events cross between the two as these CEF process messages. +namespace p2c::client::util::kernel_relay +{ + // Payloads are cereal bytes in a binary value. CEF has no empty binary value, so a payload + // that serializes to nothing (an action whose Params or Response has no fields) is sent as + // no value at that index; see ToBinary and FromBinary. + // renderer -> browser. 0: action identifier (string), 1: call id (int), 2: Params (binary) + inline constexpr const char* kCallMessage = "kern-call"; + // browser -> renderer. 0: call id (int), 1: succeeded (bool), 2: Response (binary, on success) + inline constexpr const char* kReplyMessage = "kern-reply"; + // browser -> renderer. 0: signal key (string), 1: argument (int, only for signals that take one) + inline constexpr const char* kSignalMessage = "kern-signal"; + // keys of the browser-creation extra_info dictionary, which carries the versions the kernel + // reported when the browser process opened its session + inline constexpr const char* kServiceBuildIdKey = "serviceBuildId"; + inline constexpr const char* kServiceBuildTimeKey = "serviceBuildTime"; + inline constexpr const char* kServiceVersionKey = "serviceVersion"; + inline constexpr const char* kMiddlewareApiVersionKey = "middlewareApiVersion"; + + // Params and Response types already serialize with cereal for the pipe; the relay reuses that. + // Null when value serializes to no bytes: CEF cannot hold an empty binary value. + template + CefRefPtr ToBinary(const T& value) + { + std::ostringstream stream; + { + cereal::BinaryOutputArchive archive{ stream }; + archive(value); + } + const auto bytes = std::move(stream).str(); + if (bytes.empty()) { + return nullptr; + } + return CefBinaryValue::Create(bytes.data(), bytes.size()); + } + // pBinary may be null, meaning the value serialized to no bytes + template + T FromBinary(CefBinaryValue* pBinary) + { + std::string bytes; + if (pBinary) { + bytes.resize(pBinary->GetSize()); + pBinary->GetData(bytes.data(), bytes.size(), 0); + } + std::istringstream stream{ std::move(bytes) }; + cereal::BinaryInputArchive archive{ stream }; + T value; + archive(value); + return value; + } + + // Browser process: carries kernel calls from the renderer to the kernel, and the replies back + // to the frame that made the call. + class CallRelay + { + public: + // pClient is null when the UI has no kernel link; every call is then refused. + explicit CallRelay(CefClient* pClient) : pClient_{ pClient } {} + // Handles a kCallMessage from frame. False for any other message. + bool HandleMessage(CefProcessMessage& message, CefRefPtr frame); + // Registered once per kernel action, beside its renderer-side binding. + template + static void RegisterAction() + { + Forwarders_()[A::Identifier] = [](CefClient& client, int callId, + CefRefPtr pParams, CefRefPtr frame) { + client.DispatchWithContinuation(FromBinary(pParams.get()), + [callId, frame](typename A::Response&& res, std::exception_ptr pex) { + if (pex) { + PostReply_(frame, callId, false, nullptr); + } + else { + PostReply_(frame, callId, true, ToBinary(res)); + } + }); + }; + } + private: + using Forwarder = std::function, CefRefPtr)>; + static std::unordered_map& Forwarders_(); + // any thread; the reply is sent on the CEF UI thread. pResponse may be null (see ToBinary). + static void PostReply_(CefRefPtr frame, int callId, bool succeeded, CefRefPtr pResponse); + static void SendReply_(CefRefPtr frame, int callId, bool succeeded, CefRefPtr pResponse); + CefClient* pClient_; + }; + + // Browser process: forwards kernel events to the page. getBrowser runs on the CEF UI thread and + // returns the current browser, or null before it exists; events with no browser are dropped. + class SignalRelay + { + public: + using BrowserGetter = CefRefPtr(*)(); + explicit SignalRelay(BrowserGetter getBrowser) : getBrowser_{ getBrowser } {} + // any thread + void Forward(const std::string& key, std::optional arg = {}) const; + private: + static void Send_(BrowserGetter getBrowser, std::string key, bool hasArg, uint32_t arg); + BrowserGetter getBrowser_; + }; +} diff --git a/IntelPresentMon/AppCef/source/util/KernelWrapper.h b/IntelPresentMon/AppCef/source/util/KernelWrapper.h index 874c86fb2..c57414947 100644 --- a/IntelPresentMon/AppCef/source/util/KernelWrapper.h +++ b/IntelPresentMon/AppCef/source/util/KernelWrapper.h @@ -2,8 +2,8 @@ #include #include "SignalManager.h" #include "AsyncEndpointManager.h" -#include "ActionClientServer.h" #include "IpcInvocationManager.h" +#include namespace p2c::client::util @@ -12,7 +12,14 @@ namespace p2c::client::util { util::SignalManager signals; util::AsyncEndpointManager asyncEndpoints; - std::unique_ptr pClient; std::unique_ptr pInvocationManager; + // versions the kernel reported to the browser process, received with browser creation + struct KernelVersions + { + std::string serviceBuildId = "unknown"; + std::string serviceBuildTime = "unknown"; + std::string serviceVersion = "unknown"; + std::string middlewareApiVersion = "unknown"; + } kernelVersions; }; } diff --git a/IntelPresentMon/AppCef/source/util/LogSetup.cpp b/IntelPresentMon/AppCef/source/util/LogSetup.cpp index afc62bc6c..7966dea73 100644 --- a/IntelPresentMon/AppCef/source/util/LogSetup.cpp +++ b/IntelPresentMon/AppCef/source/util/LogSetup.cpp @@ -166,7 +166,7 @@ namespace p2c::client::util try { const auto fullPipeName = R"(\\.\pipe\)" + pipePrefix; // wait maximum 1.5sec for pipe to be created - if (!pipe::DuplexPipe::WaitForAvailability(fullPipeName, 1500, true)) { + if (!pipe::DuplexPipe::WaitForAvailability(fullPipeName, 1500)) { pmlog_warn(std::format("Failed to connect to logging source server {} after waiting 1.5s", pipePrefix)); return; } diff --git a/IntelPresentMon/AppCef/source/util/SignalManager.cpp b/IntelPresentMon/AppCef/source/util/SignalManager.cpp index a25ee56d2..ccb664af1 100644 --- a/IntelPresentMon/AppCef/source/util/SignalManager.cpp +++ b/IntelPresentMon/AppCef/source/util/SignalManager.cpp @@ -35,31 +35,17 @@ namespace p2c::client::util } } - // List of Signal-firing functions (call from C++ to invoke a callback registered by JS side) - // - // targetLost(pid:number) - void SignalManager::SignalTargetLost(uint32_t pid) + void SignalManager::SignalFromBrowser(const std::string& key, std::optional arg) { - ImplementSignal_(Keys::targetLost(), pid); - } - // hotkeyFired(action:number) - void SignalManager::SignalHotkeyFired(uint32_t action) - { - ImplementSignal_(Keys::hotkeyFired(), action); - } - // presentmonInitFailed() - void SignalManager::SignalPresentmonInitFailed() - { - ImplementSignal_(Keys::presentmonInitFailed()); - } - // overlayDied() - void SignalManager::SignalOverlayDied() - { - ImplementSignal_(Keys::overlayDied()); - } - // stalePid() - void SignalManager::SignalStalePid() - { - ImplementSignal_(Keys::stalePid()); + if (std::find(std::begin(keyList), std::end(keyList), key) == std::end(keyList)) { + pmlog_warn(std::format("Kernel event [{}] is not a valid signal key", key)); + return; + } + if (arg) { + ImplementSignal_(key, *arg); + } + else { + ImplementSignal_(key); + } } } \ No newline at end of file diff --git a/IntelPresentMon/AppCef/source/util/SignalManager.h b/IntelPresentMon/AppCef/source/util/SignalManager.h index 22eed52d6..14e6aa8f5 100644 --- a/IntelPresentMon/AppCef/source/util/SignalManager.h +++ b/IntelPresentMon/AppCef/source/util/SignalManager.h @@ -3,6 +3,8 @@ #pragma once #include #include +#include +#include #include #include @@ -40,12 +42,11 @@ namespace p2c::client::util }; // functions void RegisterCallback(const std::string& key, CallbackContext ctx); - // -- Signal-firing (call from C++ to invoke a callback registered by JS side) - void SignalTargetLost(uint32_t pid); // targetLost(pid:number) - void SignalHotkeyFired(uint32_t action); // hotkeyFired(action:number) - void SignalPresentmonInitFailed(); // presentmonInitFailed() - void SignalOverlayDied(); // overlayDied() - void SignalStalePid(); // stalePid() + // Fires the callback the page registered for a kernel event forwarded by the browser + // process; key must be one of keyList. The signals and their arguments: + // targetLost(pid:number), hotkeyFired(action:number), presentmonInitFailed(), + // overlayDied(), stalePid() + void SignalFromBrowser(const std::string& key, std::optional arg); private: // functions template diff --git a/IntelPresentMon/AppCef/source/util/async/GetAppInfo.h b/IntelPresentMon/AppCef/source/util/async/GetAppInfo.h index 61bd5a773..a511249a0 100644 --- a/IntelPresentMon/AppCef/source/util/async/GetAppInfo.h +++ b/IntelPresentMon/AppCef/source/util/async/GetAppInfo.h @@ -111,14 +111,14 @@ namespace p2c::client::util::async constexpr bool isDebugBuild = true; #endif const auto pKernelWrapper = accessor.GetKernelWrapper(); - const auto pClient = pKernelWrapper ? pKernelWrapper->pClient.get() : nullptr; + const auto versions = pKernelWrapper ? pKernelWrapper->kernelVersions : KernelWrapper::KernelVersions{}; auto info = MakeCefObject( CefProp{ "productName", std::string{ "Intel(R) PresentMon" } }, CefProp{ "productVersion", std::string{ PM_VER_PRODUCT_STR } }, CefProp{ "fileVersion", std::string{ PM_VER_FILE_STR } }, CefProp{ "apiVersion", FormatApiHeaderVersion_() }, - CefProp{ "middlewareApiVersion", pClient ? pClient->GetMiddlewareApiVersion() : std::string{ "unknown" } }, + CefProp{ "middlewareApiVersion", versions.middlewareApiVersion }, CefProp{ "buildId", std::string{ ::pmon::bid::BuildIdUid() } }, CefProp{ "buildHash", std::string{ ::pmon::bid::BuildIdLongHash() } }, CefProp{ "buildHashShort", std::string{ ::pmon::bid::BuildIdShortHash() } }, @@ -128,9 +128,9 @@ namespace p2c::client::util::async CefProp{ "isDebugBuild", isDebugBuild }, CefProp{ "compileDate", std::string{ __DATE__ } }, CefProp{ "compileTime", std::string{ __TIME__ } }, - CefProp{ "serviceBuildId", pClient ? pClient->GetServiceBuildId() : std::string{ "unknown" } }, - CefProp{ "serviceBuildTime", pClient ? pClient->GetServiceBuildTime() : std::string{ "unknown" } }, - CefProp{ "serviceVersion", pClient ? pClient->GetServiceVersion() : std::string{ "unknown" } }, + CefProp{ "serviceBuildId", versions.serviceBuildId }, + CefProp{ "serviceBuildTime", versions.serviceBuildTime }, + CefProp{ "serviceVersion", versions.serviceVersion }, CefProp{ "cefVersion", std::string{ CEF_VERSION } }, CefProp{ "cefVersionMajor", CEF_VERSION_MAJOR }, CefProp{ "cefVersionMinor", CEF_VERSION_MINOR }, diff --git a/IntelPresentMon/AppCef/source/util/cact/CefExecutionContext.h b/IntelPresentMon/AppCef/source/util/cact/CefExecutionContext.h index 0d09250e3..bfda96d8f 100644 --- a/IntelPresentMon/AppCef/source/util/cact/CefExecutionContext.h +++ b/IntelPresentMon/AppCef/source/util/cact/CefExecutionContext.h @@ -1,31 +1,18 @@ #pragma once -#include -#include -#include -#include -#include -#include -#include #include #include -#include -namespace p2c::client::util +namespace p2c::client::util::kernel_relay { - class SignalManager; + class SignalRelay; } namespace p2c::client::util::cact { - using namespace ::pmon; - struct CefExecutionContext; - struct CefSessionContext { - // common session context items - std::unique_ptr> pConn; + // required by the transport, see TransportSessionContext uint32_t remotePid = 0; - uint32_t nextCommandToken = 0; }; struct CefExecutionContext @@ -34,7 +21,8 @@ namespace p2c::client::util::cact using SessionContextType = CefSessionContext; // data - SignalManager* pSignalManager = nullptr; + // kernel events arrive in the browser process and are forwarded to the page through this + const kernel_relay::SignalRelay* pSignalRelay = nullptr; std::optional responseWriteTimeoutMs; }; -} \ No newline at end of file +} diff --git a/IntelPresentMon/AppCef/source/util/cact/HotkeyFiredAction.h b/IntelPresentMon/AppCef/source/util/cact/HotkeyFiredAction.h index 1d6edd037..c189d9e4b 100644 --- a/IntelPresentMon/AppCef/source/util/cact/HotkeyFiredAction.h +++ b/IntelPresentMon/AppCef/source/util/cact/HotkeyFiredAction.h @@ -32,9 +32,7 @@ namespace p2c::client::util::cact } #ifdef PM_ASYNC_ACTION_REGISTRATION_ -#include -#include -#include +#include "../KernelRelay.h" #include "../SignalManager.h" namespace p2c::client::util::cact { @@ -45,8 +43,7 @@ namespace p2c::client::util::cact // while also enabling dependency decoupling more easily void ACT_NAME::Execute_(const ACT_EXEC_CTX& ctx, SessionContext& stx, Params&& in) { - CefPostTask(TID_RENDERER, base::BindOnce(&SignalManager::SignalHotkeyFired, - base::Unretained(ctx.pSignalManager), in.actionId)); + ctx.pSignalRelay->Forward(SignalManager::Keys::hotkeyFired(), (uint32_t)in.actionId); } } #endif diff --git a/IntelPresentMon/AppCef/source/util/cact/OverlayDiedAction.h b/IntelPresentMon/AppCef/source/util/cact/OverlayDiedAction.h index ea6c68308..6621f5d91 100644 --- a/IntelPresentMon/AppCef/source/util/cact/OverlayDiedAction.h +++ b/IntelPresentMon/AppCef/source/util/cact/OverlayDiedAction.h @@ -28,9 +28,7 @@ namespace p2c::client::util::cact } #ifdef PM_ASYNC_ACTION_REGISTRATION_ -#include -#include -#include +#include "../KernelRelay.h" #include "../SignalManager.h" namespace p2c::client::util::cact { @@ -41,8 +39,7 @@ namespace p2c::client::util::cact // while also enabling dependency decoupling more easily void ACT_NAME::Execute_(const ACT_EXEC_CTX& ctx, SessionContext& stx, Params&& in) { - CefPostTask(TID_RENDERER, base::BindOnce(&SignalManager::SignalOverlayDied, - base::Unretained(ctx.pSignalManager))); + ctx.pSignalRelay->Forward(SignalManager::Keys::overlayDied()); } } #endif diff --git a/IntelPresentMon/AppCef/source/util/cact/PresentmonInitFailedAction.h b/IntelPresentMon/AppCef/source/util/cact/PresentmonInitFailedAction.h index e6c2683d2..fce8fb957 100644 --- a/IntelPresentMon/AppCef/source/util/cact/PresentmonInitFailedAction.h +++ b/IntelPresentMon/AppCef/source/util/cact/PresentmonInitFailedAction.h @@ -28,9 +28,7 @@ namespace p2c::client::util::cact } #ifdef PM_ASYNC_ACTION_REGISTRATION_ -#include -#include -#include +#include "../KernelRelay.h" #include "../SignalManager.h" namespace p2c::client::util::cact { @@ -41,8 +39,7 @@ namespace p2c::client::util::cact // while also enabling dependency decoupling more easily void ACT_NAME::Execute_(const ACT_EXEC_CTX& ctx, SessionContext& stx, Params&& in) { - CefPostTask(TID_RENDERER, base::BindOnce(&SignalManager::SignalPresentmonInitFailed, - base::Unretained(ctx.pSignalManager))); + ctx.pSignalRelay->Forward(SignalManager::Keys::presentmonInitFailed()); } } #endif diff --git a/IntelPresentMon/AppCef/source/util/cact/StalePidAction.h b/IntelPresentMon/AppCef/source/util/cact/StalePidAction.h index feba84899..c149f5567 100644 --- a/IntelPresentMon/AppCef/source/util/cact/StalePidAction.h +++ b/IntelPresentMon/AppCef/source/util/cact/StalePidAction.h @@ -28,9 +28,7 @@ namespace p2c::client::util::cact } #ifdef PM_ASYNC_ACTION_REGISTRATION_ -#include -#include -#include +#include "../KernelRelay.h" #include "../SignalManager.h" namespace p2c::client::util::cact { @@ -41,8 +39,7 @@ namespace p2c::client::util::cact // while also enabling dependency decoupling more easily void ACT_NAME::Execute_(const ACT_EXEC_CTX& ctx, SessionContext& stx, Params&& in) { - CefPostTask(TID_RENDERER, base::BindOnce(&SignalManager::SignalStalePid, - base::Unretained(ctx.pSignalManager))); + ctx.pSignalRelay->Forward(SignalManager::Keys::stalePid()); } } #endif diff --git a/IntelPresentMon/AppCef/source/util/cact/TargetLostAction.h b/IntelPresentMon/AppCef/source/util/cact/TargetLostAction.h index 85a50464e..eee53608b 100644 --- a/IntelPresentMon/AppCef/source/util/cact/TargetLostAction.h +++ b/IntelPresentMon/AppCef/source/util/cact/TargetLostAction.h @@ -32,9 +32,7 @@ namespace p2c::client::util::cact } #ifdef PM_ASYNC_ACTION_REGISTRATION_ -#include -#include -#include +#include "../KernelRelay.h" #include "../SignalManager.h" namespace p2c::client::util::cact { @@ -45,8 +43,7 @@ namespace p2c::client::util::cact // while also enabling dependency decoupling more easily void ACT_NAME::Execute_(const ACT_EXEC_CTX& ctx, SessionContext& stx, Params&& in) { - CefPostTask(TID_RENDERER, base::BindOnce(&SignalManager::SignalTargetLost, - base::Unretained(ctx.pSignalManager), in.pid)); + ctx.pSignalRelay->Forward(SignalManager::Keys::targetLost(), in.pid); } } #endif diff --git a/IntelPresentMon/AppCef/source/winmain.cpp b/IntelPresentMon/AppCef/source/winmain.cpp index 77d8b9f62..d4ec56578 100644 --- a/IntelPresentMon/AppCef/source/winmain.cpp +++ b/IntelPresentMon/AppCef/source/winmain.cpp @@ -17,6 +17,7 @@ #include #include #include "util/CefLog.h" +#include "util/KernelLink.h" #pragma comment(lib, "Dwmapi.lib") @@ -33,16 +34,22 @@ constexpr const char* BrowserWindowClassName = "BrowserWindowClass"; constexpr const char* MessageWindowClassName = "MessageWindowClass"; constexpr const int quitCefCode = 0xABAD1DEA; CefRefPtr pBrowserClient; +// the UI's connection to the kernel, see KernelLink +std::unique_ptr pKernelLink; HWND hwndAppMsg = nullptr; +// runs on the CEF UI thread; null until the browser has been created +CefRefPtr GetBrowserForKernelEvents() +{ + return pBrowserClient ? pBrowserClient->GetBrowser() : nullptr; +} + LRESULT CALLBACK BrowserWindowWndProc(HWND window_handle, UINT message, WPARAM w_param, LPARAM l_param) { switch (message) { case WM_CREATE: { - pBrowserClient = new ccef::NanoCefBrowserClient{}; - RECT rect = { 0 }; GetClientRect(window_handle, &rect); CefRect cefRect; @@ -60,9 +67,10 @@ LRESULT CALLBACK BrowserWindowWndProc(HWND window_handle, UINT message, WPARAM w if (Options::Get().url) { url = *Options::Get().url; } + // the renderer reads the kernel's reported versions from extra_info CefBrowserHost::CreateBrowser( info, pBrowserClient.get(), url, - settings, {}, {} + settings, pKernelLink->MakeBrowserExtraInfo(), {} ); break; } @@ -221,6 +229,9 @@ int APIENTRY WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLi } // code from here on is only executed by the root process (browser window process) + + // take the kernel pipe first: its inheritance must be cleared before CEF can launch any process + auto kernelPipe = client::util::TakeKernelPipeHandle(*opt.actHandle); auto uiInstanceMutex = client::util::TryAcquireUiBrowserProcessMutex(*opt.uiMutexName); if (!uiInstanceMutex.first) { return -1; @@ -249,8 +260,19 @@ int APIENTRY WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLi cefLogSeverity = ToCefLogLevel(util::log::Level::Debug); } settings.log_severity = cefLogSeverity; - CefInitialize(main_args, settings, app.get(), nullptr); + // on failure CEF must not be used further; without this check the window + // would open with nothing rendering in it + if (!CefInitialize(main_args, settings, app.get(), nullptr)) { + const auto cefExitCode = CefGetExitCode(); + pmlog_error("CEF failed to initialize; the UI cannot start").pmwatch(cefExitCode); + return cefExitCode ? cefExitCode : -1; + } } + // The browser client exists before the kernel link, so a kernel event finds it; the + // link exists before the browser, so the page's first call finds the relay. + pBrowserClient = new ccef::NanoCefBrowserClient{}; + pKernelLink = std::make_unique(std::move(kernelPipe), &GetBrowserForKernelEvents); + pBrowserClient->SetKernelCallRelay(&pKernelLink->GetCallRelay()); auto hwndBrowser = CreateBrowserWindow(hInstance, nCmdShow); client::util::SetUiBrowserWindowMutexSuffix(hwndBrowser, *opt.uiMutexName); hwndAppMsg = CreateMessageWindow(hInstance); @@ -263,6 +285,9 @@ int APIENTRY WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLi } DestroyWindow(hwndAppMsg); + // close the kernel session while CEF can still run the tasks it posts + pBrowserClient->SetKernelCallRelay(nullptr); + pKernelLink.reset(); CefShutdown(); client::util::ClearUiBrowserWindowMutexSuffix(hwndBrowser); DestroyWindow(hwndBrowser); diff --git a/IntelPresentMon/CommonUtilities/pipe/CoroMutex.cpp b/IntelPresentMon/CommonUtilities/pipe/CoroMutex.cpp index 246c58403..39880e059 100644 --- a/IntelPresentMon/CommonUtilities/pipe/CoroMutex.cpp +++ b/IntelPresentMon/CommonUtilities/pipe/CoroMutex.cpp @@ -1,20 +1,40 @@ #include "CoroMutex.h" +#include namespace pmon::util::pipe { - as::awaitable CoroMutex::Lock() + as::awaitable CoroMutex::Lock(std::optional timeout) { if (counter_++ == 0) { - co_return; + co_return LockResult::Acquired; } auto ec = boost::system::error_code{}; + auto granted = std::make_shared(false); Timer timer{ ctx_ }; - timer.expires_after(Timer::duration::max()); - waiters_.push_back(&timer); + if (timeout) { + timer.expires_after(*timeout); + } + else { + timer.expires_after(Timer::duration::max()); + } + waiters_.push_back(Waiter{ &timer, granted }); co_await timer.async_wait(as::redirect_error(as::use_awaitable, ec)); - holdoff_ = false; + // Granted wins even when the timeout fired too: Unlock already removed this waiter and + // passed it the count, so it owns the mutex and its guard must release it. + if (*granted) { + holdoff_ = false; + co_return LockResult::Acquired; + } + // Still queued means nobody removed it, so the timeout woke it: remove it and undo its + // count here. Not queued means CancelWaiters removed it and already undid its count. + if (const auto i = std::ranges::find(waiters_, &timer, &Waiter::timer); i != waiters_.end()) { + waiters_.erase(i); + --counter_; + co_return LockResult::TimedOut; + } + co_return LockResult::Cancelled; } bool CoroMutex::TryLock() { @@ -38,17 +58,42 @@ namespace pmon::util::pipe } holdoff_ = true; - waiters_.front()->cancel(); + if (waiters_.front().granted) { + *waiters_.front().granted = true; + } + waiters_.front().timer->cancel(); waiters_.pop_front(); } + void CoroMutex::CancelWaiters() + { + auto pending = std::move(waiters_); + waiters_.clear(); + for (auto& waiter : pending) { + if (counter_ > 0) { + --counter_; + } + if (waiter.timer) { + waiter.timer->cancel(); + } + } + } CoroLockGuard::CoroLockGuard(CoroLockGuard&& other) - : pMtx_{ std::exchange(other.pMtx_, nullptr) } + : pMtx_{ std::exchange(other.pMtx_, nullptr) }, + result_{ other.result_ } {} CoroLockGuard& CoroLockGuard::operator=(CoroLockGuard&& rhs) { + if (this == &rhs) { + return *this; + } + // release any mutex already held before taking ownership of rhs + if (pMtx_) { + pMtx_->Unlock(); + } pMtx_ = std::exchange(rhs.pMtx_, nullptr); + result_ = rhs.result_; return *this; } CoroLockGuard::~CoroLockGuard() @@ -59,9 +104,12 @@ namespace pmon::util::pipe } - as::awaitable CoroLock(CoroMutex& mtx) + as::awaitable CoroLock(CoroMutex& mtx, std::optional timeout) { - co_await mtx.Lock(); + const auto result = co_await mtx.Lock(timeout); + if (result != CoroMutex::LockResult::Acquired) { + co_return CoroLockGuard{ result }; + } co_return CoroLockGuard{ mtx }; } } \ No newline at end of file diff --git a/IntelPresentMon/CommonUtilities/pipe/CoroMutex.h b/IntelPresentMon/CommonUtilities/pipe/CoroMutex.h index d90f0bfe6..cebfb1191 100644 --- a/IntelPresentMon/CommonUtilities/pipe/CoroMutex.h +++ b/IntelPresentMon/CommonUtilities/pipe/CoroMutex.h @@ -3,7 +3,10 @@ #include #include #include +#include #include +#include +#include namespace pmon::util::pipe { @@ -21,12 +24,32 @@ namespace pmon::util::pipe CoroMutex& operator=(CoroMutex&&) = default; ~CoroMutex() = default; - as::awaitable Lock(); + enum class LockResult + { + // the caller owns the mutex and must Unlock it (CoroLockGuard does) + Acquired, + // CancelWaiters woke the wait (the pipe was closed); the caller does not own it + Cancelled, + // the timeout passed first; the caller does not own it + TimedOut, + }; + // Waits for the mutex, up to timeout when one is given. + // counter_ counts the holder plus every queued waiter. A waiter is removed from waiters_ + // exactly once, and whoever removes it accounts for its count: Unlock hands the count to + // it as the new holder, CancelWaiters and a timed-out waiter decrement it. + as::awaitable Lock(std::optional timeout = {}); bool TryLock(); void Unlock(); + // Wakes every coroutine blocked in Lock without granting the mutex. + void CancelWaiters(); private: + struct Waiter + { + Timer* timer = nullptr; + std::shared_ptr granted; + }; as::io_context& ctx_; - std::deque waiters_; + std::deque waiters_; int counter_ = 0; // used to check for the the same coro unlocking 2+ times bool holdoff_ = false; @@ -34,20 +57,28 @@ namespace pmon::util::pipe class CoroLockGuard { - friend as::awaitable CoroLock(CoroMutex& mtx); + friend as::awaitable CoroLock(CoroMutex& mtx, std::optional timeout); using CoroMutexType = CoroMutex; public: + CoroLockGuard() = default; CoroLockGuard(const CoroLockGuard&) = delete; CoroLockGuard& operator=(const CoroLockGuard&) = delete; + // true when the mutex is held, that is when the result was Acquired + explicit operator bool() const { return pMtx_ != nullptr; } + CoroMutex::LockResult GetResult() const { return result_; } CoroLockGuard(CoroLockGuard&& other); CoroLockGuard& operator=(CoroLockGuard&& rhs); ~CoroLockGuard(); private: // functions - CoroLockGuard(CoroMutexType& mtx) : pMtx_{ &mtx } {} + CoroLockGuard(CoroMutexType& mtx) : pMtx_{ &mtx }, result_{ CoroMutex::LockResult::Acquired } {} + CoroLockGuard(CoroMutex::LockResult failure) : result_{ failure } {} // data CoroMutexType* pMtx_ = nullptr; + CoroMutex::LockResult result_ = CoroMutex::LockResult::Cancelled; }; - as::awaitable CoroLock(CoroMutex& mtx); + // The guard owns the mutex only when GetResult() is Acquired, and then releases it when + // destroyed. With a timeout the wait gives up at that deadline (TimedOut). + as::awaitable CoroLock(CoroMutex& mtx, std::optional timeout = {}); } \ No newline at end of file diff --git a/IntelPresentMon/CommonUtilities/pipe/ManualAsyncEvent.h b/IntelPresentMon/CommonUtilities/pipe/ManualAsyncEvent.h index 693b3fc99..48297bafc 100644 --- a/IntelPresentMon/CommonUtilities/pipe/ManualAsyncEvent.h +++ b/IntelPresentMon/CommonUtilities/pipe/ManualAsyncEvent.h @@ -3,6 +3,8 @@ #include "../win/Event.h" #include #include +#include +#include namespace pmon::util::pipe @@ -17,16 +19,39 @@ namespace pmon::util::pipe : timer_{ ctx, Timer::duration::max() } {} - as::awaitable AsyncWait() + // Returns true once signalled, false only if the optional timeout elapsed first. + // Returns immediately if already signalled, so a waiter cannot miss a signal + // raised before it started waiting. + as::awaitable AsyncWait(std::optional timeout = {}) { - auto ec = boost::system::error_code{}; - co_await timer_.async_wait(as::redirect_error(as::use_awaitable, ec)); + if (timeout) { + timer_.expires_after(*timeout); + } + while (!signalled_) { + auto ec = boost::system::error_code{}; + co_await timer_.async_wait(as::redirect_error(as::use_awaitable, ec)); + // Signal() cancels the timer; an abort with signalled_ set is success. + if (signalled_) { + co_return true; + } + if (ec) { + throw boost::system::system_error{ ec }; + } + if (timeout) { + co_return false; + } + // without a deadline the timer is parked at time max, so a clean + // completion is a spurious wake and is simply waited out again + } + co_return true; } void Signal() { + signalled_ = true; timer_.cancel(); } private: Timer timer_; + bool signalled_ = false; }; } \ No newline at end of file diff --git a/IntelPresentMon/CommonUtilities/pipe/Pipe.cpp b/IntelPresentMon/CommonUtilities/pipe/Pipe.cpp index f51ac2701..4ac2ccdb6 100644 --- a/IntelPresentMon/CommonUtilities/pipe/Pipe.cpp +++ b/IntelPresentMon/CommonUtilities/pipe/Pipe.cpp @@ -1,5 +1,6 @@ #include "Pipe.h" #include "../win/Security.h" +#include "../win/HrError.h" #include "../win/ServiceProcess.h" #include @@ -17,6 +18,22 @@ namespace pmon::util::pipe std::atomic DuplexPipe::nextUid_ = 0; + namespace + { + // A client is free to connect and vanish before the accept completes. That costs + // the listener its pipe instance, but it is ordinary traffic on a public pipe + // rather than a failure, so it is reported as benign and the caller re-arms. + [[noreturn]] void ThrowAcceptError_(DWORD error) + { + if (error == ERROR_NO_DATA /* pipe is being closed */ || error == ERROR_BROKEN_PIPE) { + pmlog_dbg("Client vanished before the pipe connection was accepted").hr(error); + throw Except("Client vanished before the pipe connection was accepted"); + } + pmlog_error("Failure accepting pipe connection").hr(error); + throw Except("Failure accepting pipe connection"); + } + } + as::awaitable DuplexPipe::Accept() { assert(!asioPipeHandle_.is_open()); @@ -32,8 +49,7 @@ namespace pmon::util::pipe } if (const auto error = GetLastError(); error == ERROR_IO_PENDING) { // async operation is in-flight and not yet complete, do async wait while not complete - bool completed = false; - while (!completed) { + for (;;) { co_await connEvt.async_wait(as::use_awaitable); // after completion signal, get result to A) make sure not a spurious wake, // B) make sure there was no error, and C) conclude the overlapped operation cleanly @@ -41,9 +57,8 @@ namespace pmon::util::pipe if (GetOverlappedResult(rawPipeHandle_, &over, &dummyBytes, FALSE)) { break; } - if (GetLastError() != ERROR_IO_INCOMPLETE) { - pmlog_error("Failure accepting pipe connection").hr(); - throw Except("Failure accepting pipe connection"); + if (const auto waitError = GetLastError(); waitError != ERROR_IO_INCOMPLETE) { + ThrowAcceptError_(waitError); } } // now we have connected, so transfer pipe ownership to asio @@ -56,39 +71,97 @@ namespace pmon::util::pipe } else { // some error has occurred during connection - pmlog_error("Failure accepting pipe connection").hr(); - throw Except("Failure accepting pipe connection"); + ThrowAcceptError_(error); } pmlog_dbg(std::format("{}:{} has received a connection", name_, uid_)); } - DuplexPipe DuplexPipe::Connect(const std::string& name, as::io_context& ioctx) + DuplexPipe DuplexPipe::Connect(const std::string& name, as::io_context& ioctx, PipeLimits limits) { - return DuplexPipe{ ioctx, Connect_(name), name, true }; + return DuplexPipe{ ioctx, Connect_(name), name, true, limits }; } - DuplexPipe DuplexPipe::Make(const std::string& name, as::io_context& ioctx, const std::string& security) + std::unique_ptr DuplexPipe::ConnectAsPtr(const std::string& name, as::io_context& ioctx, PipeLimits limits) { - return DuplexPipe{ ioctx, Make_(name), name, false }; + return std::unique_ptr(new DuplexPipe{ ioctx, Connect_(name), name, true, limits }); } - std::unique_ptr DuplexPipe::ConnectAsPtr(const std::string& name, as::io_context& ioctx) + std::unique_ptr DuplexPipe::MakeAsPtr(const std::string& name, as::io_context& ioctx, const std::string& security, PipeLimits limits) { - return std::unique_ptr(new DuplexPipe{ ioctx, Connect_(name), name, true }); + return std::unique_ptr(new DuplexPipe{ ioctx, Make_(name, security), name, false, limits }); } - std::unique_ptr DuplexPipe::MakeAsPtr(const std::string& name, as::io_context& ioctx, - const std::string& security) + DuplexPipe::ConnectedPair DuplexPipe::MakeConnectedPair(const std::string& name, as::io_context& ioctx, + const std::string& security, PipeLimits limits) { - return std::unique_ptr(new DuplexPipe{ ioctx, Make_(name, security), name, false }); + // one instance only, and creation fails if the name already exists + win::Handle server{ Make_(name, security, true) }; + // fails if any other process connected first, since there is only one instance + win::Handle client{ Connect_(name) }; + // the client is already connected, so this completes at once + win::Event connectEvent; + OVERLAPPED over{ .hEvent = connectEvent.Get() }; + const auto connected = ConnectNamedPipe(server, &over); + const auto error = GetLastError(); + if (connected || error != ERROR_PIPE_CONNECTED) { + // a started wait must end before the OVERLAPPED goes out of scope + if (!connected && error == ERROR_IO_PENDING) { + CancelIoEx(server, &over); + DWORD dummyBytes = 0; + GetOverlappedResult(server, &over, &dummyBytes, TRUE); + } + pmlog_error("Pipe pair was not connected on creation").pmwatch(name).hr(error); + throw Except("Pipe pair was not connected on creation"); + } + // the only client must be this process + ULONG clientPid = 0; + if (!GetNamedPipeClientProcessId(server, &clientPid) || clientPid != GetCurrentProcessId()) { + pmlog_error("Pipe pair client is not this process").pmwatch(name).pmwatch(clientPid); + throw Except("Pipe pair client is not this process"); + } + pmlog_dbg(std::format("Created connected pipe pair [{}]", name)); + return ConnectedPair{ + .pServer = std::unique_ptr(new DuplexPipe{ ioctx, server.Release(), name, true, limits }), + .client = std::move(client), + }; } - size_t DuplexPipe::GetWriteBufferPending() const + std::unique_ptr DuplexPipe::AdoptConnectedClientAsPtr(HANDLE clientHandle, as::io_context& ioctx, PipeLimits limits) { - return writeBuf_.size(); + return std::unique_ptr(new DuplexPipe{ ioctx, clientHandle, "adopted-client", true, limits }); + } + void DuplexPipe::DiscardPacketPayload() + { + readBuf_.consume(readBuf_.size()); } - void DuplexPipe::ClearWriteBuffer() + void DuplexPipe::Cancel_() { - return writeBuf_.consume(GetWriteBufferPending()); + if (!asioPipeHandle_.is_open()) { + return; + } + boost::system::error_code ec; + asioPipeHandle_.cancel(ec); + if (ec) { + pmlog_warn("Failure cancelling pipe operations").pmwatch(ec.message()); + } } - bool DuplexPipe::WaitForAvailability(const std::string& baseName, uint32_t timeoutMs, bool noSuffix, uint32_t pollPeriodMs) + void DuplexPipe::Close() + { + Cancel_(); + boost::system::error_code ec; + if (asioPipeHandle_.is_open()) { + asioPipeHandle_.close(ec); + if (ec) { + pmlog_warn("Failure closing pipe").pmwatch(ec.message()); + } + } + rawPipeHandle_.Clear(); + // After the handle is closed, wake CoroMutex waiters so they observe + // the closed pipe and return instead of resuming into a write. + writeMtx_.CancelWaiters(); + readMtx_.CancelWaiters(); + } + size_t DuplexPipe::GetWriteBufferPending() const + { + return writeBuf_.size(); + } + bool DuplexPipe::WaitForAvailability(const std::string& name, uint32_t timeoutMs, uint32_t pollPeriodMs) { - const auto name = baseName + (noSuffix ? "" : "-in"); const auto start = std::chrono::high_resolution_clock::now(); while (std::chrono::high_resolution_clock::now() - start < 1ms * timeoutMs) { if (WaitNamedPipeA(name.c_str(), 0)) { @@ -100,9 +173,8 @@ namespace pmon::util::pipe } return false; } - bool DuplexPipe::WaitForVacancy(const std::string& baseName, uint32_t timeoutMs, bool noSuffix, uint32_t pollPeriodMs) + bool DuplexPipe::WaitForVacancy(const std::string& name, uint32_t timeoutMs, uint32_t pollPeriodMs) { - const auto name = baseName + (noSuffix ? "" : "-out"); const auto start = std::chrono::high_resolution_clock::now(); while (std::chrono::high_resolution_clock::now() - start < 1ms * timeoutMs) { if (!WaitNamedPipeA(name.c_str(), 0)) { @@ -124,6 +196,16 @@ namespace pmon::util::pipe { return name_; } + uint32_t DuplexPipe::GetClientProcessId() const + { + // asio only exposes native_handle() as non-const; reading it does not modify the handle + const auto handle = const_cast(asioPipeHandle_).native_handle(); + ULONG pid = 0; + if (!GetNamedPipeClientProcessId(handle, &pid)) { + throw Except("Failed getting pipe client process id"); + } + return (uint32_t)pid; + } bool DuplexPipe::TryGetConnectedServerProcessId(uint32_t& serverProcessId) noexcept { if (!asioPipeHandle_.is_open()) { @@ -135,14 +217,14 @@ namespace pmon::util::pipe { switch (mode) { default:case SecurityMode::None: return {}; - case SecurityMode::Service: return "D:P(A;;GA;;;AU)S:(ML;;NW;;;LW)"s; - case SecurityMode::Child: return "D:(A;OICI;GA;;;WD)"s; } } std::string DuplexPipe::GetServiceControlPipeSecurityString() { - // SY: create/listen; AU: client read/write without FILE_CREATE_PIPE_INSTANCE or DACL change - return std::format("D:P(A;;GA;;;SY)(A;;0x{:x};;;AU)S:(ML;;NW;;;LW)", kClientPipeConnectAccess); + // SY: create/listen; AU: client read/write without FILE_CREATE_PIPE_INSTANCE or DACL change. + // ME label: no write (so no connect) from processes below medium integrity; AU alone does not + // imply an integrity level. + return std::format("D:P(A;;GA;;;SY)(A;;0x{:x};;;AU)S:(ML;;NW;;;ME)", kClientPipeConnectAccess); } DWORD DuplexPipe::GetClientPipeConnectAccessMask() noexcept { @@ -160,9 +242,10 @@ namespace pmon::util::pipe return std::format("D:(A;;GA;;;{})", userSid); } - DuplexPipe::DuplexPipe(as::io_context& ioctx, HANDLE pipeHandle, std::string name, bool asClient) + DuplexPipe::DuplexPipe(as::io_context& ioctx, HANDLE pipeHandle, std::string name, bool connected, PipeLimits limits) : name_{ std::move(name) }, + limits_{ limits }, rawPipeHandle_{ pipeHandle }, asioPipeHandle_{ ioctx }, readStream_{ &readBuf_ }, @@ -172,8 +255,8 @@ namespace pmon::util::pipe writeArchive_{ writeStream_ }, writeMtx_{ ioctx } { - if (asClient) { - // client is automatically connected upon creation, so immediatly transfer pipe to asio + if (connected) { + // a client end, or either end of a connected pair, needs no accept, so transfer the pipe to asio now asioPipeHandle_.assign(rawPipeHandle_.Release()); } } @@ -193,7 +276,7 @@ namespace pmon::util::pipe } return handle.Release(); } - HANDLE DuplexPipe::Make_(const std::string& name, const std::string& security) + HANDLE DuplexPipe::Make_(const std::string& name, const std::string& security, bool singleInstance) { pmlog_dbg(std::format("Creating instance of [{}] with security [{}]", name, security)); // structure required for creating named pipe, create with placeholder pointer for descriptor @@ -210,11 +293,12 @@ namespace pmon::util::pipe } // if we have a security string, call create pipe with above structure, else call with nullptr SECURITY_ATTRIBUTES* pSecurityAttributes = security.empty() ? nullptr : &securityAttributes; + // a single instance cannot be joined by a second server, and creating it fails if the name exists win::Handle handle(CreateNamedPipeA( name.c_str(), - PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED, + PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED | (singleInstance ? FILE_FLAG_FIRST_PIPE_INSTANCE : 0), PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_REJECT_REMOTE_CLIENTS, - PIPE_UNLIMITED_INSTANCES, + singleInstance ? 1 : PIPE_UNLIMITED_INSTANCES, 4096, 4096, 0, @@ -231,9 +315,12 @@ namespace pmon::util::pipe if (timeoutMs) { const auto result = co_await(as::async_read(asioPipeHandle_, readBuf_, as::transfer_exactly(byteCount), as::as_tuple(as::use_awaitable)) || Timeout_(*timeoutMs)); - // 2nd index active means timed out + // 2nd index active means the timer finished first: expired, or cancelled from outside if (result.index() == 1) { - throw Except("Timeout during read"); + if (std::get<1>(result)) { + throw Except("Timeout during read"); + } + throw Except("Read canceled"); } // otherwise 1st index active => extract error code and transform auto&& [ec, n] = std::get<0>(result); @@ -250,9 +337,12 @@ namespace pmon::util::pipe if (timeoutMs) { const auto result = co_await(as::async_write(asioPipeHandle_, writeBuf_, as::as_tuple(as::use_awaitable)) || Timeout_(*timeoutMs)); - // 2nd index active means timed out + // 2nd index active means the timer finished first: expired, or cancelled from outside if (result.index() == 1) { - throw Except("Timeout during write"); + if (std::get<1>(result)) { + throw Except("Timeout during write"); + } + throw Except("Write canceled"); } // otherwise 1st index active => extract error code and transform auto&& [ec, n] = std::get<0>(result); @@ -263,11 +353,15 @@ namespace pmon::util::pipe TransformError_(ec); } } - as::awaitable DuplexPipe::Timeout_(uint32_t ms) + as::awaitable DuplexPipe::Timeout_(uint32_t ms) { as::deadline_timer timer{ co_await as::this_coro::executor }; timer.expires_from_now(boost::posix_time::millisec{ ms }); - co_await timer.async_wait(as::use_awaitable); + // Losing the race to the I/O cancels this wait. That is the normal case, so it + // completes quietly instead of throwing; only a real expiry reports true. + auto ec = boost::system::error_code{}; + co_await timer.async_wait(as::redirect_error(as::use_awaitable, ec)); + co_return !ec; } void DuplexPipe::TransformError_(const boost::system::error_code& ec) diff --git a/IntelPresentMon/CommonUtilities/pipe/Pipe.h b/IntelPresentMon/CommonUtilities/pipe/Pipe.h index 4506cbd3e..c00540743 100644 --- a/IntelPresentMon/CommonUtilities/pipe/Pipe.h +++ b/IntelPresentMon/CommonUtilities/pipe/Pipe.h @@ -12,6 +12,8 @@ #include "../log/Log.h" #include "SecurityMode.h" #include "CoroMutex.h" +#include +#include #include namespace pmon::util::pipe @@ -20,6 +22,17 @@ namespace pmon::util::pipe using namespace std::literals; PM_DEFINE_EX(PipeError); + // A read that had a deadline and missed it. Distinct from a peer disconnect so + // the session owner can report SessionEndReason::PeerStalled. + PM_DEFINE_EX_FROM(PipeError, PipeReadTimeout); + // A write that had a deadline and missed it: the peer stopped reading. Part of the packet + // may already be on the wire, so the stream can no longer be trusted and the session owner + // must end the session (SessionEndReason::PeerStalled). + PM_DEFINE_EX_FROM(PipeError, PipeWriteTimeout); + // A write whose deadline passed while it waited for the write lock (another write held it), + // or that had no time left once it got the lock. Nothing was written and the stream is + // intact, so this fails only that write; the writer holding the lock answers for the stall. + PM_DEFINE_EX_FROM(PipeError, PipeSendQueueTimeout); // pipe errors that are often part of acceptable program flow PM_DEFINE_EX_FROM(PipeError, BenignPipeError); // pipe was broken (closed by remote) @@ -27,6 +40,20 @@ namespace pmon::util::pipe // pipe operation was canceled (e.g. by operator ||, ioctx.stop) PM_DEFINE_EX_FROM(BenignPipeError, PipeOperationCanceled); + // Bounds imposed on the length prefix of an incoming packet. The declared body + // length is remote input, so it is validated before the receive buffer is grown + // to hold it. Owners with a known packet shape pass tighter limits at construction. + struct PipeLimits + { + uint32_t minBodyBytes = 1; + uint32_t maxBodyBytes = 1024 * 1024; + // once a length has been declared the sender has committed, so a stall is abuse + uint32_t bodyReadTimeoutMs = 2000; + }; + + // Written into the stream before the body is serialized, then overwritten in place. + inline constexpr uint32_t kPacketBodyLengthPlaceholder = 0x54454D50u; + class DuplexPipe { public: @@ -36,21 +63,48 @@ namespace pmon::util::pipe DuplexPipe& operator=(DuplexPipe&&) = delete; ~DuplexPipe() = default; as::awaitable Accept(); - static DuplexPipe Connect(const std::string& name, as::io_context& ioctx); - static DuplexPipe Make(const std::string& name, as::io_context& ioctx, const std::string& security = {}); - static std::unique_ptr ConnectAsPtr(const std::string& name, as::io_context& ioctx); - static std::unique_ptr MakeAsPtr(const std::string& name, as::io_context& ioctx, - const std::string& security = {}); + static DuplexPipe Connect(const std::string& name, as::io_context& ioctx, PipeLimits limits = {}); + static std::unique_ptr ConnectAsPtr(const std::string& name, as::io_context& ioctx, PipeLimits limits = {}); + static std::unique_ptr MakeAsPtr(const std::string& name, as::io_context& ioctx, const std::string& security = {}, PipeLimits limits = {}); + // Both ends of a single-instance pipe, created and connected by this process, so no + // other process can ever connect to it. The client end stays a plain handle, to be + // handed to another process; it must not be given to asio here, because the I/O + // completion port association belongs to the file object both handles share. + struct ConnectedPair + { + std::unique_ptr pServer; + win::Handle client; + }; + // Throws PipeError if the name is already in use or the pair cannot be verified; + // the caller retries with a fresh name. + static ConnectedPair MakeConnectedPair(const std::string& name, as::io_context& ioctx, + const std::string& security, PipeLimits limits = {}); + // Wraps the client end of a connected pair received from the process that created it. + static std::unique_ptr AdoptConnectedClientAsPtr(HANDLE clientHandle, as::io_context& ioctx, PipeLimits limits = {}); + // timeoutMs, when given, is one deadline covering the wait for the write lock and the + // write: PipeSendQueueTimeout if it passes before any byte is sent, PipeWriteTimeout if + // it passes during the write. template as::awaitable WritePacket(const H& header, const P& payload, std::optional timeoutMs = {}) { + std::optional deadline; + std::optional lockTimeout; + if (timeoutMs) { + lockTimeout = std::chrono::milliseconds{ *timeoutMs }; + deadline = std::chrono::steady_clock::now() + *lockTimeout; + } // lock while this coro is running to prevent other coros from causing an overlapped operation fault - auto lk = co_await CoroLock(writeMtx_); - // some sanity checks + auto lk = co_await CoroLock(writeMtx_, lockTimeout); + if (lk.GetResult() == CoroMutex::LockResult::TimedOut) { + throw Except("Timeout waiting to write"); + } + // Close cancels a waiter. Do not write on the closed handle. + if (!lk || !asioPipeHandle_.is_open()) { + throw Except("Write on closed pipe"); + } assert(writeBuf_.size() == 0); - assert(asioPipeHandle_.is_open()); // first we directly write bytes for the size of the body as a placeholder until we know how many are serialized - const uint32_t placeholderSize = 'TEMP'; + const uint32_t placeholderSize = kPacketBodyLengthPlaceholder; writeStream_.write(reinterpret_cast(&placeholderSize), sizeof(placeholderSize)); // record how many bytes used for the serialization of the size const auto sizeSize = writeBuf_.size(); @@ -58,29 +112,61 @@ namespace pmon::util::pipe writeArchive_(header, payload); // calculate size of body const auto payloadSize = uint32_t(writeBuf_.size() - sizeSize); + if (payloadSize < limits_.minBodyBytes || payloadSize > limits_.maxBodyBytes) { + pmlog_error("Packet body size out of range").pmwatch(payloadSize); + writeBuf_.consume(writeBuf_.size()); + throw Except("Packet body size out of range"); + } // replace the placeholder with the actual body size const auto pSizeInPlace = const_cast(&*as::buffers_begin(writeBuf_.data())); auto replacement = std::string_view{ reinterpret_cast(&payloadSize), sizeof(payloadSize) }; std::ranges::copy(replacement, pSizeInPlace); - // transmit the packet - co_await Write_(timeoutMs); + // what is left of the deadline, measured after serialization, right before the I/O + std::optional writeTimeoutMs; + if (deadline) { + const auto remaining = std::chrono::duration_cast( + *deadline - std::chrono::steady_clock::now()); + if (remaining <= std::chrono::milliseconds::zero()) { + writeBuf_.consume(writeBuf_.size()); + throw Except("No time left to write"); + } + writeTimeoutMs = (uint32_t)remaining.count(); + } + // transmit the packet; on failure drop what was not sent, so the next writer starts + // from a clean buffer (the stream itself may hold a partial packet, see PipeWriteTimeout) + try { + co_await Write_(writeTimeoutMs); + } + catch (...) { + writeBuf_.consume(writeBuf_.size()); + throw; + } } + // The wait for the length prefix has no limit, because a peer may stay idle between + // packets for any length of time. Once a length has been declared, the body is + // bounded by PipeLimits::bodyReadTimeoutMs. template - as::awaitable ReadPacketConsumeHeader(std::optional timeoutMs = {}) + as::awaitable ReadPacketConsumeHeader() { // lock while this coro is running to prevent other coros from interrupting stream sequence // and/or causing an overlapped operation fault auto lk = co_await CoroLock(readMtx_); - // some sanity checks + if (!lk || !asioPipeHandle_.is_open()) { + throw Except("Read on closed pipe"); + } assert(readBuf_.size() == 0); - assert(asioPipeHandle_.is_open()); // read in request // first read the number of bytes in the request payload (always 4-byte read) uint32_t payloadSize; - co_await Read_(sizeof(payloadSize), timeoutMs); + co_await Read_(sizeof(payloadSize)); readStream_.read(reinterpret_cast(&payloadSize), sizeof(payloadSize)); + // validate the declared length before growing the receive buffer to fit it + if (payloadSize < limits_.minBodyBytes || payloadSize > limits_.maxBodyBytes) { + pmlog_error("Packet body size out of range").pmwatch(payloadSize); + throw Except("Packet body size out of range"); + } // read the payload - co_await Read_(payloadSize, timeoutMs); + co_await Read_(payloadSize, limits_.bodyReadTimeoutMs); // deserialize header portion of request payload H header; readArchive_(header); @@ -93,20 +179,34 @@ namespace pmon::util::pipe P ConsumePacketPayload() { P payload; - readArchive_(payload); + try { + readArchive_(payload); + } + catch (...) { + DiscardPacketPayload(); + throw; + } if (const auto sz = readBuf_.size()) { assert("unexpected data when reading packet payload from buffer!!" && false); - pmlog_warn(std::format("Buffer contained unexpected data of size", sz)); + pmlog_warn(std::format("Buffer contained unexpected data of size {}", sz)); readBuf_.consume(sz); } return payload; } + // drops a packet body without deserializing it, for when the concrete payload + // type is unknown but the stream must be left positioned at the next packet + void DiscardPacketPayload(); + // Aborts this side's in-flight reads and writes, then closes the handle so the + // peer observes a disconnect. A coroutine suspended on the pipe resumes with + // PipeOperationCanceled and unwinds through its own cleanup. + void Close(); size_t GetWriteBufferPending() const; - void ClearWriteBuffer(); - static bool WaitForAvailability(const std::string& baseName, uint32_t timeoutMs, bool noSuffix = false, uint32_t pollPeriodMs = 5); - static bool WaitForVacancy(const std::string& baseName, uint32_t timeoutMs, bool noSuffix = false, uint32_t pollPeriodMs = 5); + static bool WaitForAvailability(const std::string& name, uint32_t timeoutMs, uint32_t pollPeriodMs = 5); + static bool WaitForVacancy(const std::string& name, uint32_t timeoutMs, uint32_t pollPeriodMs = 5); uint32_t GetId() const; std::string GetName() const; + // server side only, after Accept + uint32_t GetClientProcessId() const; // Client-side only: pipe handle must be connected (post-CreateFile). bool TryGetConnectedServerProcessId(uint32_t& serverProcessId) noexcept; static std::string GetSecurityString(SecurityMode mode); @@ -116,18 +216,23 @@ namespace pmon::util::pipe static DWORD GetClientPipeConnectAccessMask() noexcept; private: // functions - DuplexPipe(as::io_context& ioctx, HANDLE pipeHandle, std::string name, bool asClient); + // connected: the handle needs no accept (a client end, or either end of a connected pair) + DuplexPipe(as::io_context& ioctx, HANDLE pipeHandle, std::string name, bool connected, PipeLimits limits); static HANDLE Connect_(const std::string& name); - static HANDLE Make_(const std::string& name, const std::string& security = {}); + static HANDLE Make_(const std::string& name, const std::string& security = {}, bool singleInstance = false); + // aborts any in-flight read or write on this pipe; used by Close + void Cancel_(); // wrapper to convert EOF system_error to PipeBroken error, with optional timeout as::awaitable Read_(size_t byteCount, std::optional timeoutMs = {}); // wrapper to convert EOF system_error to PipeBroken error, with optional timeout as::awaitable Write_(std::optional timeoutMs = {}); - as::awaitable Timeout_(uint32_t ms); + // true when the deadline expired, false when the wait was cancelled; never throws + as::awaitable Timeout_(uint32_t ms); void TransformError_(const boost::system::error_code& ec); // data static std::atomic nextUid_; std::string name_; + PipeLimits limits_; uint32_t uid_ = nextUid_++; win::Handle rawPipeHandle_; as::windows::stream_handle asioPipeHandle_; diff --git a/IntelPresentMon/CommonUtilities/pipe/SecurityMode.h b/IntelPresentMon/CommonUtilities/pipe/SecurityMode.h index 5a07d68c4..b6263cd20 100644 --- a/IntelPresentMon/CommonUtilities/pipe/SecurityMode.h +++ b/IntelPresentMon/CommonUtilities/pipe/SecurityMode.h @@ -4,7 +4,5 @@ namespace pmon::util::pipe enum class SecurityMode { None, - Service, - Child, }; } \ No newline at end of file diff --git a/IntelPresentMon/CommonUtilities/win/Privileges.cpp b/IntelPresentMon/CommonUtilities/win/Privileges.cpp index d580d2605..b5f7ade40 100644 --- a/IntelPresentMon/CommonUtilities/win/Privileges.cpp +++ b/IntelPresentMon/CommonUtilities/win/Privileges.cpp @@ -2,6 +2,7 @@ #include "HrError.h" #include "../log/Log.h" #include +#include #include "../Memory.h" namespace pmon::util::win @@ -128,4 +129,27 @@ namespace pmon::util::win return false; } } + bool WeAreBelowHighIntegrity() noexcept + { + try { + auto hToken = OpenCurrentProcessToken(TOKEN_QUERY); + DWORD size = 0; + GetTokenInformation(hToken, TokenIntegrityLevel, nullptr, 0, &size); + if (size == 0) { + throw Except("Failed sizing token integrity level"); + } + std::vector buffer(size); + if (!GetTokenInformation(hToken, TokenIntegrityLevel, buffer.data(), size, &size)) { + throw Except("Failed reading token integrity level"); + } + const auto pSid = reinterpret_cast(buffer.data())->Label.Sid; + // the integrity level is the last subauthority of the label SID + const auto rid = *GetSidSubAuthority(pSid, (DWORD)(*GetSidSubAuthorityCount(pSid) - 1)); + return rid < SECURITY_MANDATORY_HIGH_RID; + } + catch (...) { + pmlog_error("Error checking integrity level"); + return false; + } + } } \ No newline at end of file diff --git a/IntelPresentMon/CommonUtilities/win/Privileges.h b/IntelPresentMon/CommonUtilities/win/Privileges.h index 1b19c3191..a5bd99a85 100644 --- a/IntelPresentMon/CommonUtilities/win/Privileges.h +++ b/IntelPresentMon/CommonUtilities/win/Privileges.h @@ -17,4 +17,7 @@ namespace pmon::util::win void EnablePrivilege(HANDLE hToken, const std::string& privName); TokenPack PrepareMediumIntegrityToken(); bool WeAreElevated() noexcept; + // Reads the token's integrity level, not its elevation type: a medium-integrity token + // restricted from an elevated one still reports itself as elevated. False on error. + bool WeAreBelowHighIntegrity() noexcept; } \ No newline at end of file diff --git a/IntelPresentMon/Core/source/cli/CliOptions.h b/IntelPresentMon/Core/source/cli/CliOptions.h index a39a4fc49..b73a55203 100644 --- a/IntelPresentMon/Core/source/cli/CliOptions.h +++ b/IntelPresentMon/Core/source/cli/CliOptions.h @@ -1,7 +1,6 @@ #pragma once #include #include -#include #include #include @@ -55,8 +54,6 @@ namespace p2c::cli Option logDenyList{ this, "--log-deny-list", "", "Path to log deny list (with trace overrides)", CLI::ExistingFile }; Option logAllowList{ this, "--log-allow-list", "", "Path to log allow list (with trace overrides)", CLI::ExistingFile }; Option logFolder{ this, "--log-folder", "", "Path to directory in which to store log files", CLI::ExistingDirectory }; - Option logSvcPipe{ this, "--log-svc-pipe", ::pmon::gid::defaultLogPipeBaseName, "Base name of pipe to use when connecting to service IPC log" }; - Flag logSvcPipeEnable{ this, "--log-svc-pipe-enable", "Enable pipe connection to service IPC log stream" }; Flag logMiddlewareCopy{ this, "--log-middleware-copy", "Copy log entries from middleware channel to this client" }; Flag logSynchronous{ this, "--log-synchronous", "Enable synchronous logging (submit waits for processing and flush)" }; Option> logVerboseModules{ this, "--log-verbose-modules", {}, "Verbose logging modules to enable", logVmodTf_ }; diff --git a/IntelPresentMon/Core/source/infra/LogSetup.cpp b/IntelPresentMon/Core/source/infra/LogSetup.cpp index c8cc2d37e..b032ac9a9 100644 --- a/IntelPresentMon/Core/source/infra/LogSetup.cpp +++ b/IntelPresentMon/Core/source/infra/LogSetup.cpp @@ -261,7 +261,7 @@ namespace p2c try { const auto fullPipeName = R"(\\.\pipe\)" + pipePrefix; // wait maximum 1.5sec for pipe to be created - if (!pipe::DuplexPipe::WaitForAvailability(fullPipeName, 1500, true)) { + if (!pipe::DuplexPipe::WaitForAvailability(fullPipeName, 1500)) { pmlog_warn(std::format("Failed to connect to logging source server {} after waiting 1.5s", pipePrefix)); return; } diff --git a/IntelPresentMon/Core/source/win/HotkeyListener.cpp b/IntelPresentMon/Core/source/win/HotkeyListener.cpp index f5c7bc574..e5f457efb 100644 --- a/IntelPresentMon/Core/source/win/HotkeyListener.cpp +++ b/IntelPresentMon/Core/source/win/HotkeyListener.cpp @@ -26,8 +26,21 @@ namespace p2c::win } Hotkeys::~Hotkeys() { - pmlog_verb(v::core_hotkey)("Destroying hotkey processor"); + Stop(); + } + void Hotkeys::Stop() + { + { + std::lock_guard lk{ mtx_ }; + Handler_ = nullptr; + registeredHotkeys_.clear(); + } + if (!thread_.joinable()) { + return; + } + pmlog_verb(v::core_hotkey)("Stopping hotkey processor"); PostThreadMessageA(threadId_, WM_QUIT, 0, 0); + thread_.join(); } void Hotkeys::Kernel_() noexcept { @@ -221,6 +234,13 @@ namespace p2c::win // signal success return true; } + size_t Hotkeys::ClearAll() + { + std::lock_guard lk{ mtx_ }; + const auto count = registeredHotkeys_.size(); + registeredHotkeys_.clear(); + return count; + } win::ModSet Hotkeys::GatherModifiers_() const { win::ModSet mods; diff --git a/IntelPresentMon/Core/source/win/HotkeyListener.h b/IntelPresentMon/Core/source/win/HotkeyListener.h index 1420a71d2..c9ea55f9e 100644 --- a/IntelPresentMon/Core/source/win/HotkeyListener.h +++ b/IntelPresentMon/Core/source/win/HotkeyListener.h @@ -22,8 +22,13 @@ namespace p2c::win Hotkeys(const Hotkeys&) = delete; Hotkeys& operator=(const Hotkeys&) = delete; ~Hotkeys(); + // Clears the handler, posts WM_QUIT, and joins the listener thread. + // After this returns the handler cannot call into the action server. + void Stop(); bool BindAction(int action, win::Key key, win::ModSet mods); bool ClearAction(int action); + // Removes every binding and returns how many were removed. + size_t ClearAll(); void SetHandler(std::function handler); private: // types diff --git a/IntelPresentMon/Interprocess/source/act/ActionContext.h b/IntelPresentMon/Interprocess/source/act/ActionContext.h index afd1440d2..e0ace2c64 100644 --- a/IntelPresentMon/Interprocess/source/act/ActionContext.h +++ b/IntelPresentMon/Interprocess/source/act/ActionContext.h @@ -1,9 +1,20 @@ #pragma once +#include "SessionEndReason.h" +#include +#include namespace pmon::ipc::act { + // What the transport requires of a session context. The transport creates one per + // session and reads remotePid for logging and the OpenSession check; the application + // sets remotePid when the session opens. Everything else in it belongs to the application. + template + concept TransportSessionContext = std::default_initializable && requires(S stx) { + { stx.remotePid } -> std::same_as; + }; + template - concept HasCustomSessionDispose = requires(E ctx, typename E::SessionContextType stx) { - ctx.Dispose(stx); + concept HasCustomSessionDispose = requires(E ctx, typename E::SessionContextType stx, SessionDisposition disposition) { + ctx.Dispose(stx, disposition); }; -} \ No newline at end of file +} diff --git a/IntelPresentMon/Interprocess/source/act/ActionRunner.h b/IntelPresentMon/Interprocess/source/act/ActionRunner.h new file mode 100644 index 000000000..547766aa5 --- /dev/null +++ b/IntelPresentMon/Interprocess/source/act/ActionRunner.h @@ -0,0 +1,79 @@ +#pragma once +#include "SymmetricActionConnector.h" +#include "../../../CommonUtilities/log/Log.h" +#include +#include +#include +#include +#include +#include +#include + +namespace pmon::ipc::act +{ + // Plumbing shared by SymmetricActionClient and SymmetricActionServer. Each keeps its + // io thread state in a State object (held by shared_ptr) that has lifecycle, admission, + // ioctx and OnIoThread(). + + // Name for the io thread: the pipe name without the \\.\pipe\ prefix. + inline std::string MakeWorkerName(const std::string& pipeName) + { + constexpr std::string_view prefix = R"(\\.\pipe\)"; + if (pipeName.starts_with(prefix)) { + return pipeName.substr(prefix.size()); + } + return pipeName; + } + + // Queues coro on the io thread if the lifecycle is still Running. The admission gate + // counts it as in flight until it finishes, so shutdown waits for it. Returns false, + // without queuing anything, once shutdown has begun. + // The coroutine is posted, never started here: co_spawn alone runs it inline when the + // caller is on the io thread, while the gate is still held, and a failure continuation + // that reenters the gate (BeginShutdown, another dispatch) would then deadlock. + template + bool SpawnAdmitted(const std::shared_ptr& state, Coro&& coro) + { + return state->admission.TryAdmit(state->lifecycle, [&] { + as::post(state->ioctx, [state, coro = std::forward(coro)]() mutable { + as::co_spawn(state->ioctx, std::move(coro), [state](std::exception_ptr) { + state->admission.CompleteOne(); + }); + }); + }); + } + + // Destructor body for the client and server: begin shutdown, wait for Stopped, then + // join the io thread. Every failure terminates instead of detaching the thread, + // because it could still touch borrowed execution-context state after we return. + template + void StopAndJoinRunner(std::shared_ptr& state, std::thread& runner, const char* owner) + { + if (!state) { + return; + } + if (state->OnIoThread()) { + pmlog_error(std::format("Destroying the {} on its io thread is unsupported", owner)); + assert(false && "Destroying an action client or server on its io thread"); + util::log::GetDefaultChannel()->Flush(); + std::terminate(); + } + try { + state->BeginShutdown(); + } + catch (...) { + pmlog_error(util::ReportException(std::format("Failure starting {} shutdown", owner))); + util::log::GetDefaultChannel()->Flush(); + std::terminate(); + } + if (!state->lifecycle.WaitFor(kShutdownTimeout)) { + pmlog_error(std::format("{} runner did not stop after cancellation; refusing to detach", owner)); + util::log::GetDefaultChannel()->Flush(); + std::terminate(); + } + if (runner.joinable()) { + runner.join(); + } + state.reset(); + } +} diff --git a/IntelPresentMon/Interprocess/source/act/ActionServerShutdown.h b/IntelPresentMon/Interprocess/source/act/ActionServerShutdown.h new file mode 100644 index 000000000..844a24a42 --- /dev/null +++ b/IntelPresentMon/Interprocess/source/act/ActionServerShutdown.h @@ -0,0 +1,31 @@ +// Copyright (C) 2022 Intel Corporation +// SPDX-License-Identifier: MIT +#pragma once +#include "../../../CommonUtilities/log/Log.h" +#include +#include + +namespace pmon::ipc::act +{ + // Production service teardown and the transport test call this function. + // Order: mark final teardown, optional mid step, stop admission and join, + // destroy the server, record StopTraceSessions, then stop traces. + // A timeout terminates. It does not detach the runner. + template + void JoinActionServerThenStopTraces(Server& server, Mid&& mid, Destroy&& destroy, + StopTraces&& stopTraces, std::vector* events = nullptr) + { + server.EnterFinalTeardown(); + mid(); + server.BeginShutdown(); + if (!server.WaitForShutdown()) { + pmlog_error("Action server runner did not stop while PresentMon and ETW were still alive"); + std::terminate(); + } + destroy(); + if (events) { + events->push_back("StopTraceSessions"); + } + stopTraces(); + } +} diff --git a/IntelPresentMon/Interprocess/source/act/AdmissionGate.h b/IntelPresentMon/Interprocess/source/act/AdmissionGate.h new file mode 100644 index 000000000..cdeb238f3 --- /dev/null +++ b/IntelPresentMon/Interprocess/source/act/AdmissionGate.h @@ -0,0 +1,60 @@ +#pragma once +#include "Lifecycle.h" +#include +#include + +namespace pmon::ipc::act +{ + // Dispatch acceptance and the Running -> Stopping transition share this mutex. + // The coroutine is queued before the lock is released, so StopSequence cannot + // be ordered ahead of an accepted dispatch, and a rejected dispatch is never queued. + class AdmissionGate + { + public: + template + bool TryAdmit(const Lifecycle& lifecycle, Queue&& queue) + { + std::lock_guard lock{ mutex_ }; + if (!lifecycle.IsRunning()) { + return false; + } + if (hook_) { + hook_(); + } + inflight_.fetch_add(1, std::memory_order_acq_rel); + try { + queue(); + } + catch (...) { + inflight_.fetch_sub(1, std::memory_order_acq_rel); + throw; + } + return true; + } + template + void Lock(F&& fn) + { + std::lock_guard lock{ mutex_ }; + fn(); + } + void CompleteOne() noexcept + { + inflight_.fetch_sub(1, std::memory_order_acq_rel); + } + int Inflight() const noexcept + { + return inflight_.load(std::memory_order_acquire); + } + // Test seam. Invoked on the admitting thread while the gate is held and the + // phase is still Running, before the dispatch coroutine is queued. + // The hook must not re-enter the gate. + void SetHookForTest(void (*hook)()) + { + hook_ = hook; + } + private: + std::mutex mutex_; + std::atomic inflight_{ 0 }; + void (*hook_)() = nullptr; + }; +} diff --git a/IntelPresentMon/Interprocess/source/act/AsyncAction.h b/IntelPresentMon/Interprocess/source/act/AsyncAction.h index bd9376f56..f02a1cb80 100644 --- a/IntelPresentMon/Interprocess/source/act/AsyncAction.h +++ b/IntelPresentMon/Interprocess/source/act/AsyncAction.h @@ -43,19 +43,15 @@ namespace pmon::ipc::act } catch (...) { pmlog_error(util::ReportException()); - // if the output buffer is dirty, we're not sure what state we're in so just clear it - if (pipe.GetWriteBufferPending()) { - pipe.ClearWriteBuffer(); - } resHeader = MakeResponseHeader(header, TransportStatus::TransportFailure, PM_STATUS_SUCCESS); } if (resHeader.transportStatus == TransportStatus::Success) { // if no errors occured transmit standard packet with header and action response payload - co_await pipe.WritePacket(resHeader, output, ctx.responseWriteTimeoutMs); + co_await pipe.WritePacket(resHeader, output, ctx.responseWriteTimeoutMs.value_or(kDefaultWriteTimeoutMs)); } else { // if there was an error, transmit header (configured with error status) and empty payload - co_await pipe.WritePacket(resHeader, EmptyPayload{}, ctx.responseWriteTimeoutMs); + co_await pipe.WritePacket(resHeader, EmptyPayload{}, ctx.responseWriteTimeoutMs.value_or(kDefaultWriteTimeoutMs)); } } const char* GetIdentifier() const final @@ -97,9 +93,10 @@ namespace pmon::ipc::act struct ActionParamsTraits; template - concept Request = std::is_base_of_v, A> && requires { + concept Request = requires { typename A::Params; typename A::Response; + { A::Identifier } -> std::convertible_to; }; template @@ -112,7 +109,4 @@ namespace pmon::ipc::act template using ResponseFromParams = typename ActionFromParams::Response; - - template - using AwaitableFromParams = pipe::as::awaitable>; } diff --git a/IntelPresentMon/Interprocess/source/act/CommandTokenAllocator.h b/IntelPresentMon/Interprocess/source/act/CommandTokenAllocator.h new file mode 100644 index 000000000..2a36aa03b --- /dev/null +++ b/IntelPresentMon/Interprocess/source/act/CommandTokenAllocator.h @@ -0,0 +1,37 @@ +#pragma once +#include "../../../CommonUtilities/Exception.h" +#include + +namespace pmon::ipc::act +{ + // Allocates command tokens for one direction of one session. + // A token is a routing id, not an authentication or authority value. + // Construction from an explicit starting value is the seam tests use to + // exercise wraparound; production callers keep the default of zero. + class CommandTokenAllocator + { + public: + CommandTokenAllocator() = default; + explicit CommandTokenAllocator(uint32_t next) + : + next_{ next } + {} + // Skips values for which unavailable(token) is true so a wrap cannot + // collide with a pending request or a retained expiration record. + // Throws if no free value is found within the scan bound. + template + uint32_t Allocate(Unavailable&& unavailable) + { + constexpr uint32_t kMaxScan = 65536; + for (uint32_t attempt = 0; attempt < kMaxScan; ++attempt) { + const auto token = next_++; + if (!unavailable(token)) { + return token; + } + } + throw pmon::util::Except("Command token space exhausted"); + } + private: + uint32_t next_ = 0; + }; +} diff --git a/IntelPresentMon/Interprocess/source/act/Lifecycle.h b/IntelPresentMon/Interprocess/source/act/Lifecycle.h new file mode 100644 index 000000000..0977712a5 --- /dev/null +++ b/IntelPresentMon/Interprocess/source/act/Lifecycle.h @@ -0,0 +1,65 @@ +#pragma once +#include +#include +#include +#include + +namespace pmon::ipc::act +{ + // Explicit session-owner lifecycle. io_context::stopped() is not this state: + // it only reports an executor condition and cannot tell Stopping from Stopped. + enum class LifecyclePhase : uint8_t + { + Running, + Stopping, + Stopped, + }; + + // The Running -> Stopping transition is owned by exactly one caller. + // MarkStopped settles the shared future exactly once. A waiter that times out + // leaves that future and the shutdown work with the shared state that owns them. + class Lifecycle + { + public: + Lifecycle() + : + stoppedFuture_{ stopped_.get_future() } + {} + Lifecycle(const Lifecycle&) = delete; + Lifecycle& operator=(const Lifecycle&) = delete; + + bool TryBeginShutdown() noexcept + { + auto expected = LifecyclePhase::Running; + return phase_.compare_exchange_strong( + expected, LifecyclePhase::Stopping, + std::memory_order_acq_rel, std::memory_order_acquire); + } + void MarkStopped() + { + phase_.store(LifecyclePhase::Stopped, std::memory_order_release); + if (!published_.exchange(true, std::memory_order_acq_rel)) { + stopped_.set_value(); + } + } + bool IsRunning() const noexcept + { + return Get() == LifecyclePhase::Running; + } + LifecyclePhase Get() const noexcept + { + return phase_.load(std::memory_order_acquire); + } + // True once Stopped has been published. False when the timeout elapses first; + // the completion future stays owned by this object either way. + bool WaitFor(std::chrono::milliseconds timeout) const + { + return stoppedFuture_.wait_for(timeout) == std::future_status::ready; + } + private: + std::atomic phase_{ LifecyclePhase::Running }; + std::atomic published_{ false }; + std::promise stopped_; + std::shared_future stoppedFuture_; + }; +} diff --git a/IntelPresentMon/Interprocess/source/act/Packet.h b/IntelPresentMon/Interprocess/source/act/Packet.h index a4e68bca7..1ddcfbaea 100644 --- a/IntelPresentMon/Interprocess/source/act/Packet.h +++ b/IntelPresentMon/Interprocess/source/act/Packet.h @@ -18,6 +18,28 @@ namespace pmon::ipc::act ActionEvent, }; + // Version 2 carries the single-pipe demultiplexed protocol: requests, responses + // and events share one full-duplex handle and are told apart by packetType plus + // commandToken. There is no compatibility with version 1 peers. + inline constexpr uint16_t kHeaderVersion = 2; + + // The identifier is remote input used as a map key, so its length is bounded. + inline constexpr size_t kMaxIdentifierLength = 64; + + // Smallest body that can hold a serialized PacketHeader with an empty identifier + // and an empty payload. MinimalPacketBodySizeMatchesProtocolFloor asserts this + // against the real serialized size rather than trusting the constant. + inline constexpr uint32_t kMinPacketBodyBytes = 28; + + // Generous ceiling relative to the largest real packet on either channel. Bodies + // are rejected against this before the receive buffer is grown to hold them. + inline constexpr uint32_t kMaxPacketBodyBytes = 1024 * 1024; + + // Deadline for writing a packet whose sender has no deadline of its own (events, and + // responses when the execution context sets no responseWriteTimeoutMs). A healthy peer + // drains the pipe as fast as it is written, so only one that has stopped reading trips it. + inline constexpr uint32_t kDefaultWriteTimeoutMs = 10000; + struct PacketHeader { std::string identifier{}; @@ -39,8 +61,22 @@ namespace pmon::ipc::act inline PacketHeader MakeResponseHeader(const PacketHeader& reqHeader, TransportStatus txs, int exs) { auto resHeader = reqHeader; + // the reader loop on the far side demultiplexes on this field, so tagging the + // response (rather than echoing the request tag) is what makes routing work + resHeader.packetType = PacketType::ActionResponse; resHeader.transportStatus = txs; resHeader.executionStatus = exs; return resHeader; } -} \ No newline at end of file + + // Structural screening of a freshly deserialized header. A header that fails + // this cannot be dispatched safely, so the session that sent it is terminated. + inline bool IsPlausibleHeader(const PacketHeader& header) + { + return header.headerVersion == kHeaderVersion && + (int)header.packetType >= (int)PacketType::ActionRequest && + (int)header.packetType <= (int)PacketType::ActionEvent && + !header.identifier.empty() && + header.identifier.size() <= kMaxIdentifierLength; + } +} diff --git a/IntelPresentMon/Interprocess/source/act/ResponseRouter.h b/IntelPresentMon/Interprocess/source/act/ResponseRouter.h new file mode 100644 index 000000000..40b8cf242 --- /dev/null +++ b/IntelPresentMon/Interprocess/source/act/ResponseRouter.h @@ -0,0 +1,97 @@ +#pragma once +#include "../../../CommonUtilities/pipe/Pipe.h" +#include "Packet.h" +#include "SessionEndReason.h" +#include + +namespace pmon::ipc::act +{ + namespace as = boost::asio; + + // One in-flight request that this side of a session has sent and is waiting on. + // Instances live on the requesting coroutine's frame for exactly as long as they + // are registered, so the router only ever holds a pointer. + class PendingResponse + { + public: + virtual ~PendingResponse() = default; + // Invoked on the reader coroutine when the matching response arrives. The + // implementation knows the concrete response type and must drain the payload + // before returning, because those bytes are sitting in the pipe read buffer + // and the reader loop issues its next read as soon as this call completes. + void Complete(const PacketHeader& header, util::pipe::DuplexPipe& pipe) + { + settled_ = true; + OnComplete_(header, pipe); + } + // Invoked when the session ends before a response arrives. Every path that can + // end a session must reach this, or the requester waits forever. + void Fail(std::exception_ptr error) + { + settled_ = true; + OnFail_(std::move(error)); + } + // The router removes an entry from its map before settling it, and settling a + // waiter is the last thing a dying session does. So once this is true the + // registration must not touch the router again: it may already be destroyed. + bool IsSettled() const + { + return settled_; + } + protected: + virtual void OnComplete_(const PacketHeader& header, util::pipe::DuplexPipe& pipe) = 0; + virtual void OnFail_(std::exception_ptr error) = 0; + private: + bool settled_ = false; + }; + + // The subset of a connector that a request coroutine needs: somewhere to write and + // a registry that routes the eventual response back to it. Kept as an interface so + // the transfer layer does not have to know the connector template. + class ResponseRouter + { + public: + virtual ~ResponseRouter() = default; + virtual void RegisterPending(uint32_t commandToken, PendingResponse& pending) = 0; + // Registered -> Expired. The token leaves the pending map and is retained in a + // bounded expiration history. The first late response consumes that record + // (Expired -> LateResponseConsumed). A further response is a protocol violation. + virtual void ExpirePending(uint32_t commandToken) = 0; + // Next routing token that is neither pending nor retained as expired. + // Tokens are routing ids, not authority values. Called on the action io thread. + virtual uint32_t AllocateCommandToken() = 0; + virtual util::pipe::DuplexPipe& GetPipe() = 0; + virtual as::io_context& GetIoContext() = 0; + // Fails waiters and closes the pipe; the session then ends with `reason`. Io thread only. + virtual void EndSession(SessionEndReason reason) = 0; + }; + + // Guarantees that a token cannot be left registered by an exception on any path + // between writing a request and consuming its response. + class PendingRegistration + { + public: + PendingRegistration(ResponseRouter& router, uint32_t commandToken, PendingResponse& pending) + : + router_{ router }, + pending_{ pending }, + commandToken_{ commandToken } + { + router_.RegisterPending(commandToken_, pending_); + } + PendingRegistration(const PendingRegistration&) = delete; + PendingRegistration& operator=(const PendingRegistration&) = delete; + PendingRegistration(PendingRegistration&&) = delete; + PendingRegistration& operator=(PendingRegistration&&) = delete; + ~PendingRegistration() + { + if (!pending_.IsSettled()) { + router_.ExpirePending(commandToken_); + } + } + private: + ResponseRouter& router_; + PendingResponse& pending_; + uint32_t commandToken_; + }; +} diff --git a/IntelPresentMon/Interprocess/source/act/ServerSendPolicy.h b/IntelPresentMon/Interprocess/source/act/ServerSendPolicy.h new file mode 100644 index 000000000..1130474c1 --- /dev/null +++ b/IntelPresentMon/Interprocess/source/act/ServerSendPolicy.h @@ -0,0 +1,47 @@ +#pragma once + +namespace pmon::ipc::act +{ + // Server-originated sends declare a policy in the type. + // There is no implicit "first session wins" selection. + // kPaired selects how sessions arrive: false, clients connect to named listeners; + // true, there are no listeners and each session is a pipe pair the server creates + // itself (OpenPairedSession) and hands one end of to a process it launches. + + // PresentMon service: clients request, the service responds. No server send. + struct RequestResponseServerPolicy + { + static constexpr bool kUnaddressedSend = false; + static constexpr bool kAddressedSend = false; + static constexpr bool kSinglePeer = false; + static constexpr bool kPaired = false; + }; + + // At most one active peer, and unaddressed sends target it. + struct SinglePeerServerPolicy + { + static constexpr bool kUnaddressedSend = true; + static constexpr bool kAddressedSend = false; + static constexpr bool kSinglePeer = true; + static constexpr bool kPaired = false; + }; + + // Kernel/UI channel: single peer, and that peer can only be the process the server + // handed a pipe pair to, since no listener exists. + struct PairedSinglePeerServerPolicy + { + static constexpr bool kUnaddressedSend = true; + static constexpr bool kAddressedSend = false; + static constexpr bool kSinglePeer = true; + static constexpr bool kPaired = true; + }; + + // A send names the session id returned when that session was admitted. + struct AddressedMultiPeerServerPolicy + { + static constexpr bool kUnaddressedSend = false; + static constexpr bool kAddressedSend = true; + static constexpr bool kSinglePeer = false; + static constexpr bool kPaired = false; + }; +} diff --git a/IntelPresentMon/Interprocess/source/act/SessionEndReason.h b/IntelPresentMon/Interprocess/source/act/SessionEndReason.h new file mode 100644 index 000000000..805839612 --- /dev/null +++ b/IntelPresentMon/Interprocess/source/act/SessionEndReason.h @@ -0,0 +1,45 @@ +#pragma once +#include "../../../CommonUtilities/Exception.h" +#include + +namespace pmon::ipc::act +{ + // Why an accepted session is ending. This is the transport cause and is not + // rewritten just because the service has entered final teardown. + enum class SessionEndReason + { + PeerDisconnected, + ProtocolFailure, + // the peer declared a packet length and then stopped sending its body, stopped + // reading what was sent to it, or did not open its session within the handshake deadline + PeerStalled, + LocalShutdown, + }; + + // Whether disposal may recompute process-global service state. + // Independent of SessionEndReason: a peer disconnect during final teardown + // keeps PeerDisconnected and still suppresses ETW restart. + enum class SessionCleanupMode : uint8_t + { + NormalOperation, + FinalTeardown, + }; + + struct SessionDisposition + { + SessionEndReason reason; + SessionCleanupMode cleanupMode = SessionCleanupMode::NormalOperation; + }; + + // Normal operation keeps the historical rule: every reason except LocalShutdown + // recomputes global tracking and telemetry. Final teardown never does. + inline bool ShouldRecomputeGlobalState(SessionDisposition disposition) + { + return disposition.cleanupMode == SessionCleanupMode::NormalOperation + && disposition.reason != SessionEndReason::LocalShutdown; + } + + // A response that cannot be matched to a pending or still-retained expired + // request. The body is drained before this is thrown so only this session ends. + PM_DEFINE_EX(ProtocolViolation); +} diff --git a/IntelPresentMon/Interprocess/source/act/SessionLimits.h b/IntelPresentMon/Interprocess/source/act/SessionLimits.h new file mode 100644 index 000000000..955188436 --- /dev/null +++ b/IntelPresentMon/Interprocess/source/act/SessionLimits.h @@ -0,0 +1,15 @@ +#pragma once +#include + +namespace pmon::ipc::act +{ + // Admission bounds for a server. maxSessions caps concurrent sessions. On a multi-peer + // server, a session that has not opened (set remotePid) within handshakeTimeoutMs is + // closed; once open it may idle indefinitely. A single-peer server already admits only + // one session and applies no handshake deadline. + struct SessionLimits + { + uint32_t maxSessions = 64; + uint32_t handshakeTimeoutMs = 3000; + }; +} diff --git a/IntelPresentMon/Interprocess/source/act/SymmetricActionClient.h b/IntelPresentMon/Interprocess/source/act/SymmetricActionClient.h index 2b292c8fd..eef49de89 100644 --- a/IntelPresentMon/Interprocess/source/act/SymmetricActionClient.h +++ b/IntelPresentMon/Interprocess/source/act/SymmetricActionClient.h @@ -2,13 +2,16 @@ // SPDX-License-Identifier: MIT #pragma once #include "SymmetricActionConnector.h" -#include "../../../CommonUtilities/str/String.h" -#include "../../../CommonUtilities/pipe/ManualAsyncEvent.h" -#include "../../../CommonUtilities/mt/Thread.h" -#include "AsyncActionCollection.h" +#include "AdmissionGate.h" +#include "Lifecycle.h" +#include "ActionRunner.h" +#include "../../../CommonUtilities/log/IdentificationTable.h" #include "ActionContext.h" +#include +#include +#include +#include #include -#include namespace pmon::ipc::act @@ -18,140 +21,316 @@ namespace pmon::ipc::act using namespace util; using namespace ipc; namespace as = boost::asio; - using namespace as::experimental::awaitable_operators; PM_DEFINE_EX(ActionClientError); PM_DEFINE_EX_FROM(ActionClientError, ServerDroppedError); template + requires TransportSessionContext class SymmetricActionClient { using SessionContextType = typename ExecCtx::SessionContextType; + using Connector = SymmetricActionConnector; + // Shared with the runner thread so shutdown never captures a raw this. + // The runner std::thread itself stays on this object so the io thread + // does not join itself. + struct State : std::enable_shared_from_this + { + Lifecycle lifecycle; + AdmissionGate admission; + as::io_context ioctx; + // Declared after ioctx so it is destroyed before the context its pipe uses. + std::shared_ptr conn; + SessionContextType stx; + ExecCtx ctx; + std::string basePipeName; + std::atomic runnerId{}; + // The reader strand owns a shared_ptr to this State. stop() must wait + // until it has exited, or the abandoned frame keeps the pipe alive. + std::atomic liveStrands_{ 0 }; + // Set on the io thread before reader-exit shutdown takes the admission + // lock, so a test can observe the check-to-post gap. + std::atomic readerExitShutdownEntered_{ false }; + + State(std::string pipeName, ExecCtx context, CommandTokenAllocator tokens) + : + ctx{ std::move(context) }, + basePipeName{ std::move(pipeName) } + { + conn = Connector::ConnectToServer(basePipeName, ioctx, std::move(tokens)); + } + // label stands in for the pipe name in logs and the worker thread name + State(util::win::Handle connectedPipe, std::string label, ExecCtx context, CommandTokenAllocator tokens) + : + ctx{ std::move(context) }, + basePipeName{ std::move(label) } + { + conn = Connector::AdoptConnectedClient(std::move(connectedPipe), ioctx, std::move(tokens)); + } + bool OnIoThread() const + { + return std::this_thread::get_id() == runnerId.load(std::memory_order_acquire); + } + void BeginShutdown() + { + admission.Lock([&] { + if (!lifecycle.TryBeginShutdown()) { + return; + } + auto self = this->shared_from_this(); + as::co_spawn(ioctx, [self]() mutable -> as::awaitable { + co_await self->StopSequence_(); + auto& io = self->ioctx; + self.reset(); + io.stop(); + }, as::detached); + }); + } + as::awaitable StopSequence_() + { + // A peer error already queued on this executor classifies itself + // before LocalShutdown can relabel it. + co_await as::post(co_await as::this_coro::executor, as::use_awaitable); + if (conn) { + conn->EndSession(SessionEndReason::LocalShutdown); + } + co_await as::post(co_await as::this_coro::executor, as::use_awaitable); + as::steady_timer timer{ co_await as::this_coro::executor }; + while (admission.Inflight() > 0 + || liveStrands_.load(std::memory_order_acquire) > 0) { + timer.expires_after(kShutdownPollPeriod); + co_await timer.async_wait(as::use_awaitable); + } + } + void Run() + { + runnerId.store(std::this_thread::get_id(), std::memory_order_release); + try { + auto self = this->shared_from_this(); + liveStrands_.fetch_add(1, std::memory_order_acq_rel); + as::co_spawn(ioctx, [self]() -> as::awaitable { + try { + co_await self->SessionStrand_(); + } + catch (...) { + pmlog_error(ReportException()); + } + self->liveStrands_.fetch_sub(1, std::memory_order_acq_rel); + }, as::detached); + ioctx.run(); + pmlog_info("ActionClient exiting"); + } + catch (...) { + lifecycle.MarkStopped(); + pmlog_error(ReportException()); + // if the action server crashes for any reason, the service should restart at this point + // TODO: don't make this mandatory for all uses of the server (client in this case) + log::GetDefaultChannel()->Flush(); + std::terminate(); + } + lifecycle.MarkStopped(); + } + as::awaitable SessionStrand_() + { + try { + co_await conn->RunReaderLoop(ctx, stx); + } + catch (...) { + pmlog_error(util::ReportException()); + } + // Same lock and queued stop sequence as BeginShutdown. run() cannot + // return until that sequence is posted, so a dispatch in TryAdmit + // is either queued first or rejected. + readerExitShutdownEntered_.store(true, std::memory_order_release); + BeginShutdown(); + pmlog_info("Exiting action client session strand"); + } + }; public: - SymmetricActionClient(std::string pipeName, ExecCtx context = {}) - : - basePipeName_{ std::move(pipeName) }, - ctx_{ std::move(context) } + SymmetricActionClient(std::string pipeName, ExecCtx context = {}, CommandTokenAllocator tokens = {}) + { + state_ = std::make_shared(std::move(pipeName), std::move(context), std::move(tokens)); + StartRunner_(); + } + // Takes the client end of a pipe pair created by the server's process (see + // SymmetricActionServer::OpenPairedSession). Takes win::Handle, not HANDLE: a string + // literal converts to void* ahead of std::string and would select this overload. + SymmetricActionClient(util::win::Handle connectedPipe, std::string label, ExecCtx context = {}, + CommandTokenAllocator tokens = {}) { - stx_.pConn = SymmetricActionConnector::ConnectToServer(basePipeName_, ioctx_); - as::co_spawn(ioctx_, SessionStrand_(), as::detached); - runner_ = mt::Thread{ std::format("symact-{}-cli", MakeWorkerName_(basePipeName_)), - &SymmetricActionClient::Run_, this }; + state_ = std::make_shared(std::move(connectedPipe), std::move(label), std::move(context), std::move(tokens)); + StartRunner_(); } SymmetricActionClient(const SymmetricActionClient&) = delete; SymmetricActionClient& operator=(const SymmetricActionClient&) = delete; SymmetricActionClient(SymmetricActionClient&&) = delete; SymmetricActionClient& operator=(SymmetricActionClient&&) = delete; + // Joins the runner before borrowed execution-context state is destroyed. + // Destroying this object on its own action io thread is unsupported. ~SymmetricActionClient() { - try { - ioctx_.stop(); - } - catch (...) { - pmlog_error("Failed to stop ioctx"); - } + StopAndJoinRunner(state_, runner_, "action client"); } + // Rejected when the lifecycle is no longer Running. Acceptance is decided + // under the same lock that queues the coroutine. An accepted call either + // returns a response or throws. A rejected call does not start. template - auto DispatchSync(Params&& params) + auto DispatchSync(Params&& params, std::optional responseTimeoutMs = {}) { - if (!IsRunning()) { - throw Except("Server dropped off (ioctx not running); cannot dispatch"); + // it would block the only thread that can read the response + if (state_->OnIoThread()) { + throw Except("DispatchSync cannot run on the action io thread"); } - return stx_.pConn->DispatchSync(std::forward(params), ioctx_, stx_); + using Result = ResponseFromParams; + using ParamsT = std::decay_t; + std::promise promise; + auto future = promise.get_future(); + // CAUTION: the coroutine below captures promise by reference. That is only safe because + // this function blocks on the future until the coroutine has set it; keep that wait if refactoring. + const auto remotePid = state_->stx.remotePid; + const auto accepted = SpawnAdmitted(state_, [conn = state_->conn, remotePid, + owned = ParamsT{ std::forward(params) }, &promise, responseTimeoutMs]() mutable -> as::awaitable { + try { + conn->LogOutgoing(remotePid); + auto result = co_await SyncRequest>(owned, *conn, responseTimeoutMs); + promise.set_value(std::move(result)); + } + catch (...) { + try { + promise.set_exception(std::current_exception()); + } + catch (...) { + } + } + }); + if (!accepted) { + throw Except("Action client is not running; cannot dispatch"); + } + return future.get(); } template void DispatchDetached(Params&& params) { - if (!IsRunning()) { - throw Except("Server dropped off (ioctx not running); cannot dispatch"); + using ParamsT = std::decay_t; + const auto remotePid = state_->stx.remotePid; + const auto accepted = SpawnAdmitted(state_, [conn = state_->conn, remotePid, + owned = ParamsT{ std::forward(params) }]() -> as::awaitable { + conn->LogOutgoing(remotePid); + if (conn->SessionHasEnded()) { + co_return; + } + try { + co_await AsyncEmit>(owned, *conn); + } + catch (...) { + pmlog_error(ReportException()); + } + }); + if (!accepted) { + throw Except("Action client is not running; cannot dispatch"); } - stx_.pConn->DispatchDetached(std::forward(params), ioctx_, stx_); } + // `cont` runs on the action io thread, exactly once for an accepted call, + // with either a response or an exception. It may BeginShutdown or start + // another async dispatch while Running. It must not WaitForShutdown, + // DispatchSync, or destroy this client. A rejected call does not invoke `cont`. template void DispatchWithContinuation(Params&& params, std::function&&, std::exception_ptr)> cont) { - if (!IsRunning()) { - throw Except("Server dropped off (ioctx not running); cannot dispatch"); + using ParamsT = std::decay_t; + const auto remotePid = state_->stx.remotePid; + const auto accepted = SpawnAdmitted(state_, [conn = state_->conn, remotePid, + owned = ParamsT{ std::forward(params) }, cont = std::move(cont)]() mutable -> as::awaitable { + conn->LogOutgoing(remotePid); + try { + if (conn->SessionHasEnded()) { + throw util::Except("Cannot send request, session has ended"); + } + auto res = co_await SyncRequest>(owned, *conn); + try { + cont(std::move(res), {}); + } + catch (...) { + pmlog_error(ReportException("Final failure in calling continuation")); + } + } + catch (...) { + pmlog_dbg(ReportException("Error in IPC dispatch")); + try { + cont({}, std::current_exception()); + } + catch (...) { + pmlog_error(ReportException("Final failure in calling continuation")); + } + } + }); + if (!accepted) { + throw Except("Action client is not running; cannot dispatch"); } - stx_.pConn->DispatchWithContinuation(std::forward(params), ioctx_, stx_, std::move(cont)); } bool IsRunning() const { - return !ioctx_.stopped(); + return state_ && state_->lifecycle.IsRunning(); } - - protected: - void EstablishSession_(uint32_t serverPid) + LifecyclePhase GetLifecyclePhase() const { - stx_.remotePid = serverPid; + return state_->lifecycle.Get(); } - const std::string& GetControlPipeBaseName_() const + bool ReaderExitShutdownEntered() const { - return basePipeName_; + return state_->readerExitShutdownEntered_.load(std::memory_order_acquire); } - SymmetricActionConnector& GetActionConnector_() + // Nonblocking. Safe on an application thread, the action io thread, and a continuation. + // Repeated calls observe the same Running -> Stopping transition. + void BeginShutdown() { - return *stx_.pConn; + state_->BeginShutdown(); } - const SymmetricActionConnector& GetActionConnector_() const + // Blocks the caller until Stopped or kShutdownTimeout. Rejected on the action io thread. + // A timeout leaves the runner alive; it does not detach it. False means not stopped. + bool WaitForShutdown() { - return *stx_.pConn; + if (state_->OnIoThread()) { + throw Except("WaitForShutdown cannot run on the action io thread"); + } + if (!state_->lifecycle.WaitFor(kShutdownTimeout)) { + pmlog_warn("Timed out waiting for action client shutdown"); + return false; + } + return true; + } + void SetAdmissionHookForTest(void (*hook)()) + { + state_->admission.SetHookForTest(hook); } - private: - // function - static std::string MakeWorkerName_(const std::string& pipeNameBase) + protected: + void EstablishSession_(uint32_t serverPid) { - constexpr std::string_view prefix = R"(\\.\pipe\)"; - std::string base; - if (pipeNameBase.starts_with(prefix)) { - base = pipeNameBase.substr(prefix.size()); - } - else { - base = pipeNameBase; - } - return base; + state_->stx.remotePid = serverPid; } - void Run_() + Connector& SessionConnector_() { - // TODO: investigate heap issue with this line - //log::IdentificationTable::AddThisThread("act-cli-worker"); - try { - ioctx_.run(); - pmlog_info("ActionClient exiting"); - } - catch (...) { - pmlog_error(ReportException()); - // if the action server crashes for any reason, the service should restart at this point - // TODO: don't make this mandatory for all uses of the server (client in this case) - log::GetDefaultChannel()->Flush(); - std::terminate(); - } + return *state_->conn; } - as::awaitable SessionStrand_() + const std::string& GetControlPipeBaseName_() const { - // TODO: work on propagation of server disconnection event errors - try { - bool alive = true; - while (true) { - co_await stx_.pConn->SyncHandleRequest(ctx_, stx_); - } - } - catch (const pipe::BenignPipeError&) { - pmlog_dbg(util::ReportException()); - } - catch (...) { - pmlog_error(util::ReportException()); - } - pmlog_info("Exiting action server session strand"); - } - // data - std::string basePipeName_; - pipe::as::io_context ioctx_; - SessionContextType stx_; - ExecCtx ctx_; - mt::Thread runner_; + return state_->basePipeName; + } + + private: + void StartRunner_() + { + runner_ = std::thread([state = state_] { + InstallSehTranslator(); + log::IdentificationTable::AddThisThread( + std::format("symact-{}-cli", MakeWorkerName(state->basePipeName))); + state->Run(); + }); + } + std::shared_ptr state_; + std::thread runner_; }; -} \ No newline at end of file +} diff --git a/IntelPresentMon/Interprocess/source/act/SymmetricActionConnector.h b/IntelPresentMon/Interprocess/source/act/SymmetricActionConnector.h index 91ce0c822..370bf6155 100644 --- a/IntelPresentMon/Interprocess/source/act/SymmetricActionConnector.h +++ b/IntelPresentMon/Interprocess/source/act/SymmetricActionConnector.h @@ -2,166 +2,374 @@ // SPDX-License-Identifier: MIT #pragma once #include "../../../CommonUtilities/pipe/Pipe.h" -#include "../../../CommonUtilities/str/String.h" #include "Transfer.h" +#include "ResponseRouter.h" #include "AsyncActionCollection.h" -#include -#include -#include - +#include "SessionEndReason.h" +#include "CommandTokenAllocator.h" +#include +#include +#include +#include +#include +#include +#include namespace pmon::ipc::act { + // Bound for WaitForShutdown and for the destructor's join. A timeout is not + // permission to detach: the destructor fails fast instead of returning while + // the runner can still touch borrowed execution-context state. + inline constexpr std::chrono::milliseconds kShutdownTimeout{ 2000 }; + inline constexpr std::chrono::milliseconds kShutdownPollPeriod{ 5 }; + // Late responses after a response timeout consume one expiration record. + // Evicted tokens are forgotten, so a later response is an unknown token. + inline constexpr size_t kMaxExpiredResponseTokens = 64; + template - class SymmetricActionConnector + class SymmetricActionConnector : public ResponseRouter { public: - // types - using SessionContextType = typename ExecCtx::SessionContextType; - // functions - as::awaitable SyncHandleRequest(ExecCtx& ctx, SessionContextType& stx) - { - PacketHeader header; - try { - // read packet from the pipe into buffer, partially deserialize (header only) - header = co_await pInPipe_->ReadPacketConsumeHeader(); - // -- do per-action processing based on received header -- - // any action other than OpenSession without having clientPid is an anomaly - // TODO: make this processing a customization point in ExecutionContext and move it out of here - if (header.identifier != "OpenSession") { - assert(bool(stx.remotePid)); - if (!stx.remotePid) { - pmlog_warn("Received action without a valid session opened").diag(); - } - } - // lookup the command by identifier and execute it with remaining buffer contents - // response is then transmitted over the pipe to remote - // TODO: make this return result code (increment error count based on this) - co_await AsyncActionCollection::Get().Find(header.identifier).Execute(ctx, stx, header, *pInPipe_); - co_return; - } - catch (const pipe::PipeError&) { - // we assume any pipe-transport related errors are not recoverable and proceed to terminate connection - throw; - } - catch (...) { - pmlog_error(util::ReportException()); - } - // if the output buffer is dirty, we're not sure what state we're in so just clear it - if (pInPipe_->GetWriteBufferPending()) { - pInPipe_->ClearWriteBuffer(); - } - auto resHeader = MakeResponseHeader(header, TransportStatus::TransportFailure, PM_STATUS_SUCCESS); - co_await pInPipe_->WritePacket(std::move(resHeader), EmptyPayload{}, ctx.responseWriteTimeoutMs); - } - template - auto DispatchSync(Params&& params, as::io_context& ioctx, SessionContextType& stx) - { - LogDispatch_(stx); - // wrap the SyncRequest in a coro so we can assure non-concurrent increment of the token - // CAUTION: this coro has captures that will blow up if we try and exit this function before completion - // currently OK because we block on future, but any future refactor needs to take this into consideration - const auto coro = [](auto&& params, SessionContextType& stx, util::pipe::DuplexPipe& pipe) -> AwaitableFromParams { - co_return co_await SyncRequest>(std::forward(params), stx.nextCommandToken++, pipe); - }; - return as::co_spawn(ioctx, coro(std::forward(params), stx, *pOutPipe_), as::use_future).get(); - } - // TODO: this should support both retained requests and unretained events - // need to figure out fully async request flow and how to implement the continuation API(s) - template - auto DispatchDetached(Params&& params, as::io_context& ioctx, SessionContextType& stx) - { - using ParamsT = std::decay_t; - LogDispatch_(stx); - // wrap the AsyncEmit in a coro so we can assure non-concurrent increment of the token - const auto coro = [](ParamsT params, SessionContextType& stx, util::pipe::DuplexPipe& pipe) -> as::awaitable { - try { - co_await AsyncEmit>(params, stx.nextCommandToken++, pipe); - } - catch (...) { - pmlog_error(ReportException()); - } - }; - as::co_spawn(ioctx, coro(ParamsT{ std::forward(params) }, stx, *pOutPipe_), as::detached); - } - template - void DispatchWithContinuation(Params&& params, as::io_context& ioctx, SessionContextType& stx, - std::function&&, std::exception_ptr)> conti) - { - LogDispatch_(stx); - // wrap the AsyncEmit in a coro so we can assure non-concurrent increment of the token - const auto coro = [](auto params, SessionContextType& stx, util::pipe::DuplexPipe& pipe, auto conti) - -> as::awaitable { - try { - try { - auto res = co_await SyncRequest>(std::forward(params), stx.nextCommandToken++, pipe); - conti(std::move(res), {}); - } - catch (...) { - pmlog_dbg(ReportException("Error in IPC dispatch")); - conti({}, std::current_exception()); - } - } - catch (...) { - pmlog_error(ReportException("Final failure in calling continuation")); - } - }; - as::co_spawn(ioctx, coro(std::forward(params), stx, *pOutPipe_, std::move(conti)), as::detached); - } - uint32_t GetId() const - { - return pInPipe_->GetId(); - } - // Client-side: both duplex legs must report the same server PID. - uint32_t ResolveConnectedServerProcessId() - { - uint32_t inPid = 0; - uint32_t outPid = 0; - if (!pInPipe_->TryGetConnectedServerProcessId(inPid)) { - throw util::Except("Failed to resolve server process id from control pipe in-leg"); - } - if (!pOutPipe_->TryGetConnectedServerProcessId(outPid)) { - throw util::Except("Failed to resolve server process id from control pipe out-leg"); - } - if (inPid != outPid) { - throw util::Except(std::format( - "Control pipe server process id mismatch (in={} out={})", inPid, outPid)); - } - return inPid; - } - static as::awaitable> AcceptClientConnection( - const std::string& basePipeName, as::io_context& ioctx, const std::string& security) - { - using namespace as::experimental::awaitable_operators; - auto pConn = std::make_unique(basePipeName, ioctx, security); - co_await(pConn->pInPipe_->Accept() && pConn->pOutPipe_->Accept()); - co_return pConn; - } - static std::unique_ptr ConnectToServer( - const std::string& basePipeName, as::io_context& ioctx) - { - return std::make_unique(basePipeName, ioctx); - } - SymmetricActionConnector(const std::string& basePipeName, as::io_context& ioctx, const std::string& security) - : - pOutPipe_{ pipe::DuplexPipe::MakeAsPtr(basePipeName + "-out", ioctx, security) }, - pInPipe_{ pipe::DuplexPipe::MakeAsPtr(basePipeName + "-in", ioctx, security) } - {} - SymmetricActionConnector(const std::string& basePipeName, as::io_context& ioctx) - : - pOutPipe_{ pipe::DuplexPipe::ConnectAsPtr(basePipeName + "-in", ioctx) }, - pInPipe_{ pipe::DuplexPipe::ConnectAsPtr(basePipeName + "-out", ioctx) } - {} + using SessionContextType = typename ExecCtx::SessionContextType; + + // Sole reader of the pipe. Returns the reason the session ended. + // Every exit has already failed pending requesters. + as::awaitable RunReaderLoop(ExecCtx& ctx, SessionContextType& stx) + { + std::exception_ptr error; + try { + while (!sessionEnded_.load(std::memory_order_acquire)) { + const auto header = co_await pPipe_->ReadPacketConsumeHeader(); + if (sessionEnded_.load(std::memory_order_acquire)) { + break; + } + if (!IsPlausibleHeader(header)) { + pmlog_error("Rejecting implausible packet header") + .pmwatch(header.headerVersion).pmwatch((int)header.packetType); + throw util::Except("Implausible packet header"); + } + if (header.packetType == PacketType::ActionResponse) { + RouteResponse_(header); + } + else { + co_await ExecuteIncoming_(ctx, stx, header); + } + } + } + catch (...) { + error = std::current_exception(); + NoteReaderFailure_(error); + } + if (pPipe_) { + pPipe_->Close(); + } + if (endReason_) { + co_return *endReason_; + } + const auto reason = error ? ClassifyEnd_(error) : SessionEndReason::PeerDisconnected; + // a peer that broke the protocol or stalled is an error; a disconnect or local shutdown is routine + if (error) { + if (reason == SessionEndReason::ProtocolFailure || reason == SessionEndReason::PeerStalled) { + pmlog_error(util::ReportException({}, error)); + } + else { + pmlog_dbg(util::ReportException({}, error)); + } + } + co_return reason; + } + template + void LogOutgoing(uint32_t remotePid) const + { + using Action = ActionFromParams; + pmlog_dbg("Action Dispatch").pmwatch(Action::Identifier).pmwatch(remotePid); + } + uint32_t GetId() const + { + return pPipe_->GetId(); + } + bool SessionHasEnded() const + { + return sessionEnded_.load(std::memory_order_acquire); + } + void RegisterPending(uint32_t commandToken, PendingResponse& pending) override + { + if (sessionEnded_) { + throw util::Except("Cannot send request, session has ended"); + } + if (!pendingResponses_.emplace(commandToken, &pending).second) { + pmlog_error("Duplicate pending command token").pmwatch(commandToken); + throw util::Except("Duplicate pending command token"); + } + } + void ExpirePending(uint32_t commandToken) override + { + pendingResponses_.erase(commandToken); + if (!expiredResponseTokens_.insert(commandToken).second) { + return; + } + expiredResponseTokenOrder_.push_back(commandToken); + while (expiredResponseTokenOrder_.size() > kMaxExpiredResponseTokens) { + expiredResponseTokens_.erase(expiredResponseTokenOrder_.front()); + expiredResponseTokenOrder_.pop_front(); + } + } + uint32_t AllocateCommandToken() override + { + return tokens_.Allocate([this](uint32_t token) { + return pendingResponses_.contains(token) || expiredResponseTokens_.contains(token); + }); + } + util::pipe::DuplexPipe& GetPipe() override + { + return *pPipe_; + } + as::io_context& GetIoContext() override + { + return ioctx_; + } + static std::shared_ptr MakeListener( + const std::string& pipeName, as::io_context& ioctx, const std::string& security, + pipe::PipeLimits limits) + { + return std::make_shared(pipeName, ioctx, security, std::move(limits)); + } + as::awaitable AcceptConnection() + { + co_await pPipe_->Accept(); + clientPid_ = pPipe_->GetClientProcessId(); + } + // process id of the pipe client, as reported by the system; set once accepted + uint32_t GetClientProcessId() const + { + return clientPid_; + } + // Client side: process id of the pipe server, as reported by the system. + uint32_t ResolveConnectedServerProcessId() + { + uint32_t serverPid = 0; + if (!pPipe_->TryGetConnectedServerProcessId(serverPid)) { + throw util::Except("Failed to resolve server process id from control pipe"); + } + return serverPid; + } + // Fails waiters and cancels the pipe. The reader resumes and returns `reason`. + // Must be called on the io thread. A second call is a no-op. + void EndSession(SessionEndReason reason) override + { + if (sessionEnded_.load(std::memory_order_acquire)) { + return; + } + // A reader-classified peer, protocol, or timeout reason wins over LocalShutdown. + if (!endReason_) { + endReason_ = reason; + } + FailAllPending_(std::make_exception_ptr( + util::Except("Session ended by local shutdown"))); + if (pPipe_) { + pPipe_->Close(); + } + } + static std::shared_ptr ConnectToServer( + const std::string& pipeName, as::io_context& ioctx, + CommandTokenAllocator tokens = {}) + { + return std::make_shared(pipeName, ioctx, std::move(tokens)); + } + SymmetricActionConnector(const std::string& pipeName, as::io_context& ioctx, + const std::string& security, pipe::PipeLimits limits) + : + ioctx_{ ioctx }, + pPipe_{ pipe::DuplexPipe::MakeAsPtr(pipeName, ioctx, security, std::move(limits)) }, + requireOpenSession_{ true } + {} + // Server end of a pipe pair the server created itself. The system reports the server's + // own process as the client of such a pipe, so the client pid stays unknown (0). + static std::shared_ptr FromConnectedServerPipe( + std::unique_ptr pPipe, as::io_context& ioctx) + { + return std::make_shared(std::move(pPipe), ioctx, true, CommandTokenAllocator{}); + } + // Client end of a pipe pair, received from the process that created it. + static std::shared_ptr AdoptConnectedClient( + util::win::Handle connectedPipe, as::io_context& ioctx, CommandTokenAllocator tokens = {}) + { + return std::make_shared( + pipe::DuplexPipe::AdoptConnectedClientAsPtr(connectedPipe.Release(), ioctx, MakePipeLimits_()), + ioctx, false, std::move(tokens)); + } + SymmetricActionConnector(const std::string& pipeName, as::io_context& ioctx, + CommandTokenAllocator tokens) + : + ioctx_{ ioctx }, + pPipe_{ pipe::DuplexPipe::ConnectAsPtr(pipeName, ioctx, MakePipeLimits_()) }, + tokens_{ std::move(tokens) }, + requireOpenSession_{ false } + {} + SymmetricActionConnector(std::unique_ptr pPipe, as::io_context& ioctx, + bool requireOpenSession, CommandTokenAllocator tokens) + : + ioctx_{ ioctx }, + pPipe_{ std::move(pPipe) }, + tokens_{ std::move(tokens) }, + requireOpenSession_{ requireOpenSession } + {} private: - // functions - template - void LogDispatch_(const SessionContextType& stx) const - { - using Action = ActionFromParams; - pmlog_dbg("Action Dispatch").pmwatch(Action::Identifier).pmwatch(stx.remotePid); - } - // data - std::unique_ptr pOutPipe_; - std::unique_ptr pInPipe_; + static pipe::PipeLimits MakePipeLimits_() + { + return { .minBodyBytes = kMinPacketBodyBytes, .maxBodyBytes = kMaxPacketBodyBytes }; + } + static SessionEndReason ClassifyEnd_(std::exception_ptr error) + { + try { + std::rethrow_exception(error); + } + catch (const pipe::PipeReadTimeout&) { + return SessionEndReason::PeerStalled; + } + catch (const pipe::PipeWriteTimeout&) { + return SessionEndReason::PeerStalled; + } + catch (const pipe::PipeSendQueueTimeout&) { + return SessionEndReason::PeerStalled; + } + catch (const ProtocolViolation&) { + return SessionEndReason::ProtocolFailure; + } + catch (const pipe::PipeOperationCanceled&) { + return SessionEndReason::LocalShutdown; + } + catch (const pipe::BenignPipeError&) { + return SessionEndReason::PeerDisconnected; + } + catch (const pipe::PipeError&) { + return SessionEndReason::ProtocolFailure; + } + catch (...) { + return SessionEndReason::ProtocolFailure; + } + } + as::awaitable ExecuteIncoming_(ExecCtx& ctx, SessionContextType& stx, const PacketHeader& header) + { + try { + // The accepting side runs nothing until the peer has opened its session. The + // connecting side does not check: a server may push an event in the gap between + // its OpenSession response and the client recording the session. + if (requireOpenSession_ && !stx.remotePid && header.identifier != "OpenSession") { + pmlog_error("Action received before OpenSession") + .pmwatch(header.identifier).pmwatch(clientPid_); + throw util::Except("Action received before OpenSession"); + } + // lookup the command by identifier and execute it with remaining buffer contents + // response is then transmitted over the pipe to remote + // TODO: make this return result code (increment error count based on this) + const auto pidBefore = stx.remotePid; + co_await AsyncActionCollection::Get().Find(header.identifier).Execute(ctx, stx, header, *pPipe_); + // the peer claimed a pid other than the one the system reports for the pipe client; + // remotePid is only used for diagnostics, so this is reported rather than refused + if (clientPid_ && stx.remotePid != pidBefore && stx.remotePid != clientPid_) { + pmlog_warn("Session claimed a pid that does not match the pipe client") + .pmwatch(stx.remotePid).pmwatch(clientPid_); + } + co_return; + } + // the response's deadline passed while it waited for the write lock: nothing of it was + // sent, so the stream is intact. Drop only this response; the requester's own response + // timeout covers it. + catch (const pipe::PipeSendQueueTimeout&) { + pmlog_warn("Dropping a response that missed its deadline in the write queue") + .pmwatch(header.identifier); + co_return; + } + // we assume any pipe-transport related errors and protocol violations are not + // recoverable and proceed to terminate connection + catch (const pipe::PipeError&) { + throw; + } + catch (const ProtocolViolation&) { + throw; + } + catch (...) { + pmlog_error(util::ReportException()); + } + // the read buffer is this reader's alone. The write buffer is not touched here: it + // belongs to whichever writer holds the write lock, and WritePacket leaves it clean. + pPipe_->DiscardPacketPayload(); + if (header.packetType == PacketType::ActionEvent) { + co_return; + } + auto resHeader = MakeResponseHeader(header, TransportStatus::TransportFailure, PM_STATUS_SUCCESS); + try { + co_await pPipe_->WritePacket(std::move(resHeader), EmptyPayload{}, ctx.responseWriteTimeoutMs.value_or(kDefaultWriteTimeoutMs)); + } + catch (const pipe::PipeSendQueueTimeout&) { + pmlog_warn("Dropping a failure response that missed its deadline in the write queue") + .pmwatch(header.identifier); + } + } + bool ConsumeExpired_(uint32_t commandToken) + { + if (!expiredResponseTokens_.erase(commandToken)) { + return false; + } + for (auto it = expiredResponseTokenOrder_.begin(); it != expiredResponseTokenOrder_.end(); ++it) { + if (*it == commandToken) { + expiredResponseTokenOrder_.erase(it); + break; + } + } + return true; + } + void RouteResponse_(const PacketHeader& header) + { + const auto token = header.commandToken; + if (auto i = pendingResponses_.find(token); i != pendingResponses_.end()) { + auto& pending = *i->second; + pendingResponses_.erase(i); + pending.Complete(header, *pPipe_); + return; + } + pPipe_->DiscardPacketPayload(); + if (ConsumeExpired_(token)) { + pmlog_warn("Consumed late response for an expired request").pmwatch(token); + return; + } + pmlog_error("Response for unknown command token").pmwatch(token); + throw util::Except("Response for unknown command token"); + } + void NoteReaderFailure_(std::exception_ptr error) + { + const auto classified = ClassifyEnd_(error); + if (!endReason_ || (*endReason_ == SessionEndReason::LocalShutdown + && classified != SessionEndReason::LocalShutdown)) { + endReason_ = classified; + } + if (!sessionEnded_.load(std::memory_order_acquire)) { + FailAllPending_(error); + } + } + void FailAllPending_(std::exception_ptr error) + { + sessionEnded_.store(true, std::memory_order_release); + auto pending = std::move(pendingResponses_); + pendingResponses_.clear(); + expiredResponseTokens_.clear(); + expiredResponseTokenOrder_.clear(); + for (auto& entry : pending) { + entry.second->Fail(error); + } + } + as::io_context& ioctx_; + std::unique_ptr pPipe_; + CommandTokenAllocator tokens_; + // zero on the connecting side, which has no peer to verify + uint32_t clientPid_ = 0; + // true on the accepting side, see ExecuteIncoming_ + const bool requireOpenSession_; + std::unordered_map pendingResponses_; + std::unordered_set expiredResponseTokens_; + std::deque expiredResponseTokenOrder_; + std::optional endReason_; + std::atomic sessionEnded_{ false }; }; } diff --git a/IntelPresentMon/Interprocess/source/act/SymmetricActionServer.h b/IntelPresentMon/Interprocess/source/act/SymmetricActionServer.h index b20d1a90f..2f06a18f2 100644 --- a/IntelPresentMon/Interprocess/source/act/SymmetricActionServer.h +++ b/IntelPresentMon/Interprocess/source/act/SymmetricActionServer.h @@ -2,16 +2,21 @@ // SPDX-License-Identifier: MIT #pragma once #include "SymmetricActionConnector.h" -#include "../../../CommonUtilities/str/String.h" -#include "../../../CommonUtilities/pipe/ManualAsyncEvent.h" -#include "../../../CommonUtilities/mt/Thread.h" -#include "AsyncActionCollection.h" +#include "AdmissionGate.h" +#include "Lifecycle.h" +#include "ServerSendPolicy.h" +#include "ActionRunner.h" +#include "SessionLimits.h" +#include "../../../CommonUtilities/log/IdentificationTable.h" +#include "../../../CommonUtilities/file/TempFile.h" #include "ActionContext.h" -#include +#include +#include +#include +#include +#include #include -#include -#include -#include +#include namespace pmon::ipc::act @@ -21,180 +26,747 @@ namespace pmon::ipc::act using namespace util; using namespace ipc; namespace as = boost::asio; - using namespace as::experimental::awaitable_operators; - template + template + requires TransportSessionContext class SymmetricActionServer { using SessionContextType = typename ExecCtx::SessionContextType; using SessionsMap = std::unordered_map; + using Connector = SymmetricActionConnector; + + struct State : std::enable_shared_from_this + { + Lifecycle lifecycle; + AdmissionGate admission; + as::io_context ioctx; + // Application session state and the transport connector for each session, + // keyed by the same session id. Only AddSession_ and RemoveSession_ change + // either map, so the two always hold the same keys. + SessionsMap sessions; + std::unordered_map> connectors; + // Acceptors blocked in ConnectNamedPipe. They hold this State across the + // suspend, so Drain_ must close them or the pipe name outlives shutdown. + std::vector> listeners_; + std::atomic sessionCount{ 0 }; + std::atomic acceptorCount{ 0 }; + // Session strands own a shared_ptr to this State. stop() abandons a + // suspended strand, and that frame would keep State (and the pipe) + // alive past the runner join. Drain_ waits until this hits zero. + std::atomic liveStrands_{ 0 }; + static constexpr std::chrono::milliseconds kListenerRetryDelay{ 200 }; + static constexpr int kPairAttempts = 3; + std::atomic runnerId{}; + ExecCtx ctx; + bool allowConnectionlessSend = false; + uint32_t reservedPipeInstanceCount = 0; + SessionLimits limits; + std::string basePipeName; + std::string security; + + State(ExecCtx context, std::string pipeName, uint32_t reserved, std::string securityString, + bool connectionless, SessionLimits sessionLimits) + : + ctx{ std::move(context) }, + allowConnectionlessSend{ connectionless }, + reservedPipeInstanceCount{ reserved }, + limits{ sessionLimits }, + basePipeName{ std::move(pipeName) }, + security{ std::move(securityString) } + { + // Only set pSessionMap if ExecCtx can be assigned a const SessionsMap* + if constexpr (requires(ExecCtx& e, const SessionsMap* pSessions) { e.pSessionMap = pSessions; }) { + ctx.pSessionMap = &sessions; + } + // a paired server has no listeners + assert(ServerPolicy::kPaired || reservedPipeInstanceCount > 0); + assert(limits.maxSessions >= reservedPipeInstanceCount); + } + bool OnIoThread() const + { + return std::this_thread::get_id() == runnerId.load(std::memory_order_acquire); + } + void BeginShutdown() + { + admission.Lock([&] { + if (!lifecycle.TryBeginShutdown()) { + return; + } + auto self = this->shared_from_this(); + as::co_spawn(ioctx, [self]() mutable -> as::awaitable { + co_await self->Drain_(); + // The runner thread and the owning server still hold State. + // Drop this frame's ownership before stop so an abandoned + // shutdown frame cannot keep the pipe alive inside ~io_context. + auto& io = self->ioctx; + self.reset(); + io.stop(); + }, as::detached); + }); + } + void CloseListeners_() + { + auto listeners = std::move(listeners_); + listeners_.clear(); + for (auto& listener : listeners) { + if (listener) { + listener->EndSession(SessionEndReason::LocalShutdown); + } + } + } + void EndConnectedSessions_() + { + std::vector> conns; + for (auto& entry : connectors) { + conns.push_back(entry.second); + } + for (auto& conn : conns) { + if (conn) { + conn->EndSession(SessionEndReason::LocalShutdown); + } + } + } + as::awaitable Drain_() + { + // Peer and protocol completions already queued classify themselves + // before LocalShutdown is applied. + co_await as::post(co_await as::this_coro::executor, as::use_awaitable); + CloseListeners_(); + co_await as::post(co_await as::this_coro::executor, as::use_awaitable); + EndConnectedSessions_(); + as::steady_timer timer{ co_await as::this_coro::executor }; + while (!sessions.empty() || admission.Inflight() > 0 + || liveStrands_.load(std::memory_order_acquire) > 0) { + CloseListeners_(); + EndConnectedSessions_(); + timer.expires_after(kShutdownPollPeriod); + co_await timer.async_wait(as::use_awaitable); + } + } + void Run() + { + runnerId.store(std::this_thread::get_id(), std::memory_order_release); + try { + // a paired server has no listener to keep run() busy before its first + // session; stop() at shutdown ends run() regardless of this guard + std::optional> idleGuard; + if constexpr (ServerPolicy::kPaired) { + idleGuard.emplace(as::make_work_guard(ioctx)); + } + // maintain N available connector instances at all times; one that cannot be + // created now is retried by SpawnListener_ after kListenerRetryDelay + for (uint32_t i = 0; i < reservedPipeInstanceCount; i++) { + SpawnListener_(); + } + ioctx.run(); + pmlog_info("ActionServer exiting"); + } + catch (...) { + lifecycle.MarkStopped(); + pmlog_error(ReportException()); + // if the action server crashes for any reason, the service should restart at this point + // TODO: don't make this mandatory for all uses of the server + log::GetDefaultChannel()->Flush(); + std::terminate(); + } + lifecycle.MarkStopped(); + } + pipe::PipeLimits PipeLimits_() const + { + pipe::PipeLimits limits{ + .minBodyBytes = kMinPacketBodyBytes, + .maxBodyBytes = kMaxPacketBodyBytes, + }; + if constexpr (requires { ctx.bodyReadTimeoutMs; }) { + if (ctx.bodyReadTimeoutMs) { + limits.bodyReadTimeoutMs = *ctx.bodyReadTimeoutMs; + } + } + return limits; + } + bool SpawnListener_() + { + if (!lifecycle.IsRunning()) { + return false; + } + std::shared_ptr pConn; + try { + pConn = Connector::MakeListener(basePipeName, ioctx, security, PipeLimits_()); + } + catch (...) { + pmlog_error(util::ReportException("Failed creating action pipe instance")); + SpawnListenerAfterDelay_(); + return false; + } + acceptorCount.fetch_add(1, std::memory_order_relaxed); + listeners_.push_back(pConn); + auto self = this->shared_from_this(); + liveStrands_.fetch_add(1, std::memory_order_acq_rel); + try { + as::co_spawn(ioctx, [self, pConn]() -> as::awaitable { + try { + co_await self->SessionStrand_(pConn); + } + catch (...) { + pmlog_error(util::ReportException("Action session strand failed")); + } + self->liveStrands_.fetch_sub(1, std::memory_order_acq_rel); + }, as::detached); + } + catch (...) { + liveStrands_.fetch_sub(1, std::memory_order_acq_rel); + throw; + } + return true; + } + // Replaces a listener after kListenerRetryDelay. Used when one could not be created or + // failed to accept for a reason other than a vanished client: retrying at once would + // spin the io thread on a persistent failure, such as the pipe name held by another + // process, and giving up would shrink the listener pool for good. Counted as a live + // strand, so shutdown waits for it; it does nothing once shutdown has begun. + void SpawnListenerAfterDelay_() + { + auto self = this->shared_from_this(); + liveStrands_.fetch_add(1, std::memory_order_acq_rel); + try { + as::co_spawn(ioctx, [self]() -> as::awaitable { + as::steady_timer timer{ self->ioctx }; + timer.expires_after(kListenerRetryDelay); + auto ec = boost::system::error_code{}; + co_await timer.async_wait(as::redirect_error(as::use_awaitable, ec)); + try { + self->SpawnListener_(); + } + catch (...) { + pmlog_error(util::ReportException("Failed replacing action pipe listener")); + } + self->liveStrands_.fetch_sub(1, std::memory_order_acq_rel); + }, as::detached); + } + catch (...) { + liveStrands_.fetch_sub(1, std::memory_order_acq_rel); + throw; + } + } + void RemoveListener_(const std::shared_ptr& pConn) + { + std::erase(listeners_, pConn); + } + // Creates a pipe pair, serves its server end as the session, and returns the client + // end for the caller to hand to the process it launches. + as::awaitable OpenPairedSession_() + { + // End the previous session first: the launcher asks again once its child has + // exited, possibly before this side has seen that pipe close. End sessions on + // every pass, not once: a concurrent call may create its session while this one + // waits, and this call must replace it rather than wait for it to close. + as::steady_timer timer{ ioctx }; + while (!sessions.empty()) { + EndConnectedSessions_(); + timer.expires_after(kShutdownPollPeriod); + co_await timer.async_wait(as::use_awaitable); + } + if (!lifecycle.IsRunning()) { + throw Except("Action server stopped while opening a paired session"); + } + // a random name cannot be predicted and created first; if it was, try another + pipe::DuplexPipe::ConnectedPair pair; + for (int attempt = 1;; attempt++) { + const auto name = std::format(R"(\\.\pipe\{}-{})", basePipeName, file::TempFile::MakeRandomName()); + try { + pair = pipe::DuplexPipe::MakeConnectedPair(name, ioctx, security, PipeLimits_()); + break; + } + catch (...) { + if (attempt >= kPairAttempts) { + throw; + } + pmlog_warn(util::ReportException("Retrying pipe pair creation")); + } + } + SpawnSessionStrand_(Connector::FromConnectedServerPipe(std::move(pair.pServer), ioctx)); + co_return std::move(pair.client); + } + // Serves an already connected session; counted as a live strand like a listener. + void SpawnSessionStrand_(std::shared_ptr pConn) + { + auto self = this->shared_from_this(); + liveStrands_.fetch_add(1, std::memory_order_acq_rel); + try { + as::co_spawn(ioctx, [self, pConn = std::move(pConn)]() -> as::awaitable { + try { + co_await self->ServeSession_(pConn); + } + catch (...) { + pmlog_error(util::ReportException("Action session strand failed")); + } + self->liveStrands_.fetch_sub(1, std::memory_order_acq_rel); + }, as::detached); + } + catch (...) { + liveStrands_.fetch_sub(1, std::memory_order_acq_rel); + throw; + } + } + as::awaitable SessionStrand_(std::shared_ptr pConn) + { + bool accepted = false; + bool unexpectedFailure = false; + try { + // suspend until a client connects to this listener + co_await pConn->AcceptConnection(); + accepted = true; + } + catch (const pipe::BenignPipeError&) { + pmlog_dbg(util::ReportException("Accept ended without a session")); + } + catch (...) { + pmlog_error(util::ReportException("Accept ended without a session")); + unexpectedFailure = true; + } + RemoveListener_(pConn); + if (!lifecycle.IsRunning()) { + acceptorCount.fetch_sub(1, std::memory_order_relaxed); + co_return; + } + // start a replacement listener so the pool stays at full strength; after an + // unexpected failure wait first, so a persistent failure cannot spin + if (unexpectedFailure) { + SpawnListenerAfterDelay_(); + } + else { + SpawnListener_(); + } + acceptorCount.fetch_sub(1, std::memory_order_relaxed); + if (accepted) { + co_await ServeSession_(std::move(pConn)); + } + } + as::awaitable ServeSession_(std::shared_ptr pConn) + { + if (!lifecycle.IsRunning()) { + co_return; + } + if (!Admit_()) { + co_return; + } + const auto sessionId = pConn->GetId(); + // insert a session context object for this connection, will be initialized properly upon OpenSession action + auto& stx = AddSession_(sessionId, pConn); + // the client pid here is reported by the system, not claimed by the peer + pmlog_info(std::format("Action pipe connected id:{} client pid:{}", + sessionId, pConn->GetClientProcessId())); + as::steady_timer handshakeDeadline{ ioctx }; + if constexpr (!ServerPolicy::kSinglePeer) { + handshakeDeadline.expires_after(std::chrono::milliseconds{ limits.handshakeTimeoutMs }); + // handlers only run inside ioctx.run(), so State outlives this one + handshakeDeadline.async_wait([this, sessionId](const boost::system::error_code& ec) { + if (!ec) { + EndSessionIfNotOpened_(sessionId); + } + }); + } + // run the action handler until client session is terminated + const auto reason = co_await pConn->RunReaderLoop(ctx, stx); + const auto claimedPid = DisposeSession_(sessionId, reason); + pmlog_info(std::format("Action pipe disconnected, session closed id:{} client pid:{} claimed pid:{}", + sessionId, pConn->GetClientProcessId(), claimedPid.value_or(0))); + } + // False when the connection is refused. + bool Admit_() const + { + if (ServerPolicy::kSinglePeer && !sessions.empty()) { + pmlog_warn("Refusing action pipe connection, single-peer server already has a session"); + return false; + } + if (sessions.size() >= limits.maxSessions) { + pmlog_warn("Refusing action pipe connection, session cap reached") + .pmwatch(limits.maxSessions); + return false; + } + return true; + } + void EndSessionIfNotOpened_(uint32_t sid) + { + if (auto i = sessions.find(sid); i != sessions.end() && !i->second.remotePid) { + pmlog_warn("Closing action pipe connection that did not open its session in time").pmwatch(sid); + FindConnector_(sid)->EndSession(SessionEndReason::PeerStalled); + } + } + std::optional DisposeSession_(uint32_t sid, SessionEndReason reason) + { + pmlog_dbg(std::format("Disposing session id:{} reason:{}", sid, (int)reason)); + std::optional remotePid; + if (auto i = sessions.find(sid); i != sessions.end()) { + auto& session = i->second; + if (session.remotePid) { + remotePid = session.remotePid; + } + if constexpr (HasCustomSessionDispose) { + try { + SessionDisposition disposition{ .reason = reason }; + if constexpr (requires(const ExecCtx& e) { e.GetSessionCleanupMode(); }) { + disposition.cleanupMode = ctx.GetSessionCleanupMode(); + } + ctx.Dispose(session, disposition); + } + catch (...) { + pmlog_error(util::ReportException("Failure disposing session")); + } + } + RemoveSession_(sid); + } + else { + pmlog_warn("Session to be removed not found"); + } + return remotePid; + } + SessionContextType& AddSession_(uint32_t sid, std::shared_ptr pConn) + { + connectors.emplace(sid, std::move(pConn)); + auto& stx = sessions.emplace(sid, SessionContextType{}).first->second; + assert(sessions.size() == connectors.size()); + sessionCount.store((uint32_t)sessions.size(), std::memory_order_relaxed); + return stx; + } + void RemoveSession_(uint32_t sid) + { + sessions.erase(sid); + connectors.erase(sid); + assert(sessions.size() == connectors.size()); + sessionCount.store((uint32_t)sessions.size(), std::memory_order_relaxed); + } + // Null when the session is gone. + std::shared_ptr FindConnector_(uint32_t sid) const + { + if (auto i = connectors.find(sid); i != connectors.end()) { + return i->second; + } + return nullptr; + } + }; public: SymmetricActionServer(ExecCtx context, std::string basePipeName, - uint32_t reservedPipeInstanceCount, std::string securityString, bool allowConnectionlessSend = false) - : - reservedPipeInstanceCount_{ reservedPipeInstanceCount }, - basePipeName_{ std::move(basePipeName) }, - security_{ std::move(securityString) }, - ctx_{ std::move(context) }, - worker_{ std::format("symact-{}-srv", MakeWorkerName_(basePipeName_)), &SymmetricActionServer::Run_, this}, - allowConnectionlessSend_{ allowConnectionlessSend } + uint32_t reservedPipeInstanceCount, std::string securityString, bool allowConnectionlessSend = false, + SessionLimits limits = {}) + requires (!ServerPolicy::kPaired) { - // Only set pSessionMap if ExecCtx can be assigned a const SessionsMap* - if constexpr (requires(ExecCtx& e, const SessionsMap* pSessions) { e.pSessionMap = pSessions; }) { - ctx_.pSessionMap = &sessions_; - } - assert(reservedPipeInstanceCount_ > 0); + state_ = std::make_shared(std::move(context), std::move(basePipeName), + reservedPipeInstanceCount, std::move(securityString), allowConnectionlessSend, limits); + StartRunner_(); + } + // A paired server has no listeners; sessions come from OpenPairedSession. Each pair is + // named \\.\pipe\- and created with securityString. + SymmetricActionServer(ExecCtx context, std::string pairNamePrefix, std::string securityString, + bool allowConnectionlessSend = false) + requires ServerPolicy::kPaired + { + state_ = std::make_shared(std::move(context), std::move(pairNamePrefix), + 0, std::move(securityString), allowConnectionlessSend, SessionLimits{}); + StartRunner_(); } SymmetricActionServer(const SymmetricActionServer&) = delete; SymmetricActionServer& operator=(const SymmetricActionServer&) = delete; SymmetricActionServer(SymmetricActionServer&&) = delete; SymmetricActionServer& operator=(SymmetricActionServer&&) = delete; + // Joins the runner before borrowed execution-context dependencies die. + // Destroying this object on its own action io thread is unsupported. ~SymmetricActionServer() { - ioctx_.stop(); + StopAndJoinRunner(state_, runner_, "action server"); } template - auto DispatchSync(Params&& params) + auto DispatchSync(Params&& params, std::optional responseTimeoutMs = {}) + requires ServerPolicy::kUnaddressedSend { - assert(IsRunning()); - // TODO: server needs an actual way to specify which client endpoint to transmit to - if (sessions_.empty()) { - if (allowConnectionlessSend_) { - return ResponseFromParams{}; + if (state_->OnIoThread()) { + throw Except("DispatchSync called from the action io thread"); + } + using Result = ResponseFromParams; + using ParamsT = std::decay_t; + std::promise promise; + auto future = promise.get_future(); + // CAUTION: the coroutine below captures promise by reference. That is only safe because + // this function blocks on the future until the coroutine has set it; keep that wait if refactoring. + const auto accepted = SpawnAdmitted(state_, [state = state_, owned = ParamsT{ std::forward(params) }, + &promise, responseTimeoutMs]() mutable -> as::awaitable { + try { + std::shared_ptr conn; + if (state->sessions.size() == 1) { + conn = state->FindConnector_(state->sessions.begin()->first); + } + else if (state->sessions.size() > 1) { + pmlog_error("Single-peer action server observed multiple sessions") + .pmwatch(state->sessions.size()); + } + if (!conn) { + if (state->allowConnectionlessSend) { + promise.set_value(Result{}); + } + else { + pmlog_error("Server attempting to send when no client is connected"); + promise.set_exception(std::make_exception_ptr( + Except("Server attempting to send when no client is connected"))); + } + co_return; + } + const auto remotePid = state->sessions.begin()->second.remotePid; + conn->LogOutgoing(remotePid); + auto result = co_await SyncRequest>(owned, *conn, responseTimeoutMs); + promise.set_value(std::move(result)); } - pmlog_error("Server attempting to send when no client is connected"); - throw Except("Server attempting to send when no client is connected"); + catch (...) { + try { + promise.set_exception(std::current_exception()); + } + catch (...) { + } + } + }); + if (!accepted) { + throw Except("Action server is not running"); } - auto& stx = sessions_.begin()->second; - return stx.pConn->DispatchSync(std::forward(params), ioctx_, stx); + return future.get(); } template - void DispatchDetached(Params&& params) + auto DispatchSync(uint32_t sessionId, Params&& params, std::optional responseTimeoutMs = {}) + requires ServerPolicy::kAddressedSend { - assert(IsRunning()); - // TODO: server needs an actual way to specify which client endpoint to transmit to - if (sessions_.empty()) { - pmlog_warn("Server dropping detached send because no client is connected"); - return; + if (state_->OnIoThread()) { + throw Except("DispatchSync called from the action io thread"); } - auto& stx = sessions_.begin()->second; - stx.pConn->DispatchDetached(std::forward(params), ioctx_, stx); - } - bool IsRunning() const - { - return !ioctx_.stopped(); + using Result = ResponseFromParams; + using ParamsT = std::decay_t; + std::promise promise; + auto future = promise.get_future(); + // CAUTION: the coroutine below captures promise by reference. That is only safe because + // this function blocks on the future until the coroutine has set it; keep that wait if refactoring. + const auto accepted = SpawnAdmitted(state_, [state = state_, sessionId, owned = ParamsT{ std::forward(params) }, + &promise, responseTimeoutMs]() mutable -> as::awaitable { + try { + std::shared_ptr conn; + uint32_t remotePid = 0; + if (auto i = state->sessions.find(sessionId); i != state->sessions.end()) { + remotePid = i->second.remotePid; + conn = state->FindConnector_(sessionId); + } + if (!conn) { + pmlog_error("Server send addressed to an unknown session").pmwatch(sessionId); + promise.set_exception(std::make_exception_ptr( + Except("Server send addressed to an unknown session"))); + co_return; + } + conn->LogOutgoing(remotePid); + auto result = co_await SyncRequest>(owned, *conn, responseTimeoutMs); + promise.set_value(std::move(result)); + } + catch (...) { + try { + promise.set_exception(std::current_exception()); + } + catch (...) { + } + } + }); + if (!accepted) { + throw Except("Action server is not running"); + } + return future.get(); } - - private: - // functions - static std::string MakeWorkerName_(const std::string& pipeNameBase) + template + void DispatchDetached(Params&& params) + requires ServerPolicy::kUnaddressedSend { - constexpr std::string_view prefix = R"(\\.\pipe\)"; - std::string base; - if (pipeNameBase.starts_with(prefix)) { - base = pipeNameBase.substr(prefix.size()); - } - else { - base = pipeNameBase; + using ParamsT = std::decay_t; + const auto accepted = SpawnAdmitted(state_, [state = state_, owned = ParamsT{ std::forward(params) }]() -> as::awaitable { + std::shared_ptr conn; + uint32_t remotePid = 0; + if (state->sessions.size() == 1) { + remotePid = state->sessions.begin()->second.remotePid; + conn = state->FindConnector_(state->sessions.begin()->first); + } + if (!conn) { + if (!state->allowConnectionlessSend) { + pmlog_warn("Server dropping detached send because no client is connected"); + } + co_return; + } + conn->LogOutgoing(remotePid); + if (conn->SessionHasEnded()) { + co_return; + } + try { + co_await AsyncEmit>(owned, *conn); + } + catch (...) { + pmlog_error(ReportException()); + } + }); + if (!accepted) { + pmlog_warn("Dropping server send because the action server is not running"); } - return base; } - void Run_() + template + void DispatchDetached(uint32_t sessionId, Params&& params) + requires ServerPolicy::kAddressedSend { - try { - // maintain N available connector instances at all times - for (uint32_t i = 0; i < reservedPipeInstanceCount_; i++) { - as::co_spawn(ioctx_, AcceptLoop_(), as::detached); - } - // run the io context event handler until signalled to exit - ioctx_.run(); - pmlog_info("ActionServer exiting"); + if (state_->OnIoThread()) { + throw Except("DispatchDetached called from the action io thread"); } - catch (...) { - pmlog_error(ReportException()); - // if the action server crashes for any reason, the service should restart at this point - // TODO: don't make this mandatory for all uses of the server - log::GetDefaultChannel()->Flush(); - std::terminate(); + using ParamsT = std::decay_t; + std::promise promise; + auto future = promise.get_future(); + // CAUTION: the coroutine below captures promise by reference. That is only safe because + // this function blocks on the future until the coroutine has set it; keep that wait if refactoring. + const auto accepted = SpawnAdmitted(state_, [state = state_, sessionId, owned = ParamsT{ std::forward(params) }, + &promise]() mutable -> as::awaitable { + try { + std::shared_ptr conn; + uint32_t remotePid = 0; + if (auto i = state->sessions.find(sessionId); i != state->sessions.end()) { + remotePid = i->second.remotePid; + conn = state->FindConnector_(sessionId); + } + if (!conn) { + pmlog_error("Server send addressed to an unknown session").pmwatch(sessionId); + promise.set_exception(std::make_exception_ptr( + Except("Server send addressed to an unknown session"))); + co_return; + } + conn->LogOutgoing(remotePid); + if (!conn->SessionHasEnded()) { + co_await AsyncEmit>(owned, *conn); + } + promise.set_value(); + } + catch (...) { + try { + promise.set_exception(std::current_exception()); + } + catch (...) { + } + } + }); + if (!accepted) { + throw Except("Action server is not running"); } + future.get(); } - as::awaitable AcceptLoop_() + // Ends any current session, creates a new pipe pair, serves its server end as the only + // session, and returns the client end. The caller hands that handle to the process it + // launches and then closes its own copy; it must not do I/O on it. Blocks until the pair + // exists; throws if the server is not running or the pair cannot be created. + util::win::Handle OpenPairedSession() + requires ServerPolicy::kPaired { - // CreateNamedPipe runs before AcceptClientConnection suspends. Without a - // yield, a persistent failure (access denied on a squatted name) spins - // this coroutine and stalls the single io thread. - constexpr auto acceptRetryDelay = std::chrono::milliseconds{ 200 }; - while (IsRunning()) { - bool accepted = false; + if (state_->OnIoThread()) { + throw Except("OpenPairedSession called from the action io thread"); + } + std::promise promise; + auto future = promise.get_future(); + // CAUTION: the coroutine below captures promise by reference. That is only safe because + // this function blocks on the future until the coroutine has set it; keep that wait if refactoring. + const auto accepted = SpawnAdmitted(state_, [state = state_, &promise]() -> as::awaitable { try { - auto pConn = co_await SymmetricActionConnector::AcceptClientConnection( - basePipeName_, ioctx_, security_); - as::co_spawn(ioctx_, HandleSession_(std::move(pConn)), as::detached); - accepted = true; - } - catch (const pipe::BenignPipeError&) { - pmlog_dbg(util::ReportException()); + promise.set_value(co_await state->OpenPairedSession_()); } catch (...) { - pmlog_error(util::ReportException()); - } - if (!accepted && IsRunning()) { - auto ec = boost::system::error_code{}; - as::steady_timer timer{ ioctx_ }; - timer.expires_after(acceptRetryDelay); - co_await timer.async_wait(as::redirect_error(as::use_awaitable, ec)); + try { + promise.set_exception(std::current_exception()); + } + catch (...) { + } } + }); + if (!accepted) { + throw Except("Action server is not running"); } + return future.get(); } - as::awaitable HandleSession_(std::unique_ptr> pConn) + bool IsRunning() const { - const uint32_t sessionId = pConn->GetId(); - try { - auto&&[i, b] = sessions_.emplace(sessionId, SessionContextType{ .pConn = std::move(pConn) }); - pmlog_info(std::format("Action pipe connected id:{}", sessionId)); - auto& stx = i->second; - while (true) { - co_await stx.pConn->SyncHandleRequest(ctx_, stx); - } + return state_ && state_->lifecycle.IsRunning(); + } + LifecyclePhase GetLifecyclePhase() const + { + return state_->lifecycle.Get(); + } + void EnterFinalTeardown() + { + if constexpr (requires(ExecCtx& e) { e.EnterFinalTeardown(); }) { + state_->ctx.EnterFinalTeardown(); } - catch (const pipe::BenignPipeError&) { - pmlog_dbg(util::ReportException()); + } + void BeginShutdown() + { + state_->BeginShutdown(); + } + // False when the runner has not reached Stopped. Does not detach. + bool WaitForShutdown() + { + if (state_->OnIoThread()) { + throw Except("WaitForShutdown cannot run on the action io thread"); } - catch (...) { - pmlog_error(util::ReportException()); + if (!state_->lifecycle.WaitFor(kShutdownTimeout)) { + pmlog_warn("Timed out waiting for action server shutdown"); + return false; } - const auto clientPid = DisposeSession_(sessionId); - pmlog_info(std::format("Action pipe disconnected, session closed id:{} pid:{}", sessionId, clientPid.value_or(0))); + return true; } - std::optional DisposeSession_(uint32_t sid) + uint32_t GetSessionCount() const { - pmlog_dbg(std::format("Disposing session id:{}", sid)); - std::optional remotePid; - if (auto i = sessions_.find(sid); i != sessions_.end()) { - auto& session = i->second; - if (session.remotePid) { - remotePid = session.remotePid; - if constexpr (HasCustomSessionDispose) { - ctx_.Dispose(session); + return state_->sessionCount.load(std::memory_order_relaxed); + } + uint32_t GetAcceptorCount() const + { + return state_->acceptorCount.load(std::memory_order_relaxed); + } + // Session ids are the admitting pipe ids. Copied on the io thread. + std::vector CopySessionIds() + { + if (state_->OnIoThread()) { + throw Except("CopySessionIds called from the action io thread"); + } + std::promise> promise; + auto future = promise.get_future(); + // CAUTION: the coroutine below captures promise by reference. That is only safe because + // this function blocks on the future until the coroutine has set it; keep that wait if refactoring. + const auto accepted = SpawnAdmitted(state_, [state = state_, &promise]() -> as::awaitable { + try { + std::vector ids; + ids.reserve(state->sessions.size()); + for (auto& entry : state->sessions) { + ids.push_back(entry.first); } + promise.set_value(std::move(ids)); } - sessions_.erase(i); - } - else { - pmlog_warn("Session to be removed not found"); + catch (...) { + try { + promise.set_exception(std::current_exception()); + } + catch (...) { + } + } + co_return; + }); + if (!accepted) { + throw Except("Action server is not running"); } - return remotePid; + return future.get(); + } + + private: + void StartRunner_() + { + runner_ = std::thread([state = state_] { + InstallSehTranslator(); + log::IdentificationTable::AddThisThread( + std::format("symact-{}-srv", MakeWorkerName(state->basePipeName))); + state->Run(); + }); } - // data - bool allowConnectionlessSend_ = false; - uint32_t reservedPipeInstanceCount_; - std::string basePipeName_; - std::string security_; - as::io_context ioctx_; - // maps session uid => session (uid is same as session recv (in) pipe id) - SessionsMap sessions_; - ExecCtx ctx_; - mt::Thread worker_; + std::shared_ptr state_; + std::thread runner_; }; } diff --git a/IntelPresentMon/Interprocess/source/act/Transfer.h b/IntelPresentMon/Interprocess/source/act/Transfer.h index c1786d574..6171ad9c3 100644 --- a/IntelPresentMon/Interprocess/source/act/Transfer.h +++ b/IntelPresentMon/Interprocess/source/act/Transfer.h @@ -1,55 +1,138 @@ #pragma once #include "../../../CommonUtilities/win/WinAPI.h" #include "../../../CommonUtilities/pipe/Pipe.h" +#include "../../../CommonUtilities/pipe/ManualAsyncEvent.h" #include "Packet.h" #include "ActionExecutionError.h" #include "AsyncAction.h" +#include "ResponseRouter.h" +#include +#include +#include namespace pmon::ipc::act { namespace as = boost::asio; using namespace util::pipe; + PM_DEFINE_EX(ResponseTimeout); + + // How long a requester waits for its response before giving up. Sized well above the + // slowest real action (service-side ETW session setup) so that only a peer which has + // genuinely stopped answering trips it, but bounded so that nobody waits forever. + inline constexpr uint32_t kDefaultResponseTimeoutMs = 10000; + + // Turns a non-Success response header into the exception the requester should see. + inline std::exception_ptr MakeResponseError(const PacketHeader& resHeader) + { + if (resHeader.executionStatus) { + const auto code = (PM_STATUS)resHeader.executionStatus; + pmlog_error("Execution error response to SyncRequest").code(code).diag(); + return std::make_exception_ptr(util::Except(code)); + } + pmlog_error("Execution error response to SyncRequest").diag(); + return std::make_exception_ptr(util::Except("Execution error response to SyncRequest")); + } + + // Writes one packet with a deadline. A missed deadline means the peer stopped reading, and + // part of the packet may already be on the wire, so the session is ended (PeerStalled) + // before the error reaches the caller. Ending it also fails every coroutine waiting for the + // write lock, so one stalled write cannot hold up the others. A PipeSendQueueTimeout (the + // deadline passed before anything was sent) leaves the stream intact, so it fails only this + // write and does not end the session. + template + as::awaitable WriteOrEndSession_(ResponseRouter& router, const PacketHeader& header, + const P& payload, uint32_t timeoutMs) + { + try { + co_await router.GetPipe().WritePacket(header, payload, timeoutMs); + } + catch (const PipeWriteTimeout&) { + pmlog_error("Peer stopped reading; ending the session").pmwatch(header.identifier); + router.EndSession(SessionEndReason::PeerStalled); + throw; + } + } + template - auto SyncRequest(const typename C::Params& params, uint32_t commandToken, DuplexPipe& pipe, std::optional timeoutMs = {}) - -> as::awaitable + auto SyncRequest(const typename C::Params& params, ResponseRouter& router, + std::optional responseTimeoutMs = {}) -> as::awaitable { + // Allocated on the action io thread, skipping pending and retained expired values. + const auto commandToken = router.AllocateCommandToken(); + // the reader loop owns the pipe, so it fills this in on our behalf and wakes us; + // a response therefore no longer has to be the next packet on the wire + struct Completion : PendingResponse + { + Completion(as::io_context& ioctx) : signal{ ioctx } {} + void OnComplete_(const PacketHeader& header, DuplexPipe& pipe) override + { + try { + if (header.transportStatus == TransportStatus::Success) { + response = pipe.ConsumePacketPayload(); + } + else { + // the responder wrote an empty payload; consume it to leave the stream clean + pipe.ConsumePacketPayload(); + error = MakeResponseError(header); + } + } + catch (...) { + error = std::current_exception(); + } + signal.Signal(); + } + void OnFail_(std::exception_ptr e) override + { + error = std::move(e); + signal.Signal(); + } + typename C::Response response{}; + std::exception_ptr error; + ManualAsyncEvent signal; + } completion{ router.GetIoContext() }; + const PacketHeader reqHeader{ .identifier = C::Identifier, .commandToken = commandToken, .packetType = PacketType::ActionRequest, - .headerVersion = 1, + .headerVersion = kHeaderVersion, .actionVersion = C::Version, }; - co_await pipe.WritePacket(reqHeader, params, timeoutMs); - const auto resHeader = co_await pipe.ReadPacketConsumeHeader(timeoutMs); - if (resHeader.transportStatus != TransportStatus::Success) { - // consume the empty payload to leave the pipe stream in a clean state - pipe.ConsumePacketPayload(); - if (resHeader.executionStatus) { - const auto code = (PM_STATUS)resHeader.executionStatus; - pmlog_error("Execution error response to SyncRequest").code(code).diag(); - throw util::Except((PM_STATUS)resHeader.executionStatus); - } - else { - pmlog_error("Execution error response to SyncRequest").diag(); - throw util::Except("Execution error response to SyncRequest"); - } + // one deadline covers sending the request and waiting for its response + const std::chrono::milliseconds timeout{ responseTimeoutMs.value_or(kDefaultResponseTimeoutMs) }; + const auto deadline = std::chrono::steady_clock::now() + timeout; + // register before writing, otherwise a fast responder can race ahead of us + const PendingRegistration registration{ router, commandToken, completion }; + co_await WriteOrEndSession_(router, reqHeader, params, (uint32_t)timeout.count()); + // on the way out the registration expires this token, so a response that arrives + // after we have given up is dropped by the reader loop rather than killing the session + // a response that already arrived is still taken when the budget is spent + const auto remaining = std::max(std::chrono::milliseconds::zero(), + std::chrono::duration_cast(deadline - std::chrono::steady_clock::now())); + if (!co_await completion.signal.AsyncWait(remaining)) { + pmlog_error("Timeout waiting for action response") + .pmwatch(C::Identifier).pmwatch(commandToken); + throw util::Except("Timeout waiting for action response"); + } + if (completion.error) { + std::rethrow_exception(completion.error); } - co_return pipe.ConsumePacketPayload(); + co_return std::move(completion.response); } template - auto AsyncEmit(const typename C::Params& params, uint32_t commandToken, DuplexPipe& pipe, std::optional timeoutMs = {}) - -> as::awaitable + auto AsyncEmit(const typename C::Params& params, ResponseRouter& router) -> as::awaitable { + const auto commandToken = router.AllocateCommandToken(); const PacketHeader reqHeader{ .identifier = C::Identifier, .commandToken = commandToken, .packetType = PacketType::ActionEvent, - .headerVersion = 1, + .headerVersion = kHeaderVersion, .actionVersion = C::Version, }; - co_await pipe.WritePacket(reqHeader, params, timeoutMs); + // events are never answered, so no token is registered + co_await WriteOrEndSession_(router, reqHeader, params, kDefaultWriteTimeoutMs); } } diff --git a/IntelPresentMon/KernelProcess/KernelProcess.args.json b/IntelPresentMon/KernelProcess/KernelProcess.args.json index 6845dafe8..b10ae661b 100644 --- a/IntelPresentMon/KernelProcess/KernelProcess.args.json +++ b/IntelPresentMon/KernelProcess/KernelProcess.args.json @@ -36,10 +36,6 @@ "Id": "49b6fc1f-b969-4280-b0d8-17a213e80f16", "Command": "--log-verbose-modules tele_gpu" }, - { - "Id": "bab48d6d-3a48-4b3b-9ed9-903e381824c6", - "Command": "--log-svc-pipe-enable" - }, { "Id": "bda24a31-51f4-481a-96f0-037e62a1e578", "Command": "--middleware-dll-path .\\PresentMonAPI2.dll" diff --git a/IntelPresentMon/KernelProcess/kact/KernelExecutionContext.h b/IntelPresentMon/KernelProcess/kact/KernelExecutionContext.h index b3abcdcad..8dd568aab 100644 --- a/IntelPresentMon/KernelProcess/kact/KernelExecutionContext.h +++ b/IntelPresentMon/KernelProcess/kact/KernelExecutionContext.h @@ -1,27 +1,19 @@ #pragma once -#include "../../Interprocess/source/act/SymmetricActionConnector.h" -#include "../../CommonUtilities/pipe/ManualAsyncEvent.h" -#include -#include -#include -#include #include #include -#include #include "../../Core/source/kernel/Kernel.h" #include "../../Core/source/win/HotkeyListener.h" +#include "../../CommonUtilities/log/Log.h" +#include "../../Interprocess/source/act/SessionEndReason.h" +#include namespace kproc::kact { using namespace ::pmon; - struct KernelExecutionContext; - struct KernelSessionContext { - // common session context items - std::unique_ptr> pConn; + // required by the transport, see TransportSessionContext uint32_t remotePid = 0; - uint32_t nextCommandToken = 0; }; struct KernelExecutionContext @@ -34,5 +26,16 @@ namespace kproc::kact p2c::kern::Kernel** ppKernel = nullptr; p2c::win::Hotkeys* pHotkeys = nullptr; + + // Called by the server once for every session that ends. Hotkey bindings belong to + // the UI session that made them, so none outlive it. + void Dispose(SessionContextType&, ipc::act::SessionDisposition disposition) const + { + if (pHotkeys) { + const auto cleared = pHotkeys->ClearAll(); + pmlog_info(std::format("Kernel session ended, cleared {} hotkey bindings reason:{}", + cleared, (int)disposition.reason)); + } + } }; } \ No newline at end of file diff --git a/IntelPresentMon/KernelProcess/winmain.cpp b/IntelPresentMon/KernelProcess/winmain.cpp index d9b445325..9dd1e75d6 100644 --- a/IntelPresentMon/KernelProcess/winmain.cpp +++ b/IntelPresentMon/KernelProcess/winmain.cpp @@ -29,6 +29,9 @@ #include #include #include +#include +#include +#include #include using namespace pmon; @@ -40,33 +43,46 @@ namespace as = boost::asio; namespace kproc { - using KernelServer = ipc::act::SymmetricActionServer; + using KernelServer = ipc::act::SymmetricActionServer; namespace cact = p2c::client::util::cact; class KernelHandler : public p2c::kern::KernelHandler { public: - KernelHandler(KernelServer& server) : server_{ server } {} + KernelHandler(KernelServer& server) : server_{ &server } {} + void Detach() + { + std::lock_guard lk{ mtx_ }; + server_ = nullptr; + } void OnTargetLost(uint32_t pid) override { - server_.DispatchDetached(cact::TargetLostAction::Params{ pid }); + Send_(cact::TargetLostAction::Params{ pid }); } void OnOverlayDied() override { - server_.DispatchDetached(cact::OverlayDiedAction::Params{}); + Send_(cact::OverlayDiedAction::Params{}); } void OnPresentmonInitFailed() override { - server_.DispatchDetached(cact::PresentmonInitFailedAction::Params{}); + Send_(cact::PresentmonInitFailedAction::Params{}); } void OnStalePidSelected() override { - server_.DispatchDetached(cact::StalePidAction::Params{}); + Send_(cact::StalePidAction::Params{}); } private: - // data - KernelServer& server_; + template + void Send_(Params&& params) + { + std::lock_guard lk{ mtx_ }; + if (server_) { + server_->DispatchDetached(std::forward(params)); + } + } + std::mutex mtx_; + KernelServer* server_ = nullptr; }; class HeadlessKernelHandler : public p2c::kern::KernelHandler { @@ -383,8 +399,6 @@ int APIENTRY WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLi bid::BuildIdShortHash(), !bid::BuildIdDirtyFlag())); // launch the service as a child process if desired (typically during development) - const auto logSvcPipe = opt.logSvcPipe.AsOptional().value_or( - std::format("pm2-child-svc-log-{}", GetCurrentProcessId())); as::io_context ioctx; std::optional> svcChild; if (opt.svcAsChild) { @@ -394,7 +408,6 @@ int APIENTRY WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLi "--shm-name-prefix"s, *opt.shmNamePrefix, "--etw-session-name"s, *opt.etwSessionName, "--log-level"s, util::log::GetLevelName(util::log::GlobalPolicy::Get().GetLogLevel()), - "--log-pipe-name"s, logSvcPipe, "--enable-stdio-log"s, }; // append verbose module options @@ -419,17 +432,12 @@ int APIENTRY WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLi svcChild = bp2::windows::default_launcher{}(ioctx, "PresentMonService.exe"s, std::move(args)); } // wait for pipe availability of service api - if (!::pmon::util::win::WaitForNamedPipe(*opt.controlPipe + "-in", 1500000)) { + if (!::pmon::util::win::WaitForNamedPipe(*opt.controlPipe, 1500000)) { pmlog_error("timeout waiting for child service control pipe to go online"); return -1; } } - if (opt.logSvcPipeEnable) { - // connect to service's log pipe (best effort) - ConnectToLoggingSourcePipe(logSvcPipe); - } - //// connect to the middleware diagnostic layer (not generally used by ipm since we connect //// to logging directly via copy channel in dev and log middleware to file in production) //std::optional diag; @@ -550,13 +558,16 @@ int APIENTRY WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLi p2c::kern::Kernel* pKernel = nullptr; // active object that creates a window and sinks raw input messages to listen for hotkey presses p2c::win::Hotkeys hotkeys; - // this server receives a connection from the CEF render process - const auto actName = std::format(R"(\\.\pipe\ipm-cef-channel-{})", GetCurrentProcessId()); - KernelServer server{ kact::KernelExecutionContext{ .ppKernel = &pKernel, .pHotkeys = &hotkeys }, - actName, 1, "D:(A;;GA;;;WD)S:(ML;;NW;;;ME)", headless }; + // this server serves the UI. It has no listening pipe: each UI launch gets a private pipe pair + // whose client end only that UI inherits (OpenPairedSession), so no other process can connect + auto pServer = std::make_unique(kact::KernelExecutionContext{ .ppKernel = &pKernel, .pHotkeys = &hotkeys }, + std::format("pm-kernel-ui-{}", GetCurrentProcessId()), + util::pipe::DuplexPipe::GetPrivateControlPipeSecurityString(false), headless); // set the hotkey manager to send notifications via the action server - hotkeys.SetHandler([&](int action) { - server.DispatchDetached(p2c::client::util::cact::HotkeyFiredAction::Params{ .actionId = action }); + hotkeys.SetHandler([&pServer](int action) { + if (pServer) { + pServer->DispatchDetached(p2c::client::util::cact::HotkeyFiredAction::Params{ .actionId = action }); + } }); // select which handler to use for kernel async events/signals auto pKernelHandler = [&]() -> std::unique_ptr<::p2c::kern::KernelHandler> { @@ -566,13 +577,37 @@ int APIENTRY WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLi } else { // this handler receives events from the kernel and transmits them to the render process via the server - return std::make_unique(server); + return std::make_unique(*pServer); } }(); // the kernel manages the PresentMon data collection and the overlay rendering p2c::kern::Kernel kernel{ pKernelHandler.get(), headless}; // new we set this pointer, giving the server access to the Kernel pKernel = &kernel; + // Destroyed first: stop hotkeys, join and destroy the server while Kernel is alive. + struct ActionServerTeardown_ + { + p2c::win::Hotkeys& hotkeys; + std::unique_ptr& server; + KernelHandler* handler = nullptr; + ~ActionServerTeardown_() + { + hotkeys.Stop(); + if (handler) { + handler->Detach(); + } + if (!server) { + return; + } + server->BeginShutdown(); + if (!server->WaitForShutdown()) { + pmlog_error("Kernel action server runner did not stop while Kernel was still alive"); + std::terminate(); + } + server.reset(); + } + } actionServerTeardown_{ hotkeys, pServer, + dynamic_cast(pKernelHandler.get()) }; // run the UI when not headless if (!headless) { uint64_t uiLaunchAttempt = 0; @@ -613,12 +648,18 @@ int APIENTRY WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLi args.push_back(o.second); } const auto cefLogPipe = std::format("pm-ui-log-{}-{}", GetCurrentProcessId(), uiLaunchAttempt++); + // a new pipe pair for this UI; this also ends the session of any UI launched before it + auto uiPipe = pServer->OpenPairedSession(); + // the launcher passes on only listed handles, and each must be marked inheritable + if (!SetHandleInformation(uiPipe, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT)) { + pmlog_error("Failed to make the UI pipe handle inheritable").hr(); + } // add fixed CLI options to the args vector args.append_range(std::vector{ "--p2c-log-level"s, util::log::GetLevelName(*opt.logLevel), "--p2c-log-trace-level"s, util::log::GetLevelName(*opt.logTraceLevel), "--p2c-ui-mutex-name"s, *opt.uiMutexName, - "--p2c-act-name"s, actName, + "--p2c-act-handle"s, std::to_string((uintptr_t)uiPipe.Get()), "--p2c-log-pipe-name"s, cefLogPipe }); // launch the CEF browser process, which in turn launches all the other processes in the CEF process constellation @@ -628,22 +669,37 @@ int APIENTRY WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLi ::pmon::util::file::ScopedWorkingDirectory setInstallWorkingDirectory{ infra::util::FolderResolver::ResolveInstallPath() }; + // the UI inherits the pipe handle and no other handle; auto, because the launchers' + // call operators are not virtual and must be called on the concrete type + auto launch = [&](auto& launcher) { + launcher.inherited_handles.push_back(uiPipe.Get()); + return launcher(ioctx, "PresentMonUI.exe"s, args); + }; if (util::win::WeAreElevated()) { + // No fallback to an elevated UI: Chromium would relaunch it de-elevated, + // and the relaunched UI does not inherit the kernel pipe, so it could never connect. try { pmlog_info("detected elevation, attempting integrity downgrade"); auto mediumTokenPack = util::win::PrepareMediumIntegrityToken(); - return bp2::windows::as_user_launcher{ mediumTokenPack.hMediumToken.Get() }( - ioctx, "PresentMonUI.exe"s, args - ); + bp2::windows::as_user_launcher launcher{ mediumTokenPack.hMediumToken.Get() }; + return launch(launcher); } catch (...) { - pmlog_warn(util::ReportException("Failed to downgrade integrity, falling back to standard process spawn")); + pmlog_error(util::ReportException("Failed to launch the UI at reduced integrity")); + MessageBoxW(nullptr, + L"Intel PresentMon could not start its window with reduced privileges. " + L"Start Intel PresentMon without \"Run as administrator\".", + L"Intel PresentMon", + MB_ICONERROR | MB_APPLMODAL | MB_SETFOREGROUND); + throw; } } - return bp2::windows::default_launcher{}( - ioctx, "PresentMonUI.exe"s, args - ); + bp2::windows::default_launcher launcher; + return launch(launcher); }(); + // The UI has its own copy now. Ours must not stay open: the session ends only when + // every copy of the client end is closed, which is how the kernel sees the UI exit. + uiPipe = util::win::Handle{}; KillOnCloseJob uiJob{ cefChild.native_handle() }; diff --git a/IntelPresentMon/PresentMonAPI2Tests/ActionRunnerTests.cpp b/IntelPresentMon/PresentMonAPI2Tests/ActionRunnerTests.cpp new file mode 100644 index 000000000..81b44c54a --- /dev/null +++ b/IntelPresentMon/PresentMonAPI2Tests/ActionRunnerTests.cpp @@ -0,0 +1,65 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: MIT +// Exercises SpawnAdmitted, the admission step shared by the action client and server. +#include "../CommonUtilities/win/WinAPI.h" +#include "CppUnitTest.h" +#include "../Interprocess/source/act/ActionRunner.h" +#include "../Interprocess/source/act/AdmissionGate.h" +#include "../Interprocess/source/act/Lifecycle.h" +#include +#include +#include +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; +using namespace std::literals; + +namespace ActionRunnerTests +{ + using namespace pmon::ipc::act; + namespace as = boost::asio; + + // the members SpawnAdmitted uses + struct TestState + { + Lifecycle lifecycle; + AdmissionGate admission; + as::io_context ioctx; + }; + + TEST_CLASS(SpawnAdmittedTests) + { + public: + // Admitted from the io thread itself, the coroutine must not start until SpawnAdmitted + // has returned. Started any earlier, it would run while the admission gate is held, and + // a failure continuation calling BeginShutdown or dispatching again would deadlock. + TEST_METHOD(AdmittedWorkStartsAfterTheGateIsReleased) + { + auto state = std::make_shared(); + auto keepRunning = as::make_work_guard(state->ioctx); + std::jthread io{ [state] { state->ioctx.run(); } }; + // -1 not started yet, 0 started before SpawnAdmitted returned, 1 started after + auto startedAfterReturn = std::make_shared>(-1); + auto spawnReturned = std::make_shared>(false); + as::post(state->ioctx, [state, startedAfterReturn, spawnReturned] { + const bool accepted = SpawnAdmitted(state, [startedAfterReturn, spawnReturned]() -> as::awaitable { + startedAfterReturn->store(spawnReturned->load() ? 1 : 0); + co_return; + }); + spawnReturned->store(accepted); + }); + for (int i = 0; i < 200 && startedAfterReturn->load() < 0; i++) { + std::this_thread::sleep_for(10ms); + } + for (int i = 0; i < 200 && state->admission.Inflight() > 0; i++) { + std::this_thread::sleep_for(10ms); + } + keepRunning.reset(); + state->ioctx.stop(); + io.join(); + Assert::AreEqual(1, startedAfterReturn->load(), + L"Work admitted from the io thread started before SpawnAdmitted returned"); + Assert::AreEqual(0, state->admission.Inflight(), L"The admitted work was not completed"); + } + }; +} diff --git a/IntelPresentMon/PresentMonAPI2Tests/CMakeLists.txt b/IntelPresentMon/PresentMonAPI2Tests/CMakeLists.txt index 014349580..91d9b57d4 100644 --- a/IntelPresentMon/PresentMonAPI2Tests/CMakeLists.txt +++ b/IntelPresentMon/PresentMonAPI2Tests/CMakeLists.txt @@ -52,9 +52,17 @@ add_library( MultiClientTests.cpp PipeAcceptReliabilityTests.cpp PipeSecurityRegressionTests.cpp + PipePairTests.cpp + ActionRunnerTests.cpp PacedFrameTests.cpp PacedPollingTests.cpp RealtimeMetricTests.cpp + TransportDemuxTests.cpp + TransportLifecycleTests.cpp + TransportPairedTests.cpp + TransportSessionTests.cpp + TransportTestKit.cpp + TransportWriteDeadlineTests.cpp UiLaunchTests.cpp "${PROJECT_SOURCE_DIR}/IntelPresentMon/CommonUtilities/test/MachineExpectations.cpp" ) diff --git a/IntelPresentMon/PresentMonAPI2Tests/MultiClientTests.cpp b/IntelPresentMon/PresentMonAPI2Tests/MultiClientTests.cpp index e9ef23516..b543b8f8d 100644 --- a/IntelPresentMon/PresentMonAPI2Tests/MultiClientTests.cpp +++ b/IntelPresentMon/PresentMonAPI2Tests/MultiClientTests.cpp @@ -7,8 +7,10 @@ #include "../CommonUtilities/test/MachineExpectations.h" #include #include +#include #include "Folders.h" #include "JobManager.h" +#include "../Interprocess/source/act/SessionLimits.h" using namespace Microsoft::VisualStudio::CppUnitTestFramework; @@ -570,6 +572,209 @@ namespace MultiClientTests } }; + // Every case here misuses the control pipe and then proves that a legitimate client + // is still admitted and served. + TEST_CLASS(TransportLifetimeTests) + { + TestFixture fixture_; + static constexpr auto rejectionDeadline_ = 3s; + // matches the SessionLimits default handshakeTimeoutMs + static constexpr auto handshakeDeadline_ = 3s; + // matches reservedPipeInstanceCount passed by PresentMonService::ActionServer + static constexpr uint32_t reservedAcceptors_ = 2; + + public: + TEST_METHOD_INITIALIZE(Setup) + { + fixture_.Setup(); + } + TEST_METHOD_CLEANUP(Cleanup) + { + fixture_.Cleanup(); + } + // Connect and vanish. Repeated churn must not consume admission capacity, because + // one accepted handle is already a whole session. + TEST_METHOD(OneSidedConnectAndClose) + { + WaitForAcceptorCount_(reservedAcceptors_); + for (int i = 0; i < 50; i++) { + AssertRawConnects_(); + // each vanishing client costs a pipe instance, so the replacement has to + // arrive before the next one, or the reserve erodes one connect at a time + AssertAcceptorReserveHeld_(); + } + AssertClientCanRunASession_(); + AssertTransportIdle_(); + } + // Hold connections open. Holding more than the reserved instance count must not + // block anyone, because acceptors are replenished per accept. + TEST_METHOD(HeldConnectionsDoNotStarveAdmission) + { + std::vector held; + for (uint32_t i = 0; i < reservedAcceptors_ * 4; i++) { + held.emplace_back(CtrlPipe_()); + AssertConnected_(held.back()); + } + AssertClientCanRunASession_(); + held.clear(); + AssertTransportIdle_(); + } + // Connections that never open a session are shed by the service at the handshake + // deadline, releasing their sessions while the holder still has its handles open. + TEST_METHOD(IdleConnectionsShedAtHandshakeDeadline) + { + std::vector held; + for (uint32_t i = 0; i < reservedAcceptors_ * 2; i++) { + held.emplace_back(CtrlPipe_()); + AssertConnected_(held.back()); + } + const auto start = std::chrono::steady_clock::now(); + for (auto& raw : held) { + Assert::IsTrue(raw.WaitForServerClose(handshakeDeadline_ + 2s), + L"An idle connection outlived the handshake deadline"); + } + Assert::IsTrue(std::chrono::steady_clock::now() - start >= handshakeDeadline_ / 2, + L"Idle connections were closed long before the handshake deadline"); + AssertTransportIdle_(); + AssertClientCanRunASession_(); + } + // guards against the suffixed names ever being reintroduced + TEST_METHOD(HalfPipeAbuseIsNotExpressible) + { + AssertPipeAbsent_(CtrlPipe_() + "-in"); + AssertPipeAbsent_(CtrlPipe_() + "-out"); + } + // a declared body larger than the protocol maximum must be refused before the + // receive buffer is grown to hold it + TEST_METHOD(OversizedDeclaredBodyRejected) + { + AssertDeclaredBodySizeRejected_(0x7fffffff); + AssertClientCanRunASession_(); + AssertTransportIdle_(); + } + TEST_METHOD(UndersizedDeclaredBodyRejected) + { + AssertDeclaredBodySizeRejected_(0); + AssertDeclaredBodySizeRejected_(1); + AssertClientCanRunASession_(); + AssertTransportIdle_(); + } + // a sender that declares a length and then stalls must lose only its own session + TEST_METHOD(PartialBodyTimesOutOnlyOffendingSession) + { + RawPipeClient raw{ CtrlPipe_() }; + AssertConnected_(raw); + raw.WriteTruncatedBody(4096, 2048); + // the victim runs for the whole duration of the offender's stall + auto client = fixture_.LaunchClient(); + Assert::IsTrue(raw.WaitForServerClose(10s), + L"Stalled session was not closed by the body read timeout"); + client.Quit(); + AssertTransportIdle_(); + } + // The cap must shed load without ever becoming a permanent lockout. The connection + // beyond it is refused well before the handshake deadline could shed it. + TEST_METHOD(SessionCapEnforced) + { + constexpr uint32_t sessionCap = ipc::act::SessionLimits{}.maxSessions; + std::vector held; + for (uint32_t i = 0; i < sessionCap; i++) { + held.emplace_back(CtrlPipe_()); + AssertConnected_(held.back()); + } + WaitForSessionCount_(sessionCap); + Assert::AreEqual(sessionCap, fixture_.service->QueryStatus().actionSessionCount); + RawPipeClient excess{ CtrlPipe_() }; + AssertConnected_(excess); + Assert::IsTrue(excess.WaitForServerClose(1s), + L"Session beyond the cap was not dropped"); + held.clear(); + AssertClientCanRunASession_(); + AssertTransportIdle_(); + } + // shutting down with traffic in every state must not hang or crash + TEST_METHOD(ShutdownWithConnectionsInFlight) + { + std::vector held; + for (uint32_t i = 0; i < reservedAcceptors_ * 2; i++) { + held.emplace_back(CtrlPipe_()); + } + auto client = fixture_.LaunchClient(); + fixture_.StopService(); + } + private: + const std::string& CtrlPipe_() const + { + return fixture_.GetControlPipeName(); + } + void AssertDeclaredBodySizeRejected_(uint32_t declaredSize) + { + RawPipeClient raw{ CtrlPipe_() }; + AssertConnected_(raw); + raw.WriteDeclaredBodySize(declaredSize); + Assert::IsTrue(raw.WaitForServerClose(rejectionDeadline_), + L"Out of range packet body size was not rejected promptly"); + } + void AssertRawConnects_() + { + RawPipeClient raw{ CtrlPipe_() }; + AssertConnected_(raw); + } + // a real client completing a session is the only convincing proof of serviceability + void AssertClientCanRunASession_() + { + auto client = fixture_.LaunchClient(); + client.Quit(); + } + void AssertTransportIdle_() + { + WaitForSessionCount_(0); + WaitForAcceptorCount_(reservedAcceptors_); + const auto status = fixture_.service->QueryStatus(); + Assert::AreEqual(0u, status.actionSessionCount, L"Sessions leaked after abuse"); + Assert::AreEqual(reservedAcceptors_, status.actionAcceptorCount, + L"Acceptor pool was not replenished after abuse"); + } + // A replacement listener is counted before the one it replaces is released, so the + // pool can read one high for an instant but must never read low: that is exactly + // the erosion this case is about, and it needs no settling time to observe. + void AssertAcceptorReserveHeld_() + { + const auto count = fixture_.service->QueryStatus().actionAcceptorCount; + Assert::IsTrue(count >= reservedAcceptors_, util::str::ToWide(std::format( + "A vanishing client consumed a listener, pool down to {}", count)).c_str()); + } + void WaitForSessionCount_(uint32_t expected) + { + for (int i = 0; i < 100; i++) { + if (fixture_.service->QueryStatus().actionSessionCount == expected) { + return; + } + std::this_thread::sleep_for(20ms); + } + } + void WaitForAcceptorCount_(uint32_t expected) + { + for (int i = 0; i < 100; i++) { + if (fixture_.service->QueryStatus().actionAcceptorCount == expected) { + return; + } + std::this_thread::sleep_for(20ms); + } + } + static void AssertConnected_(const RawPipeClient& raw) + { + Assert::IsTrue(raw.IsConnected(), util::str::ToWide(std::format( + "Raw connect to the control pipe failed with error {}", raw.GetConnectError())).c_str()); + } + static void AssertPipeAbsent_(const std::string& name) + { + const RawPipeClient raw{ name, 0ms }; + Assert::IsFalse(raw.IsConnected(), L"A suffixed half-pipe name still exists"); + Assert::AreEqual((DWORD)ERROR_FILE_NOT_FOUND, raw.GetConnectError()); + } + }; + TEST_CLASS(ServiceCrashTests) { private: @@ -588,7 +793,10 @@ namespace MultiClientTests return args; } } fixture_; - static constexpr auto clientExitTimeout_ = 3s; + // the case is about a client exiting at all rather than hanging on a dead service; + // a Debug teardown (introspection, shm, query release) alone can run for seconds + // on a loaded machine, so the budget is generous on purpose + static constexpr auto clientExitTimeout_ = 10s; void RunCrashCase_(const std::vector& args) { diff --git a/IntelPresentMon/PresentMonAPI2Tests/PipeAcceptReliabilityTests.cpp b/IntelPresentMon/PresentMonAPI2Tests/PipeAcceptReliabilityTests.cpp index aec70d13c..5e5c84b25 100644 --- a/IntelPresentMon/PresentMonAPI2Tests/PipeAcceptReliabilityTests.cpp +++ b/IntelPresentMon/PresentMonAPI2Tests/PipeAcceptReliabilityTests.cpp @@ -1,5 +1,6 @@ // Copyright (C) 2022-2025 Intel Corporation // SPDX-License-Identifier: MIT +#include "../CommonUtilities/win/WinAPI.h" #include "CppUnitTest.h" #include "TestProcess.h" #include "Folders.h" diff --git a/IntelPresentMon/PresentMonAPI2Tests/PipePairTests.cpp b/IntelPresentMon/PresentMonAPI2Tests/PipePairTests.cpp new file mode 100644 index 000000000..459acd30e --- /dev/null +++ b/IntelPresentMon/PresentMonAPI2Tests/PipePairTests.cpp @@ -0,0 +1,308 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: MIT +// Exercises the connected pipe pair: both ends created by one process, so no other +// process can connect to it. The kernel uses one per UI launch. +#include "../CommonUtilities/win/WinAPI.h" +#include "CppUnitTest.h" +#include "../CommonUtilities/pipe/Pipe.h" +#include +#include +#include +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; +using namespace std::literals; + +namespace PipePairTests +{ + using namespace pmon::util; + namespace as = boost::asio; + + struct Note + { + uint32_t value = 0; + template void serialize(A& ar) { ar(value); } + }; + + // larger than the pipe's buffer, so writing it needs the peer to read + struct Bulk + { + std::string bytes; + template void serialize(A& ar) { ar(bytes); } + }; + + std::string MakeUniquePipeName_() + { + static std::atomic nextId = 0; + return std::format(R"(\\.\pipe\pm-pair-test-{}-{})", GetCurrentProcessId(), nextId++); + } + + // runs a coroutine on ioctx to completion and rethrows anything it threw + template + void RunToCompletion_(as::io_context& ioctx, F&& coro) + { + auto done = as::co_spawn(ioctx, std::forward(coro), as::use_future); + ioctx.run(); + done.get(); + } + + TEST_CLASS(PipePairTests) + { + public: + TEST_METHOD(PairCarriesPacketsBothWays) + { + as::io_context ioctx; + auto pair = pipe::DuplexPipe::MakeConnectedPair(MakeUniquePipeName_(), ioctx, {}); + auto pClient = pipe::DuplexPipe::AdoptConnectedClientAsPtr(pair.client.Release(), ioctx); + Assert::AreEqual((uint32_t)GetCurrentProcessId(), pair.pServer->GetClientProcessId(), + L"The pair's client was not this process"); + Note atClient[2]; + Note atServer[2]; + RunToCompletion_(ioctx, [&]() -> as::awaitable { + co_await pair.pServer->WritePacket(Note{ 1 }, Note{ 2 }); + atClient[0] = co_await pClient->ReadPacketConsumeHeader(); + atClient[1] = pClient->ConsumePacketPayload(); + co_await pClient->WritePacket(Note{ 3 }, Note{ 4 }); + atServer[0] = co_await pair.pServer->ReadPacketConsumeHeader(); + atServer[1] = pair.pServer->ConsumePacketPayload(); + }); + Assert::AreEqual(1u, atClient[0].value); + Assert::AreEqual(2u, atClient[1].value); + Assert::AreEqual(3u, atServer[0].value); + Assert::AreEqual(4u, atServer[1].value); + } + // The pair has its only instance connected, so the name refuses everyone else. + TEST_METHOD(PairNameRefusesAnotherClient) + { + as::io_context ioctx; + const auto name = MakeUniquePipeName_(); + auto pair = pipe::DuplexPipe::MakeConnectedPair(name, ioctx, {}); + const win::Handle intruder{ CreateFileA(name.c_str(), GENERIC_READ | GENERIC_WRITE, 0, + nullptr, OPEN_EXISTING, 0, nullptr) }; + const auto error = GetLastError(); + Assert::IsFalse((bool)intruder, L"Another client connected to the pair's name"); + Assert::AreEqual((DWORD)ERROR_PIPE_BUSY, error); + } + // A name someone created first must not be used: they would own the other instance. + TEST_METHOD(PairRefusesNameAlreadyInUse) + { + as::io_context ioctx; + const auto name = MakeUniquePipeName_(); + auto squatter = pipe::DuplexPipe::MakeAsPtr(name, ioctx); + Assert::ExpectException([&] { + pipe::DuplexPipe::MakeConnectedPair(name, ioctx, {}); + }, L"A pair was created on a name already in use"); + } + // A peer that stops reading cannot hold a write forever: the deadline throws + // PipeWriteTimeout, and the unsent bytes are dropped so the next writer starts clean. + TEST_METHOD(WriteTimesOutWhenPeerStopsReading) + { + as::io_context ioctx; + auto pair = pipe::DuplexPipe::MakeConnectedPair(MakeUniquePipeName_(), ioctx, {}); + // the client end is adopted but never read + auto pClient = pipe::DuplexPipe::AdoptConnectedClientAsPtr(pair.client.Release(), ioctx); + bool timedOut = false; + const auto start = std::chrono::steady_clock::now(); + RunToCompletion_(ioctx, [&]() -> as::awaitable { + try { + co_await pair.pServer->WritePacket(Note{ 1 }, Bulk{ std::string(512 * 1024, 'x') }, 50u); + } + catch (const pipe::PipeWriteTimeout&) { + timedOut = true; + } + }); + const auto elapsed = std::chrono::steady_clock::now() - start; + Assert::IsTrue(timedOut, L"A write to a peer that does not read did not time out"); + Assert::IsTrue(elapsed < 2s, L"The write timeout took far longer than its deadline"); + Assert::AreEqual((size_t)0, pair.pServer->GetWriteBufferPending(), + L"A timed-out write left unsent bytes in the write buffer"); + } + // This is how the kernel sees its UI exit: the only client handle closes. + TEST_METHOD(ClosingClientEndEndsServerReads) + { + as::io_context ioctx; + auto pair = pipe::DuplexPipe::MakeConnectedPair(MakeUniquePipeName_(), ioctx, {}); + auto pClient = pipe::DuplexPipe::AdoptConnectedClientAsPtr(pair.client.Release(), ioctx); + pClient.reset(); + bool broken = false; + RunToCompletion_(ioctx, [&]() -> as::awaitable { + try { + co_await pair.pServer->ReadPacketConsumeHeader(); + } + catch (const pipe::PipeBroken&) { + broken = true; + } + }); + Assert::IsTrue(broken, L"Closing the client end did not end the server's read"); + } + // Closing the pipe while a write with a deadline is queued for the write lock wakes it + // as cancelled, promptly, rather than letting it run to its timeout. + TEST_METHOD(CloseWakesQueuedTimedWrite) + { + as::io_context ioctx; + auto pair = pipe::DuplexPipe::MakeConnectedPair(MakeUniquePipeName_(), ioctx, {}); + // the client end is adopted but never read, so the first write holds the lock + auto pClient = pipe::DuplexPipe::AdoptConnectedClientAsPtr(pair.client.Release(), ioctx); + bool firstDone = false; + bool queuedDone = false; + bool queuedCancelled = false; + std::chrono::steady_clock::duration queuedWaited{}; + RunToCompletion_(ioctx, [&]() -> as::awaitable { + const auto ex = co_await as::this_coro::executor; + as::co_spawn(ex, [&]() -> as::awaitable { + try { + co_await pair.pServer->WritePacket(Note{ 1 }, Bulk{ std::string(512 * 1024, 'x') }, 5000u); + } + catch (...) { + } + firstDone = true; + }, as::detached); + as::steady_timer pause{ ex }; + pause.expires_after(20ms); + co_await pause.async_wait(as::use_awaitable); + const auto start = std::chrono::steady_clock::now(); + as::co_spawn(ex, [&]() -> as::awaitable { + try { + co_await pair.pServer->WritePacket(Note{ 2 }, Note{ 3 }, 5000u); + } + catch (const pipe::PipeOperationCanceled&) { + queuedCancelled = true; + } + catch (...) { + } + queuedWaited = std::chrono::steady_clock::now() - start; + queuedDone = true; + }, as::detached); + pause.expires_after(20ms); + co_await pause.async_wait(as::use_awaitable); + pair.pServer->Close(); + while (!firstDone || !queuedDone) { + co_await as::post(ex, as::use_awaitable); + } + }); + Assert::IsTrue(queuedCancelled, L"Closing the pipe did not cancel the write queued for the lock"); + Assert::IsTrue(queuedWaited < 1s, L"The queued write ran on toward its timeout after the pipe closed"); + } + }; + + TEST_CLASS(CoroMutexTests) + { + public: + // A timed waiter that gives up leaves the queue and its count, so the mutex is free + // as soon as the holder releases it. + TEST_METHOD(TimedWaiterTimesOutAndLeavesNoCount) + { + as::io_context ioctx; + pipe::CoroMutex mtx{ ioctx }; + auto result = pipe::CoroMutex::LockResult::Acquired; + std::chrono::steady_clock::duration waited{}; + bool heldWhileHolding = false; + bool freeAfterRelease = false; + RunToCompletion_(ioctx, [&]() -> as::awaitable { + auto holder = co_await pipe::CoroLock(mtx); + const auto start = std::chrono::steady_clock::now(); + const auto waiter = co_await pipe::CoroLock(mtx, 50ms); + waited = std::chrono::steady_clock::now() - start; + result = waiter.GetResult(); + heldWhileHolding = !mtx.TryLock(); + if (!heldWhileHolding) { + mtx.Unlock(); + } + holder = pipe::CoroLockGuard{}; + freeAfterRelease = mtx.TryLock(); + if (freeAfterRelease) { + mtx.Unlock(); + } + }); + Assert::IsTrue(result == pipe::CoroMutex::LockResult::TimedOut, L"The waiter did not time out"); + Assert::IsTrue(waited < 1s, L"The timed wait ran far past its timeout"); + Assert::IsTrue(heldWhileHolding, L"The timed-out waiter's count was undone twice"); + Assert::IsTrue(freeAfterRelease, L"A timed-out waiter left its count on the mutex"); + } + // Unlock and the timeout can wake a waiter at the same moment. Whichever wins, an + // acquired waiter owns the mutex and releases it through its guard, a timed-out waiter + // never owns it, and the mutex ends up free. + TEST_METHOD(GrantRacingTimeoutKeepsOwnershipConsistent) + { + as::io_context ioctx; + pipe::CoroMutex mtx{ ioctx }; + int acquired = 0; + int timedOut = 0; + int leftHeld = 0; + constexpr int rounds = 200; + RunToCompletion_(ioctx, [&]() -> as::awaitable { + const auto ex = co_await as::this_coro::executor; + for (int i = 0; i < rounds; i++) { + auto holder = co_await pipe::CoroLock(mtx); + bool done = false; + as::co_spawn(ex, [&]() -> as::awaitable { + const auto waiter = co_await pipe::CoroLock(mtx, 1ms); + if (waiter.GetResult() == pipe::CoroMutex::LockResult::Acquired) { + acquired++; + } + else if (waiter.GetResult() == pipe::CoroMutex::LockResult::TimedOut) { + timedOut++; + } + done = true; + }, as::detached); + // release on a timer due at the same moment as the waiter's timeout + as::steady_timer release{ ex }; + release.expires_after(1ms); + co_await release.async_wait(as::use_awaitable); + holder = pipe::CoroLockGuard{}; + while (!done) { + co_await as::post(ex, as::use_awaitable); + } + if (mtx.TryLock()) { + mtx.Unlock(); + } + else { + leftHeld++; + } + } + }); + Assert::AreEqual(rounds, acquired + timedOut, L"A waiter was neither granted nor timed out"); + Assert::AreEqual(0, leftHeld, L"The mutex was left held after a grant raced a timeout"); + } + // Closing the pipe (CancelWaiters) wakes a timed waiter as Cancelled. Its count is undone + // once, by CancelWaiters, not again when its timer would have expired. + TEST_METHOD(CancelWaitersWakesTimedWaiterOnce) + { + as::io_context ioctx; + pipe::CoroMutex mtx{ ioctx }; + auto result = pipe::CoroMutex::LockResult::Acquired; + bool heldWhileHolding = false; + bool freeAfterRelease = false; + RunToCompletion_(ioctx, [&]() -> as::awaitable { + const auto ex = co_await as::this_coro::executor; + auto holder = co_await pipe::CoroLock(mtx); + bool done = false; + as::co_spawn(ex, [&]() -> as::awaitable { + const auto waiter = co_await pipe::CoroLock(mtx, 5000ms); + result = waiter.GetResult(); + done = true; + }, as::detached); + // let the waiter queue + co_await as::post(ex, as::use_awaitable); + co_await as::post(ex, as::use_awaitable); + mtx.CancelWaiters(); + while (!done) { + co_await as::post(ex, as::use_awaitable); + } + // the holder still holds it: a second decrement would make it look free + heldWhileHolding = !mtx.TryLock(); + if (!heldWhileHolding) { + mtx.Unlock(); + } + holder = pipe::CoroLockGuard{}; + freeAfterRelease = mtx.TryLock(); + if (freeAfterRelease) { + mtx.Unlock(); + } + }); + Assert::IsTrue(result == pipe::CoroMutex::LockResult::Cancelled, L"CancelWaiters did not wake the timed waiter as cancelled"); + Assert::IsTrue(heldWhileHolding, L"The cancelled waiter's count was undone twice"); + Assert::IsTrue(freeAfterRelease, L"The mutex count was wrong after cancelling a timed waiter"); + } + }; +} diff --git a/IntelPresentMon/PresentMonAPI2Tests/PipeSecurityRegressionTests.cpp b/IntelPresentMon/PresentMonAPI2Tests/PipeSecurityRegressionTests.cpp index 4f215bf42..1277b1965 100644 --- a/IntelPresentMon/PresentMonAPI2Tests/PipeSecurityRegressionTests.cpp +++ b/IntelPresentMon/PresentMonAPI2Tests/PipeSecurityRegressionTests.cpp @@ -4,6 +4,7 @@ // Hermetic pipe security regressions for CI (no elevation). Full SY-listener AU squat on the // MSI default pipe remains manual (ActionPipeProbe / optional VM). +#include "../CommonUtilities/win/WinAPI.h" #include "CppUnitTest.h" #include "TestProcess.h" #include "Folders.h" diff --git a/IntelPresentMon/PresentMonAPI2Tests/PresentMonAPI2Tests.vcxproj b/IntelPresentMon/PresentMonAPI2Tests/PresentMonAPI2Tests.vcxproj index 9e0da0986..68d44e847 100644 --- a/IntelPresentMon/PresentMonAPI2Tests/PresentMonAPI2Tests.vcxproj +++ b/IntelPresentMon/PresentMonAPI2Tests/PresentMonAPI2Tests.vcxproj @@ -110,12 +110,20 @@ + + + + + + + + @@ -157,10 +165,12 @@ + + diff --git a/IntelPresentMon/PresentMonAPI2Tests/PresentMonAPI2Tests.vcxproj.filters b/IntelPresentMon/PresentMonAPI2Tests/PresentMonAPI2Tests.vcxproj.filters index 337b75a4d..29087ed49 100644 --- a/IntelPresentMon/PresentMonAPI2Tests/PresentMonAPI2Tests.vcxproj.filters +++ b/IntelPresentMon/PresentMonAPI2Tests/PresentMonAPI2Tests.vcxproj.filters @@ -24,6 +24,24 @@ Source Files + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + Source Files @@ -36,6 +54,12 @@ Source Files + + Source Files + + + Source Files + Source Files @@ -94,6 +118,12 @@ Header Files + + Header Files + + + Header Files + Header Files diff --git a/IntelPresentMon/PresentMonAPI2Tests/RawPipeClient.h b/IntelPresentMon/PresentMonAPI2Tests/RawPipeClient.h new file mode 100644 index 000000000..bf53dbb34 --- /dev/null +++ b/IntelPresentMon/PresentMonAPI2Tests/RawPipeClient.h @@ -0,0 +1,86 @@ +// Copyright (C) 2022-2023 Intel Corporation +// SPDX-License-Identifier: MIT +#pragma once +#include "../CommonUtilities/win/WinAPI.h" +#include "../CommonUtilities/win/Handle.h" +#include +#include +#include +#include + +// raw control-pipe client for transport abuse cases, which need to write bytes the +// real client would never produce (and sometimes to write nothing at all) +class RawPipeClient +{ +public: + // A server posts a bounded number of pipe instances, so a connect can legitimately find + // them all taken (ERROR_PIPE_BUSY) or find the name momentarily gone while acceptors are + // replenished (ERROR_FILE_NOT_FOUND). Real clients poll through this via + // WaitForAvailability, and so must we. Pass a zero timeout to probe without waiting. + explicit RawPipeClient(const std::string& pipeName, std::chrono::milliseconds connectTimeout = std::chrono::seconds{ 2 }) + { + const auto deadline = std::chrono::steady_clock::now() + connectTimeout; + do { + handle_ = pmon::util::win::Handle{ CreateFileA(pipeName.c_str(), + GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, 0, nullptr) }; + connectError_ = GetLastError(); + if (handle_ || (connectError_ != ERROR_PIPE_BUSY && connectError_ != ERROR_FILE_NOT_FOUND)) { + return; + } + std::this_thread::sleep_for(std::chrono::milliseconds{ 2 }); + } while (std::chrono::steady_clock::now() < deadline); + } + RawPipeClient(const RawPipeClient&) = delete; + RawPipeClient& operator=(const RawPipeClient&) = delete; + RawPipeClient(RawPipeClient&&) = default; + RawPipeClient& operator=(RawPipeClient&&) = default; + ~RawPipeClient() = default; + bool IsConnected() const + { + return (bool)handle_; + } + DWORD GetConnectError() const + { + return connectError_; + } + void Write(const void* pData, size_t size) + { + DWORD written = 0; + WriteFile(handle_, pData, (DWORD)size, &written, nullptr); + } + void WriteDeclaredBodySize(uint32_t size) + { + Write(&size, sizeof(size)); + } + // writes a length prefix followed by fewer bytes than promised, then stops + void WriteTruncatedBody(uint32_t declaredSize, uint32_t actualSize) + { + WriteDeclaredBodySize(declaredSize); + const std::vector filler(actualSize, '\0'); + Write(filler.data(), filler.size()); + } + // true once the server has closed its end, which is how a rejected session is observed + bool WaitForServerClose(std::chrono::milliseconds timeout) + { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline) { + if (IsClosedByServer()) { + return true; + } + std::this_thread::sleep_for(std::chrono::milliseconds{ 5 }); + } + return false; + } + bool IsClosedByServer() const + { + DWORD available = 0; + return !PeekNamedPipe(handle_, nullptr, 0, nullptr, &available, nullptr); + } + void Close() + { + handle_.Clear(); + } +private: + pmon::util::win::Handle handle_; + DWORD connectError_ = 0; +}; diff --git a/IntelPresentMon/PresentMonAPI2Tests/TestCommands.h b/IntelPresentMon/PresentMonAPI2Tests/TestCommands.h index e7fe932c1..d8119aad5 100644 --- a/IntelPresentMon/PresentMonAPI2Tests/TestCommands.h +++ b/IntelPresentMon/PresentMonAPI2Tests/TestCommands.h @@ -18,11 +18,15 @@ namespace pmon::test std::set frameStorePids; uint32_t telemetryPeriodMs; std::optional etwFlushPeriodMs; + // action transport health: open sessions and acceptors currently posted + uint32_t actionSessionCount = 0; + uint32_t actionAcceptorCount = 0; template void serialize(Archive& ar) { - ar(trackedPids, frameStorePids, telemetryPeriodMs, etwFlushPeriodMs); + ar(trackedPids, frameStorePids, telemetryPeriodMs, etwFlushPeriodMs, + actionSessionCount, actionAcceptorCount); } }; } diff --git a/IntelPresentMon/PresentMonAPI2Tests/TestProcess.h b/IntelPresentMon/PresentMonAPI2Tests/TestProcess.h index 927178ef5..e4272cc6d 100644 --- a/IntelPresentMon/PresentMonAPI2Tests/TestProcess.h +++ b/IntelPresentMon/PresentMonAPI2Tests/TestProcess.h @@ -6,6 +6,7 @@ #include "JobManager.h" #include "Logging.h" #include "TestCommands.h" +#include "RawPipeClient.h" #include "../CommonUtilities/file/FileUtils.h" #include "../CommonUtilities/pipe/Pipe.h" #include @@ -17,6 +18,8 @@ #include #include #include +#include +#include using namespace Microsoft::VisualStudio::CppUnitTestFramework; namespace as = boost::asio; @@ -466,6 +469,10 @@ class CommonTestFixture { return std::make_unique(ioctx_, jobMan_, args, GetCommonArgs()); } + const std::string& GetControlPipeName() const + { + return GetCommonArgs().ctrlPipe; + } virtual const CommonProcessArgs& GetCommonArgs() const = 0; private: // functions diff --git a/IntelPresentMon/PresentMonAPI2Tests/TransportDemuxTests.cpp b/IntelPresentMon/PresentMonAPI2Tests/TransportDemuxTests.cpp new file mode 100644 index 000000000..80a693db1 --- /dev/null +++ b/IntelPresentMon/PresentMonAPI2Tests/TransportDemuxTests.cpp @@ -0,0 +1,303 @@ +// Copyright (C) 2022-2026 Intel Corporation +// SPDX-License-Identifier: MIT +// Request, response and push demultiplexing on one handle, and command tokens. +#include "TransportTestKit.h" + +namespace TransportDemuxTests +{ + TEST_CLASS(DemuxTests) + { + public: + TEST_METHOD_INITIALIZE(Setup) + { + RegisterActions_(); + } + // Walkthrough: one client, one request, no pushes + TEST_METHOD(WalkthroughOneRequest) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + TestServer server{ pipeName, disposeCount }; + auto pClient = ConnectClient_(pipeName, eventCount); + // a 10-minute response timeout so pausing in the debugger does not trigger a timeout + const auto res = pClient->DispatchSync(EchoWithPush::Params{ .value = 42, .pushCount = 0 }, 600000); + Assert::AreEqual(42u, res.value); + } + // the framing floor must match what the protocol actually serializes, otherwise + // the pipe layer rejects legitimate minimal packets or lets junk through + TEST_METHOD(MinimalPacketBodySizeMatchesProtocolFloor) + { + std::ostringstream body; + { + cereal::BinaryOutputArchive archive{ body }; + archive(PacketHeader{}, EmptyPayload{}); + } + Assert::AreEqual((size_t)kMinPacketBodyBytes, body.str().size(), + L"kMinPacketBodyBytes no longer matches the serialized size of an empty header"); + } + // the whole point of the demux: an event pushed while a request is in flight must + // not be mistaken for that request's response + TEST_METHOD(PushDuringRequestResolvesCorrectResponse) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + TestServer server{ pipeName, disposeCount }; + auto pClient = ConnectClient_(pipeName, eventCount); + + constexpr uint32_t requestCount = 200; + constexpr uint32_t pushesPerRequest = 3; + for (uint32_t i = 0; i < requestCount; i++) { + const auto res = pClient->DispatchSync(EchoWithPush::Params{ + .value = i, .pushCount = pushesPerRequest }); + Assert::AreEqual(i, res.value, L"Response did not match its own request"); + Assert::AreEqual((uint32_t)GetCurrentProcessId(), res.remotePid); + } + // pushes are detached, so allow them to drain before counting + for (int i = 0; i < 100 && eventCount.load() < requestCount * pushesPerRequest; i++) { + std::this_thread::sleep_for(10ms); + } + Assert::AreEqual(requestCount * pushesPerRequest, eventCount.load(), + L"Pushed events were lost while requests were in flight"); + } + // Every packet body read races a timer. When the read wins, the cancelled timer + // must finish without throwing, so healthy traffic raises no C++ exceptions at all. + TEST_METHOD(HealthyTrafficThrowsNoExceptions) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + TestServer server{ pipeName, disposeCount }; + auto pClient = ConnectClient_(pipeName, eventCount); + + constexpr uint32_t requestCount = 50; + uint32_t throws = 0; + { + CxxThrowCounter_ counter; + for (uint32_t i = 0; i < requestCount; i++) { + const auto res = pClient->DispatchSync(EchoWithPush::Params{ .value = i, .pushCount = 1 }); + Assert::AreEqual(i, res.value); + } + for (int i = 0; i < 100 && eventCount.load() < requestCount; i++) { + std::this_thread::sleep_for(10ms); + } + throws = counter.Count(); + } + Assert::AreEqual(requestCount, eventCount.load(), L"Pushed events were lost"); + Assert::AreEqual(0u, throws, L"Healthy request, response and event traffic raised C++ exceptions"); + } + // concurrent callers on one session share a reader loop; each must get its own answer + TEST_METHOD(ConcurrentRequestsOnOneSessionDoNotCrossTalk) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + TestServer server{ pipeName, disposeCount }; + auto pClient = ConnectClient_(pipeName, eventCount); + + constexpr uint32_t threadCount = 4; + constexpr uint32_t perThread = 50; + std::atomic mismatches = 0; + std::vector threads; + for (uint32_t t = 0; t < threadCount; t++) { + threads.emplace_back([&, t] { + for (uint32_t i = 0; i < perThread; i++) { + const auto value = t * 1000 + i; + const auto res = pClient->DispatchSync(EchoWithPush::Params{ + .value = value, .pushCount = 1 }); + if (res.value != value) { + mismatches.fetch_add(1); + } + } + }); + } + threads.clear(); + Assert::AreEqual(0u, mismatches.load(), L"A response was delivered to the wrong caller"); + } + // two sessions from one process exercise the per-session pending-response maps, + // where the two token counters are free to collide in value + TEST_METHOD(MultipleSessionsFromOneProcess) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCountA = 0; + std::atomic eventCountB = 0; + TestServer server{ pipeName, disposeCount }; + auto pClientA = ConnectClient_(pipeName, eventCountA); + auto pClientB = ConnectClient_(pipeName, eventCountB); + Assert::AreEqual(2u, server.GetSessionCount()); + + for (uint32_t i = 0; i < 50; i++) { + const auto resA = pClientA->DispatchSync(EchoWithPush::Params{ .value = i, .pushCount = 0 }); + const auto resB = pClientB->DispatchSync(EchoWithPush::Params{ .value = i + 500, .pushCount = 0 }); + Assert::AreEqual(i, resA.value); + Assert::AreEqual(i + 500, resB.value); + } + } + TEST_METHOD(DuplicateResponseAfterLateResponseKillsSession) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::mutex disposeMutex; + std::vector reasons; + std::atomic eventCount = 0; + TestServer server{ pipeName, disposeCount, &disposeMutex, &reasons }; + auto pClient = ConnectClient_(pipeName, eventCount); + Assert::ExpectException([&] { + pClient->DispatchSync(LateDouble::Params{ .value = 5 }, 40); + }); + for (int i = 0; i < 200 && pClient->IsRunning(); i++) { + std::this_thread::sleep_for(10ms); + } + Assert::IsFalse(pClient->IsRunning(), L"A second response for a consumed expired token left the session up"); + WaitForSessionCount_(server, 0); + Assert::AreEqual(1u, disposeCount.load()); + } + TEST_METHOD(ExpirationEvictionTreatsForgottenTokenAsUnknown) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + TestServer server{ pipeName, disposeCount }; + auto pClient = ConnectClient_(pipeName, eventCount); + for (size_t i = 0; i < kMaxExpiredResponseTokens + 1; i++) { + Assert::ExpectException([&] { + pClient->DispatchSync(DropRequest::Params{}, 30); + }); + } + const auto kept = pClient->DispatchSync(SpoofRequest::Params{ .token = 2 }); + Assert::AreEqual(2u, kept.token); + Assert::IsTrue(pClient->IsRunning()); + try { + pClient->DispatchSync(SpoofRequest::Params{ .token = 1 }); + } + catch (...) { + } + for (int i = 0; i < 200 && pClient->IsRunning(); i++) { + std::this_thread::sleep_for(10ms); + } + Assert::IsFalse(pClient->IsRunning(), L"A response for an evicted token did not end the session"); + } + TEST_METHOD(CommandTokenAllocatorDefaultStartsAtZero) + { + CommandTokenAllocator allocator; + const auto unavailable = [](uint32_t) { return false; }; + Assert::AreEqual(0u, allocator.Allocate(unavailable)); + Assert::AreEqual(1u, allocator.Allocate(unavailable)); + } + TEST_METHOD(CommandTokenAllocatorSkipsUnavailableNearWrap) + { + CommandTokenAllocator allocator{ 0xFFFFFFFFu }; + const std::unordered_set pending{ 0 }; + const std::unordered_set expired{ 1 }; + const auto unavailable = [&](uint32_t token) { + return pending.contains(token) || expired.contains(token); + }; + Assert::AreEqual(0xFFFFFFFFu, allocator.Allocate(unavailable)); + Assert::AreEqual(2u, allocator.Allocate(unavailable)); + } + TEST_METHOD(LiveTokenAllocationSkipsPendingAndExpiredOnWrap) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + TestServer server{ pipeName, disposeCount }; + Assert::IsTrue(util::pipe::DuplexPipe::WaitForAvailability(pipeName, 2000)); + class ProbeClient : public SymmetricActionClient + { + public: + ProbeClient(const std::string& pipe, std::atomic& events, CommandTokenAllocator tokens) + : + SymmetricActionClient{ pipe, ClientExecutionContext{ .pEventCount = &events }, std::move(tokens) } + { + const auto res = DispatchSync(OpenSession::Params{ .clientPid = GetCurrentProcessId() }); + EstablishSession_(res.serverPid); + } + void Probe(std::atomic& skippedPending, std::atomic& skippedExpired, std::atomic& done) + { + DispatchWithContinuation(EchoWithPush::Params{ .value = 1, .pushCount = 0 }, + [this, &skippedPending, &skippedExpired, &done](EchoWithPush::Response&&, std::exception_ptr error) { + if (!error) { + struct FakePending : PendingResponse + { + void OnComplete_(const PacketHeader&, util::pipe::DuplexPipe&) override {} + void OnFail_(std::exception_ptr) override {} + } fake; + SessionConnector_().ExpirePending(0); + skippedExpired = SessionConnector_().AllocateCommandToken(); + SessionConnector_().RegisterPending(2, fake); + skippedPending = SessionConnector_().AllocateCommandToken(); + SessionConnector_().ExpirePending(2); + } + done.fetch_add(1); + }); + } + }; + ProbeClient client{ pipeName, eventCount, CommandTokenAllocator{ 0xFFFFFFFEu } }; + std::atomic skippedPending = 0; + std::atomic skippedExpired = 0; + std::atomic done = 0; + client.Probe(skippedPending, skippedExpired, done); + for (int i = 0; i < 200 && done.load() == 0; i++) { + std::this_thread::sleep_for(10ms); + } + Assert::AreEqual(1, done.load()); + Assert::AreEqual(1u, skippedExpired.load(), L"Wrap landed on a retained expiration"); + Assert::AreEqual(3u, skippedPending.load(), L"Wrap landed on a pending token"); + } + TEST_METHOD(LiveRequestsWrapCommandTokens) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + TestServer server{ pipeName, disposeCount }; + Assert::IsTrue(util::pipe::DuplexPipe::WaitForAvailability(pipeName, 2000)); + class WrapClient : public SymmetricActionClient + { + public: + WrapClient(const std::string& pipe, std::atomic& events) + : + SymmetricActionClient{ pipe, ClientExecutionContext{ .pEventCount = &events }, + CommandTokenAllocator{ 0xFFFFFFFEu } } + { + const auto res = DispatchSync(OpenSession::Params{ .clientPid = GetCurrentProcessId() }); + EstablishSession_(res.serverPid); + } + }; + WrapClient client{ pipeName, eventCount }; + const auto first = client.DispatchSync(EchoToken::Params{ .value = 10 }); + const auto second = client.DispatchSync(EchoToken::Params{ .value = 11 }); + const auto third = client.DispatchSync(EchoToken::Params{ .value = 12 }); + Assert::AreEqual(10u, first.value); + Assert::AreEqual(0xFFFFFFFFu, first.token); + Assert::AreEqual(11u, second.value); + Assert::AreEqual(0u, second.token); + Assert::AreEqual(12u, third.value); + Assert::AreEqual(1u, third.token); + } + // remotePid is only diagnostic: a session opened under another pid is reported in + // the log but still served + TEST_METHOD(OpenSessionUnderAnotherPidIsStillServed) + { + AssertOpenSessionUnderPidIsServed_(GetCurrentProcessId() + 4); + AssertOpenSessionUnderPidIsServed_(GetCurrentProcessId() + 4); + } + private: + template + static void AssertOpenSessionUnderPidIsServed_(uint32_t claimedPid) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + TestServer server{ pipeName, disposeCount }; + Assert::IsTrue(util::pipe::DuplexPipe::WaitForAvailability(pipeName, 2000), + L"Timed out waiting for the test transport pipe"); + TestClient client{ pipeName, eventCount, claimedPid }; + const auto res = client.DispatchSync(EchoWithPush::Params{ .value = 14, .pushCount = 0 }); + Assert::AreEqual(14u, res.value, L"A session opened under another pid was not served"); + Assert::AreEqual(claimedPid, res.remotePid, L"The session did not keep the pid it claimed"); + Assert::AreEqual(0u, disposeCount.load(), L"A session opened under another pid was ended"); + } + }; +} diff --git a/IntelPresentMon/PresentMonAPI2Tests/TransportLifecycleTests.cpp b/IntelPresentMon/PresentMonAPI2Tests/TransportLifecycleTests.cpp new file mode 100644 index 000000000..d55f5688f --- /dev/null +++ b/IntelPresentMon/PresentMonAPI2Tests/TransportLifecycleTests.cpp @@ -0,0 +1,677 @@ +// Copyright (C) 2022-2026 Intel Corporation +// SPDX-License-Identifier: MIT +// Shutdown, session disposal and its reasons, admission racing shutdown, runner joins and final +// teardown. +#include "TransportTestKit.h" + +namespace TransportDemuxTests +{ + TEST_CLASS(LifecycleTests) + { + public: + TEST_METHOD_INITIALIZE(Setup) + { + RegisterActions_(); + } + TEST_METHOD(ClientIsNotRunningAfterServerDisconnect) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + std::unique_ptr pClient; + { + TestServer server{ pipeName, disposeCount }; + pClient = ConnectClient_(pipeName, eventCount); + Assert::IsTrue(pClient->IsRunning(), L"Client should be running while session is active"); + } + for (int i = 0; i < 200 && pClient->IsRunning(); i++) { + std::this_thread::sleep_for(10ms); + } + Assert::IsFalse(pClient->IsRunning(), + L"Client io context should stop when the session reader strand exits"); + } + // A continuation shuts down its own client while another request on that + // client is still pending. Ownership stays with this test until both settle. + TEST_METHOD(PendingRequestFailsWhenShutdownFromContinuationOnIoThread) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + TestServer server{ pipeName, disposeCount }; + auto pClient = ConnectClient_(pipeName, eventCount); + + std::atomic pendingFailed = false; + std::atomic pendingFinished = false; + std::jthread pendingRequester{ [&] { + try { + pClient->DispatchSync(DropRequest::Params{}); + } + catch (...) { + pendingFailed = true; + } + pendingFinished = true; + } }; + std::this_thread::sleep_for(100ms); + + std::atomic continuationCalls = 0; + std::atomic shutdownPosted = false; + pClient->DispatchWithContinuation(EchoWithPush::Params{ .value = 1, .pushCount = 0 }, + [&](EchoWithPush::Response&&, std::exception_ptr) { + continuationCalls.fetch_add(1); + pClient->BeginShutdown(); + shutdownPosted = true; + }); + + for (int i = 0; i < 300 && (!pendingFinished || !shutdownPosted); i++) { + std::this_thread::sleep_for(10ms); + } + Assert::AreEqual(1, continuationCalls.load(), L"Continuation did not run exactly once"); + Assert::IsTrue(shutdownPosted.load(), L"Shutdown was not initiated from the continuation"); + Assert::IsTrue(pendingFinished.load(), L"Same-client shutdown left a pending request hung"); + Assert::IsTrue(pendingFailed.load(), L"A pending request during same-client shutdown did not fail"); + pClient->WaitForShutdown(); + Assert::IsTrue(pClient->GetLifecyclePhase() == LifecyclePhase::Stopped); + } + TEST_METHOD(PendingRequestFailsWhenClientShutsDown) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + TestServer server{ pipeName, disposeCount }; + auto pClient = ConnectClient_(pipeName, eventCount); + + std::atomic failed = false; + std::atomic finished = false; + std::jthread requester{ [&] { + try { + pClient->DispatchSync(DropRequest::Params{}); + } + catch (...) { + failed = true; + } + finished = true; + } }; + // let the responder consume the request without answering it + std::this_thread::sleep_for(100ms); + pClient->BeginShutdown(); + for (int i = 0; i < 300 && !finished; i++) { + std::this_thread::sleep_for(10ms); + } + Assert::IsTrue(finished.load(), L"Shutdown left a request waiting for a response"); + Assert::IsTrue(failed.load(), L"A request that can never be answered reported success"); + } + // shutdown cleanup must not become a stall: a client whose server is already gone, + // and one whose server is still there, both have to tear down promptly + TEST_METHOD(ClientShutdownIsPrompt) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + { + TestServer server{ pipeName, disposeCount }; + auto pClient = ConnectClient_(pipeName, eventCount); + AssertTeardownIsPrompt_(pClient, L"with a live server"); + WaitForSessionCount_(server, 0); + Assert::AreEqual(1u, disposeCount.load(), L"Live server did not dispose the torn-down client"); + Assert::AreEqual(TestServer<>::reservedAcceptors, server.GetAcceptorCount()); + } + AssertPipeAbsent_(pipeName); + std::unique_ptr pOrphan; + { + TestServer server{ pipeName, disposeCount }; + try { + pOrphan = ConnectClient_(pipeName, eventCount); + } + catch (const std::exception& ex) { + Assert::Fail(util::str::ToWide(std::format( + "second connect failed sessions={} acceptors={} what={}", + server.GetSessionCount(), server.GetAcceptorCount(), ex.what())).c_str()); + } + } + AssertTeardownIsPrompt_(pOrphan, L"after the server dropped"); + } + TEST_METHOD(RepeatedShutdownIsIdempotent) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + TestServer server{ pipeName, disposeCount }; + auto pClient = ConnectClient_(pipeName, eventCount); + pClient->BeginShutdown(); + pClient->BeginShutdown(); + pClient->WaitForShutdown(); + pClient->WaitForShutdown(); + Assert::IsTrue(pClient->GetLifecyclePhase() == LifecyclePhase::Stopped); + } + TEST_METHOD(DestructionAfterAsyncShutdownIsPrompt) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + TestServer server{ pipeName, disposeCount }; + auto pClient = ConnectClient_(pipeName, eventCount); + pClient->BeginShutdown(); + const auto start = std::chrono::steady_clock::now(); + pClient.reset(); + Assert::IsTrue(std::chrono::steady_clock::now() - start < 500ms, + L"Destroying a client after BeginShutdown stalled"); + } + TEST_METHOD(ShutdownAfterPeerDisconnectReachesStopped) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + std::unique_ptr pClient; + { + TestServer server{ pipeName, disposeCount }; + pClient = ConnectClient_(pipeName, eventCount); + } + for (int i = 0; i < 200 && pClient->IsRunning(); i++) { + std::this_thread::sleep_for(10ms); + } + Assert::IsFalse(pClient->IsRunning()); + pClient->BeginShutdown(); + pClient->WaitForShutdown(); + Assert::IsTrue(pClient->GetLifecyclePhase() == LifecyclePhase::Stopped); + } + TEST_METHOD(DispatchRacingShutdownSettlesOrRejects) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + TestServer server{ pipeName, disposeCount }; + auto pClient = ConnectClient_(pipeName, eventCount); + std::atomic completed = 0; + std::atomic rejected = 0; + std::vector threads; + for (int t = 0; t < 4; t++) { + threads.emplace_back([&] { + const auto deadline = std::chrono::steady_clock::now() + 3s; + while (std::chrono::steady_clock::now() < deadline) { + try { + const auto res = pClient->DispatchSync(EchoWithPush::Params{ .value = 3, .pushCount = 0 }); + if (res.value == 3) { + completed.fetch_add(1); + } + } + catch (...) { + rejected.fetch_add(1); + return; + } + } + }); + } + std::this_thread::sleep_for(30ms); + pClient->BeginShutdown(); + threads.clear(); + Assert::IsTrue(rejected.load() > 0, L"Shutdown never rejected or failed a dispatch"); + pClient->WaitForShutdown(); + Assert::IsTrue(pClient->GetLifecyclePhase() == LifecyclePhase::Stopped); + } + TEST_METHOD(ContinuationRunsExactlyOnce) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + TestServer server{ pipeName, disposeCount }; + auto pClient = ConnectClient_(pipeName, eventCount); + std::atomic calls = 0; + pClient->DispatchWithContinuation(EchoWithPush::Params{ .value = 9, .pushCount = 0 }, + [&](EchoWithPush::Response&& res, std::exception_ptr error) { + calls.fetch_add(1); + Assert::IsTrue(error == nullptr); + Assert::AreEqual(9u, res.value); + }); + for (int i = 0; i < 200 && calls.load() == 0; i++) { + std::this_thread::sleep_for(10ms); + } + std::this_thread::sleep_for(50ms); + Assert::AreEqual(1, calls.load()); + } + TEST_METHOD(DisposeOnceOnDisconnectProtocolFailureAndLocalShutdown) + { + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::mutex disposeMutex; + std::vector reasons; + std::atomic eventCount = 0; + TestServer server{ pipeName, disposeCount, &disposeMutex, &reasons }; + { + auto pClient = ConnectClient_(pipeName, eventCount); + WaitForSessionCount_(server, 1); + } + WaitForSessionCount_(server, 0); + Assert::AreEqual(1u, disposeCount.load()); + Assert::AreEqual((size_t)1, reasons.size()); + Assert::IsTrue(reasons[0] == SessionEndReason::PeerDisconnected); + } + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::mutex disposeMutex; + std::vector reasons; + std::atomic eventCountA = 0; + std::atomic eventCountB = 0; + TestServer server{ pipeName, disposeCount, &disposeMutex, &reasons }; + auto pOther = ConnectClient_(pipeName, eventCountB); + (void)eventCountA; + std::ostringstream body; + { + cereal::BinaryOutputArchive archive{ body }; + PacketHeader header{ + .identifier = "Nope", + .commandToken = 0xA11F0000u, + .packetType = PacketType::ActionResponse, + .headerVersion = kHeaderVersion, + }; + archive(header, EmptyPayload{}); + } + const auto payload = body.str(); + const uint32_t payloadSize = (uint32_t)payload.size(); + util::win::Handle raw; + for (int i = 0; i < 200 && !raw; i++) { + raw = util::win::Handle{ CreateFileA(pipeName.c_str(), GENERIC_READ | GENERIC_WRITE, + 0, nullptr, OPEN_EXISTING, 0, nullptr) }; + if (!raw) { + std::this_thread::sleep_for(5ms); + } + } + Assert::IsTrue((bool)raw, L"Could not open a raw session to inject an unknown response"); + DWORD written = 0; + Assert::IsTrue(WriteFile(raw, &payloadSize, sizeof(payloadSize), &written, nullptr) != FALSE); + Assert::IsTrue(WriteFile(raw, payload.data(), (DWORD)payload.size(), &written, nullptr) != FALSE); + for (int i = 0; i < 200 && disposeCount.load() == 0; i++) { + std::this_thread::sleep_for(10ms); + } + Assert::AreEqual(1u, server.GetSessionCount()); + const auto res = pOther->DispatchSync(EchoWithPush::Params{ .value = 4, .pushCount = 0 }); + Assert::AreEqual(4u, res.value); + Assert::AreEqual(1u, disposeCount.load()); + Assert::AreEqual((size_t)1, reasons.size()); + Assert::IsTrue(reasons[0] == SessionEndReason::ProtocolFailure); + } + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::mutex disposeMutex; + std::vector reasons; + std::atomic eventCount = 0; + TestServer server{ pipeName, disposeCount, &disposeMutex, &reasons }; + auto pClient = ConnectClient_(pipeName, eventCount); + WaitForSessionCount_(server, 1); + server.BeginShutdown(); + server.WaitForShutdown(); + Assert::IsTrue(server.GetLifecyclePhase() == LifecyclePhase::Stopped); + Assert::AreEqual(1u, disposeCount.load()); + Assert::AreEqual((size_t)1, reasons.size()); + Assert::IsTrue(reasons[0] == SessionEndReason::LocalShutdown); + } + } + TEST_METHOD(DispatchAcceptanceIsDecidedWithScheduling) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + TestServer server{ pipeName, disposeCount }; + auto pClient = ConnectClient_(pipeName, eventCount); + AdmissionHookState_ hook; + hook.client = pClient.get(); + gAdmissionHook_ = &hook; + pClient->SetAdmissionHookForTest(&AdmissionHookFn_); + std::atomic dispatchDone{ false }; + std::jthread dispatcher{ [&] { + try { + (void)pClient->DispatchSync(EchoWithPush::Params{ .value = 4, .pushCount = 0 }); + } + catch (...) { + } + dispatchDone.store(true); + } }; + { + std::unique_lock lock{ hook.mutex }; + Assert::IsTrue(hook.cv.wait_for(lock, 2s, [&] { return hook.entered; }), + L"Dispatch never entered the admission gate"); + } + Assert::IsTrue(hook.sawRunning.load(), L"Admission hook ran after the phase left Running"); + std::jthread shutdown{ [&] { pClient->BeginShutdown(); } }; + std::this_thread::sleep_for(50ms); + { + std::lock_guard lock{ hook.mutex }; + hook.release = true; + } + hook.cv.notify_all(); + dispatcher.join(); + shutdown.join(); + pClient->SetAdmissionHookForTest(nullptr); + gAdmissionHook_ = nullptr; + Assert::IsTrue(dispatchDone.load(), L"Accepted dispatch did not finish"); + pClient->WaitForShutdown(); + Assert::IsTrue(pClient->GetLifecyclePhase() == LifecyclePhase::Stopped); + std::atomic calls = 0; + Assert::ExpectException([&] { + pClient->DispatchWithContinuation(EchoWithPush::Params{ .value = 5, .pushCount = 0 }, + [&](EchoWithPush::Response&&, std::exception_ptr) { calls.fetch_add(1); }); + }); + std::this_thread::sleep_for(30ms); + Assert::AreEqual(0, calls.load(), L"Rejected continuation was invoked"); + } + TEST_METHOD(ReaderExitAdmissionIsDecidedWithScheduling) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + TestServer server{ pipeName, disposeCount }; + auto pClient = ConnectClient_(pipeName, eventCount); + AdmissionHookState_ hook; + hook.client = pClient.get(); + gAdmissionHook_ = &hook; + pClient->SetAdmissionHookForTest(&AdmissionHookFn_); + std::atomic dispatchDone{ false }; + std::jthread dispatcher{ [&] { + try { + (void)pClient->DispatchSync(EchoWithPush::Params{ .value = 4, .pushCount = 0 }); + } + catch (...) { + } + dispatchDone.store(true); + } }; + { + std::unique_lock lock{ hook.mutex }; + Assert::IsTrue(hook.cv.wait_for(lock, 2s, [&] { return hook.entered; }), + L"Dispatch never entered the admission gate"); + } + Assert::IsTrue(hook.sawRunning.load(), L"Admission hook ran after the phase left Running"); + server.BeginShutdown(); + for (int i = 0; i < 200 && !pClient->ReaderExitShutdownEntered(); i++) { + std::this_thread::sleep_for(5ms); + } + Assert::IsTrue(pClient->ReaderExitShutdownEntered(), L"Reader exit did not reach shutdown"); + Assert::IsTrue(pClient->GetLifecyclePhase() == LifecyclePhase::Running, + L"Reader exit left Running before the admitted dispatch was queued"); + { + std::lock_guard lock{ hook.mutex }; + hook.release = true; + } + hook.cv.notify_all(); + const auto start = std::chrono::steady_clock::now(); + dispatcher.join(); + Assert::IsTrue(std::chrono::steady_clock::now() - start < 500ms, + L"Dispatch in the reader-exit gap did not finish"); + Assert::IsTrue(dispatchDone.load(), L"Accepted dispatch did not finish"); + pClient->SetAdmissionHookForTest(nullptr); + gAdmissionHook_ = nullptr; + pClient->WaitForShutdown(); + Assert::IsTrue(pClient->GetLifecyclePhase() == LifecyclePhase::Stopped); + std::atomic calls = 0; + Assert::ExpectException([&] { + pClient->DispatchWithContinuation(EchoWithPush::Params{ .value = 5, .pushCount = 0 }, + [&](EchoWithPush::Response&&, std::exception_ptr) { calls.fetch_add(1); }); + }); + std::this_thread::sleep_for(30ms); + Assert::AreEqual(0, calls.load(), L"Rejected continuation was invoked"); + } + TEST_METHOD(RunnerJoinsBeforeBorrowedDependencyDies) + { + auto borrowedAlive = std::make_shared>(true); + std::vector events; + std::atomic disposeCount = 0; + const auto pipeName = MakeUniquePipeName_(); + { + struct BorrowMark + { + std::shared_ptr> alive; + ~BorrowMark() { alive->store(false, std::memory_order_release); } + } borrow{ borrowedAlive }; + { + TestServer server{ pipeName, disposeCount, nullptr, nullptr, ServerTestHooks{ + .events = &events, + .borrowedAlive = borrowedAlive, + } }; + auto pClient = ConnectClient_(pipeName, disposeCount); + (void)pClient; + } + Assert::IsTrue(borrowedAlive->load(), L"Borrowed dependency died before the runner joined"); + bool sawDestroyed = false; + bool sawDeadTouch = false; + for (auto event : events) { + if (std::string_view{ event } == "ExecCtxDestroyed") { + sawDestroyed = true; + } + if (std::string_view{ event } == "ExecCtxTouchedDeadBorrow") { + sawDeadTouch = true; + } + } + Assert::IsTrue(sawDestroyed, L"Execution context outlived the joined runner"); + Assert::IsFalse(sawDeadTouch, L"Runner still touched a borrowed dependency after it died"); + } + } + TEST_METHOD(ContinuationBeginShutdownThenExternalDestroy) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + TestServer server{ pipeName, disposeCount }; + auto pClient = ConnectClient_(pipeName, eventCount); + std::atomic continued{ false }; + pClient->DispatchWithContinuation(EchoWithPush::Params{ .value = 6, .pushCount = 0 }, + [&](EchoWithPush::Response&&, std::exception_ptr) { + pClient->BeginShutdown(); + continued.store(true); + }); + for (int i = 0; i < 200 && !continued.load(); i++) { + std::this_thread::sleep_for(10ms); + } + Assert::IsTrue(continued.load(), L"Continuation did not run"); + const auto start = std::chrono::steady_clock::now(); + pClient.reset(); + Assert::IsTrue(std::chrono::steady_clock::now() - start < 500ms, + L"External destroy after continuation shutdown did not join"); + } + TEST_METHOD(FinalTeardownDisposesBeforeTraceStopAndPreservesReasons) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::mutex disposeMutex; + std::vector reasons; + std::vector modes; + std::atomic updateTracking = 0; + std::atomic startEtw = 0; + std::vector events; + std::atomic eventCount = 0; + auto borrowedAlive = std::make_shared>(true); + bool tracesStoppedWhileBorrowed = false; + { + auto pServer = std::make_unique>(pipeName, disposeCount, &disposeMutex, &reasons, ServerTestHooks{ + .disposeModes = &modes, + .updateTracking = &updateTracking, + .startEtw = &startEtw, + .events = &events, + .borrowedAlive = borrowedAlive, + }); + auto pClient = ConnectClient_(pipeName, eventCount); + JoinActionServerThenStopTraces(*pServer, [&] { + pClient.reset(); + for (int i = 0; i < 200 && reasons.empty(); i++) { + std::this_thread::sleep_for(10ms); + } + }, [&] { + Assert::IsFalse(pServer->IsRunning()); + pServer.reset(); + }, [&] { + tracesStoppedWhileBorrowed = borrowedAlive->load(std::memory_order_acquire); + }, &events); + } + Assert::AreEqual((size_t)1, reasons.size()); + Assert::IsTrue(reasons[0] == SessionEndReason::PeerDisconnected, + L"Final teardown overwrote a peer disconnect"); + Assert::AreEqual((size_t)1, modes.size()); + Assert::IsTrue(modes[0] == SessionCleanupMode::FinalTeardown); + Assert::AreEqual(0u, updateTracking.load(), L"UpdateTracking ran during final teardown"); + Assert::AreEqual(0u, startEtw.load(), L"StartEtwSession ran during final teardown"); + Assert::IsTrue(tracesStoppedWhileBorrowed, L"PresentMon stand-in died before trace stop"); + AssertPipeAbsent_(pipeName); + bool disposed = false; + bool ctxDestroyed = false; + for (auto event : events) { + if (std::string_view{ event } == "Dispose") { + Assert::IsFalse(ctxDestroyed, L"Session disposal ran after the execution context died"); + disposed = true; + } + if (std::string_view{ event } == "ExecCtxDestroyed") { + ctxDestroyed = true; + } + } + Assert::IsTrue(disposed); + Assert::IsTrue(ctxDestroyed); + size_t disposeAt = events.size(); + size_t destroyedAt = events.size(); + size_t stopAt = events.size(); + for (size_t i = 0; i < events.size(); i++) { + const std::string_view name{ events[i] }; + if (name == "Dispose") { + disposeAt = i; + } + if (name == "ExecCtxDestroyed") { + destroyedAt = i; + } + if (name == "StopTraceSessions") { + stopAt = i; + } + } + Assert::IsTrue(stopAt < events.size(), L"Teardown did not record StopTraceSessions"); + Assert::IsTrue(disposeAt < stopAt, L"Disposal did not happen before StopTraceSessions"); + Assert::IsTrue(destroyedAt < stopAt, L"Action server was not joined before PresentMon teardown"); + } + TEST_METHOD(NormalDisconnectStillRecomputes) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::mutex disposeMutex; + std::vector reasons; + std::vector modes; + std::atomic updateTracking = 0; + std::atomic eventCount = 0; + { + TestServer server{ pipeName, disposeCount, &disposeMutex, &reasons, ServerTestHooks{ + .disposeModes = &modes, + .updateTracking = &updateTracking, + } }; + auto pClient = ConnectClient_(pipeName, eventCount); + pClient.reset(); + for (int i = 0; i < 200 && reasons.empty(); i++) { + std::this_thread::sleep_for(10ms); + } + } + Assert::AreEqual((size_t)1, reasons.size()); + Assert::IsTrue(reasons[0] == SessionEndReason::PeerDisconnected); + Assert::IsTrue(modes[0] == SessionCleanupMode::NormalOperation); + Assert::AreEqual(1u, updateTracking.load(), L"A normal disconnect skipped global recomputation"); + } + TEST_METHOD(ProtocolFaultAndBodyTimeoutKeepReasonDuringFinalTeardown) + { + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::mutex disposeMutex; + std::vector reasons; + std::vector modes; + std::atomic updateTracking = 0; + std::atomic eventCount = 0; + ServerExecutionContext::ProtocolGate gate; + TestServer server{ pipeName, disposeCount, &disposeMutex, &reasons, ServerTestHooks{ + .disposeModes = &modes, + .updateTracking = &updateTracking, + .protocolGate = &gate, + } }; + auto pClient = ConnectClient_(pipeName, eventCount); + server.EnterFinalTeardown(); + std::atomic dispatchDone{ false }; + std::jthread dispatcher{ [&] { + try { + pClient->DispatchSync(ProtocolFault::Params{}); + } + catch (...) { + } + dispatchDone.store(true); + } }; + for (int i = 0; i < 200 && !gate.entered.load(); i++) { + std::this_thread::sleep_for(5ms); + } + Assert::IsTrue(gate.entered.load(), L"Protocol fault action did not reach the gate"); + server.BeginShutdown(); + gate.release.store(true, std::memory_order_release); + dispatcher.join(); + server.WaitForShutdown(); + Assert::IsTrue(dispatchDone.load()); + Assert::AreEqual((size_t)1, reasons.size()); + Assert::IsTrue(reasons[0] == SessionEndReason::ProtocolFailure, + L"Final teardown replaced a protocol failure"); + Assert::IsTrue(modes[0] == SessionCleanupMode::FinalTeardown); + Assert::AreEqual(0u, updateTracking.load()); + } + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::mutex disposeMutex; + std::vector reasons; + std::vector modes; + std::atomic updateTracking = 0; + TestServer server{ pipeName, disposeCount, &disposeMutex, &reasons, ServerTestHooks{ + .disposeModes = &modes, + .updateTracking = &updateTracking, + .bodyReadTimeoutMs = 50, + } }; + server.EnterFinalTeardown(); + Assert::IsTrue(util::pipe::DuplexPipe::WaitForAvailability(pipeName, 2000)); + util::win::Handle raw; + for (int i = 0; i < 200 && !raw; i++) { + raw = util::win::Handle{ CreateFileA(pipeName.c_str(), GENERIC_READ | GENERIC_WRITE, + 0, nullptr, OPEN_EXISTING, 0, nullptr) }; + if (!raw) { + std::this_thread::sleep_for(5ms); + } + } + Assert::IsTrue((bool)raw, L"Could not open a raw session for the body-read timeout"); + WaitForSessionCount_(server, 1); + const uint32_t declared = 4096; + DWORD written = 0; + Assert::IsTrue(WriteFile(raw, &declared, sizeof(declared), &written, nullptr) != FALSE); + for (int i = 0; i < 200 && reasons.empty(); i++) { + std::this_thread::sleep_for(10ms); + } + Assert::AreEqual((size_t)1, reasons.size()); + Assert::IsTrue(reasons[0] == SessionEndReason::PeerStalled, + L"Body-read timeout was relabeled"); + Assert::IsTrue(modes[0] == SessionCleanupMode::FinalTeardown); + Assert::AreEqual(0u, updateTracking.load()); + server.BeginShutdown(); + server.WaitForShutdown(); + Assert::AreEqual((size_t)1, reasons.size(), L"Shutdown added a second disposal"); + Assert::IsTrue(reasons[0] == SessionEndReason::PeerStalled); + } + } + TEST_METHOD(NoActionAdmittedAfterFinalTeardownStopping) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + TestServer server{ pipeName, disposeCount }; + auto pClient = ConnectClient_(pipeName, eventCount); + server.EnterFinalTeardown(); + server.BeginShutdown(); + Assert::IsFalse(server.IsRunning()); + const auto start = std::chrono::steady_clock::now(); + server.DispatchDetached(PushNotice::Params{ .value = 1 }); + try { + pClient->DispatchSync(EchoWithPush::Params{ .value = 1, .pushCount = 0 }); + } + catch (...) { + } + Assert::IsTrue(std::chrono::steady_clock::now() - start < 500ms, + L"A dispatch was admitted or blocked after Stopping"); + server.WaitForShutdown(); + Assert::AreEqual(0u, server.GetSessionCount()); + } + }; +} diff --git a/IntelPresentMon/PresentMonAPI2Tests/TransportPairedTests.cpp b/IntelPresentMon/PresentMonAPI2Tests/TransportPairedTests.cpp new file mode 100644 index 000000000..ed232cbb6 --- /dev/null +++ b/IntelPresentMon/PresentMonAPI2Tests/TransportPairedTests.cpp @@ -0,0 +1,179 @@ +// Copyright (C) 2022-2026 Intel Corporation +// SPDX-License-Identifier: MIT +// The paired server: no listeners, each session is a pipe pair the server creates and hands one +// end of to a process it launches. +#include "TransportTestKit.h" + +namespace TransportDemuxTests +{ + TEST_CLASS(PairedTests) + { + public: + TEST_METHOD_INITIALIZE(Setup) + { + RegisterActions_(); + } + // --- paired server: no listeners, each session is a pipe pair it creates --- + TEST_METHOD(PairedSessionServesRequestsAndEvents) + { + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + PairedTestServer server{ disposeCount }; + PairedTestClient client{ server.OpenPairedSession(), eventCount }; + const auto res = client.DispatchSync(EchoWithPush::Params{ .value = 5, .pushCount = 1 }); + Assert::AreEqual(5u, res.value, L"A request over a pipe pair was not answered"); + for (int i = 0; i < 100 && eventCount.load() < 1; i++) { + std::this_thread::sleep_for(10ms); + } + Assert::AreEqual(1u, eventCount.load(), L"An event over a pipe pair was not delivered"); + Assert::AreEqual(1u, server.GetSessionCount()); + } + TEST_METHOD(PairedActionBeforeOpenSessionEndsSession) + { + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + std::mutex disposeMutex; + std::vector reasons; + PairedTestServer server{ disposeCount, &disposeMutex, &reasons }; + SymmetricActionClient bareClient{ server.OpenPairedSession(), "pair-test", + ClientExecutionContext{ .pEventCount = &eventCount } }; + bool refused = false; + try { + bareClient.DispatchSync(EchoWithPush::Params{ .value = 7, .pushCount = 1 }); + } + catch (...) { + refused = true; + } + Assert::IsTrue(refused, L"An action sent before OpenSession on a pair was served"); + WaitForSessionCount_(server, 0); + Assert::AreEqual(0u, eventCount.load(), L"The refused action executed"); + std::lock_guard lock{ disposeMutex }; + Assert::AreEqual((size_t)1, reasons.size()); + Assert::IsTrue(reasons[0] == SessionEndReason::ProtocolFailure); + } + // This is how the kernel sees its UI exit. + TEST_METHOD(PairedClientDisconnectEndsSession) + { + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + std::mutex disposeMutex; + std::vector reasons; + PairedTestServer server{ disposeCount, &disposeMutex, &reasons }; + auto pClient = std::make_unique(server.OpenPairedSession(), eventCount); + Assert::AreEqual(1u, server.GetSessionCount()); + pClient.reset(); + WaitForSessionCount_(server, 0); + Assert::AreEqual(0u, server.GetSessionCount(), L"Closing the client did not end the session"); + std::lock_guard lock{ disposeMutex }; + Assert::AreEqual((size_t)1, reasons.size()); + Assert::IsTrue(reasons[0] == SessionEndReason::PeerDisconnected); + } + // Two opens at once: neither may wait for the session the other one creates to close. + // Whether the calls actually overlap depends on scheduling, so this guards the fix + // without catching a regression on every run; it never fails spuriously. + TEST_METHOD(ConcurrentPairOpensBothReturn) + { + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + PairedTestServer server{ disposeCount }; + // a connected session makes both opens wait for it to drain + PairedTestClient first{ server.OpenPairedSession(), eventCount }; + util::win::Handle clientA; + util::win::Handle clientB; + std::atomic returned = 0; + // a call that throws still counts as returned; the handle check below catches it + auto open = [&](util::win::Handle& out) { + try { + out = server.OpenPairedSession(); + } + catch (...) { + } + returned.fetch_add(1); + }; + { + std::jthread openA{ [&] { open(clientA); } }; + std::jthread openB{ [&] { open(clientB); } }; + for (int i = 0; i < 500 && returned.load() < 2; i++) { + std::this_thread::sleep_for(10ms); + } + if (returned.load() < 2) { + // unblock the waiting call so the threads can be joined + server.BeginShutdown(); + } + } + Assert::AreEqual(2, returned.load(), L"A concurrent OpenPairedSession waited for the other's new session to close"); + Assert::IsTrue((bool)clientA && (bool)clientB, L"A concurrent OpenPairedSession failed"); + WaitForSessionCount_(server, 1); + Assert::AreEqual(1u, server.GetSessionCount(), L"Concurrent opens left other than one session"); + } + TEST_METHOD(NewPairEndsPreviousSession) + { + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + PairedTestServer server{ disposeCount }; + PairedTestClient first{ server.OpenPairedSession(), eventCount }; + PairedTestClient second{ server.OpenPairedSession(), eventCount }; + Assert::AreEqual(1u, disposeCount.load(), L"A new pair did not end the previous session"); + Assert::AreEqual(1u, server.GetSessionCount()); + const auto res = second.DispatchSync(EchoWithPush::Params{ .value = 9, .pushCount = 0 }); + Assert::AreEqual(9u, res.value, L"The new pair was not served"); + bool firstRefused = false; + try { + first.DispatchSync(EchoWithPush::Params{ .value = 1, .pushCount = 0 }); + } + catch (...) { + firstRefused = true; + } + Assert::IsTrue(firstRefused, L"The replaced session was still served"); + } + TEST_METHOD(PairedServerShutsDownIdleAndWithSession) + { + { + std::atomic disposeCount = 0; + PairedTestServer server{ disposeCount }; + // with no listener and no session, only the idle guard keeps the server running + std::this_thread::sleep_for(100ms); + Assert::IsTrue(server.IsRunning(), L"An idle paired server stopped by itself"); + server.BeginShutdown(); + Assert::IsTrue(server.WaitForShutdown(), L"An idle paired server did not shut down"); + } + { + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + PairedTestServer server{ disposeCount }; + PairedTestClient client{ server.OpenPairedSession(), eventCount }; + server.BeginShutdown(); + Assert::IsTrue(server.WaitForShutdown(), L"A paired server with a session did not shut down"); + Assert::AreEqual(1u, disposeCount.load()); + } + } + // The kernel hands the client end to the UI it launches: only the listed handle is + // inherited, and the session lives exactly as long as the child holding it. + TEST_METHOD(PairedHandleInheritedByChildKeepsSession) + { + std::atomic disposeCount = 0; + std::mutex disposeMutex; + std::vector reasons; + PairedTestServer server{ disposeCount, &disposeMutex, &reasons }; + auto clientEnd = server.OpenPairedSession(); + Assert::IsTrue((bool)SetHandleInformation(clientEnd, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT)); + wchar_t systemDir[MAX_PATH]{}; + Assert::IsTrue(GetSystemDirectoryW(systemDir, MAX_PATH) > 0); + boost::asio::io_context procCtx; + boost::process::v2::windows::default_launcher launcher; + launcher.inherited_handles.push_back(clientEnd.Get()); + // the child does nothing with the pipe; it only holds the handle for about two seconds + auto child = launcher(procCtx, std::filesystem::path{ systemDir } / L"cmd.exe", + std::vector{ "/c", "ping", "-n", "3", "127.0.0.1" }); + clientEnd = util::win::Handle{}; + std::this_thread::sleep_for(500ms); + Assert::AreEqual(1u, server.GetSessionCount(), L"The session ended while the child still held the handle"); + child.wait(); + WaitForSessionCount_(server, 0); + Assert::AreEqual(0u, server.GetSessionCount(), L"The session outlived the child holding the handle"); + std::lock_guard lock{ disposeMutex }; + Assert::AreEqual((size_t)1, reasons.size()); + Assert::IsTrue(reasons[0] == SessionEndReason::PeerDisconnected); + } + }; +} diff --git a/IntelPresentMon/PresentMonAPI2Tests/TransportSessionTests.cpp b/IntelPresentMon/PresentMonAPI2Tests/TransportSessionTests.cpp new file mode 100644 index 000000000..13018fc6c --- /dev/null +++ b/IntelPresentMon/PresentMonAPI2Tests/TransportSessionTests.cpp @@ -0,0 +1,298 @@ +// Copyright (C) 2022-2026 Intel Corporation +// SPDX-License-Identifier: MIT +// Listeners and session admission: acceptor churn, single-peer and addressed sends, the handshake +// deadline, the session cap, and refusing actions before OpenSession. +#include "TransportTestKit.h" + +namespace TransportDemuxTests +{ + TEST_CLASS(SessionTests) + { + public: + TEST_METHOD_INITIALIZE(Setup) + { + RegisterActions_(); + } + // an accept immediately yields a session and a replacement acceptor, so churn + // cannot erode the pool the way unpaired half-connections used to + TEST_METHOD(AcceptorPoolSurvivesConnectChurn) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + // Multi-peer: a closed client's session is removed on the server's thread, so it can + // still be counted when the next client connects, and a single-peer server would + // rightly refuse that client. + TestServer server{ pipeName, disposeCount }; + + for (int i = 0; i < 25; i++) { + auto pClient = ConnectClient_(pipeName, eventCount); + const auto res = pClient->DispatchSync(EchoWithPush::Params{ .value = 7, .pushCount = 0 }); + Assert::AreEqual(7u, res.value); + } + WaitForSessionCount_(server, 0); + Assert::AreEqual(0u, server.GetSessionCount()); + Assert::AreEqual(25u, disposeCount.load(), L"A disposed session was not cleaned up"); + } + // The listener count can be observed directly: every client that connects and + // vanishes costs one pipe instance, and every one of them must be replaced or + // the server eventually has nothing left to accept on + TEST_METHOD(ConnectAndCloseChurnKeepsListenerReserve) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + // Multi-peer: a vanished client can still be in the map for a moment, and + // that must not keep the next real client from being admitted. + TestServer server{ pipeName, disposeCount }; + WaitForAcceptorCount_(server, TestServer<>::reservedAcceptors); + + for (int i = 0; i < 50; i++) { + RawConnectAndClose_(pipeName); + // a replacement is counted before the listener it replaces is released, so + // the pool can read one high for an instant but must never read low + Assert::IsTrue(server.GetAcceptorCount() >= TestServer<>::reservedAcceptors, + L"A vanishing client consumed a listener from the pool"); + } + WaitForAcceptorCount_(server, TestServer<>::reservedAcceptors); + Assert::AreEqual(TestServer<>::reservedAcceptors, server.GetAcceptorCount(), + L"Listener pool did not settle back to its reserve"); + // the pool is not just counted correctly, it still admits a real client + auto pClient = ConnectClient_(pipeName, eventCount); + const auto res = pClient->DispatchSync(EchoWithPush::Params{ .value = 7, .pushCount = 0 }); + Assert::AreEqual(7u, res.value); + } + // the suffixed names are the shape of the reported defect; they must not come back + TEST_METHOD(HalfPipeNamesAreNotExpressible) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + TestServer server{ pipeName, disposeCount }; + Assert::IsTrue(util::pipe::DuplexPipe::WaitForAvailability(pipeName, 2000)); + AssertPipeAbsent_(pipeName + "-in"); + AssertPipeAbsent_(pipeName + "-out"); + } + // regression for the dispose guard: cleanup used to be skipped entirely when a + // session never reported a remote pid + TEST_METHOD(DisposeRunsForSessionThatNeverOpened) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + TestServer server{ pipeName, disposeCount }; + { + Assert::IsTrue(util::pipe::DuplexPipe::WaitForAvailability(pipeName, 2000)); + SymmetricActionClient bareClient{ + pipeName, ClientExecutionContext{ .pEventCount = &eventCount } }; + WaitForSessionCount_(server, 1); + } + WaitForSessionCount_(server, 0); + Assert::AreEqual(1u, disposeCount.load(), + L"Dispose did not run for a session that never sent OpenSession"); + } + // The server runs nothing for a peer that has not opened its session: the action is + // refused before it executes and the session ends as a protocol failure. + TEST_METHOD(ActionBeforeOpenSessionEndsSession) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + std::mutex disposeMutex; + std::vector reasons; + TestServer server{ pipeName, disposeCount, &disposeMutex, &reasons }; + Assert::IsTrue(util::pipe::DuplexPipe::WaitForAvailability(pipeName, 2000)); + SymmetricActionClient bareClient{ + pipeName, ClientExecutionContext{ .pEventCount = &eventCount } }; + bool refused = false; + try { + // if this ran, its push would reach the client before the response + bareClient.DispatchSync(EchoWithPush::Params{ .value = 7, .pushCount = 1 }); + } + catch (...) { + refused = true; + } + Assert::IsTrue(refused, L"An action sent before OpenSession was served"); + WaitForSessionCount_(server, 0); + Assert::AreEqual(1u, disposeCount.load(), L"The refused session was not disposed"); + Assert::AreEqual(0u, eventCount.load(), L"The refused action executed"); + std::lock_guard lock{ disposeMutex }; + Assert::AreEqual((size_t)1, reasons.size()); + Assert::IsTrue(reasons[0] == SessionEndReason::ProtocolFailure, + L"An action before OpenSession did not end the session as a protocol failure"); + } + // The client does not require an opened session: a server may push an event before + // the client has recorded the OpenSession response, and that is legitimate. + TEST_METHOD(EventBeforeEstablishSessionIsAccepted) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + TestServer server{ pipeName, disposeCount }; + Assert::IsTrue(util::pipe::DuplexPipe::WaitForAvailability(pipeName, 2000)); + SymmetricActionClient bareClient{ + pipeName, ClientExecutionContext{ .pEventCount = &eventCount } }; + WaitForSessionCount_(server, 1); + server.DispatchDetached(PushNotice::Params{ .value = 3 }); + for (int i = 0; i < 100 && eventCount.load() < 1; i++) { + std::this_thread::sleep_for(10ms); + } + Assert::AreEqual(1u, eventCount.load(), L"An event before EstablishSession_ was not delivered"); + Assert::IsTrue(bareClient.IsRunning(), L"An event before EstablishSession_ ended the client"); + Assert::AreEqual(1u, server.GetSessionCount(), L"An event before EstablishSession_ ended the session"); + } + TEST_METHOD(SinglePeerRejectsSecondSession) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + TestServer server{ pipeName, disposeCount }; + auto pClient = ConnectClient_(pipeName, eventCount); + bool refused = false; + try { + std::atomic extraEvents = 0; + auto pExtra = ConnectClient_(pipeName, extraEvents); + for (int i = 0; i < 200 && pExtra->IsRunning(); i++) { + std::this_thread::sleep_for(10ms); + } + refused = !pExtra->IsRunning(); + } + catch (...) { + refused = true; + } + Assert::IsTrue(refused, L"Single-peer server accepted a second session"); + Assert::AreEqual(1u, server.GetSessionCount()); + const auto res = pClient->DispatchSync(EchoWithPush::Params{ .value = 6, .pushCount = 0 }); + Assert::AreEqual(6u, res.value); + } + TEST_METHOD(AddressedSendRequiresExplicitSession) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCountA = 0; + std::atomic eventCountB = 0; + TestServer server{ pipeName, disposeCount }; + Assert::IsTrue(util::pipe::DuplexPipe::WaitForAvailability(pipeName, 2000)); + TestClient clientA{ pipeName, eventCountA }; + TestClient clientB{ pipeName, eventCountB }; + const auto ids = server.CopySessionIds(); + Assert::AreEqual((size_t)2, ids.size()); + Assert::ExpectException([&] { + server.DispatchToSession(0xFFFFFFFFu, PushNotice::Params{ .value = 1 }); + }); + server.DispatchToSession(ids[0], PushNotice::Params{ .value = 2 }); + for (int i = 0; i < 200 && eventCountA.load() + eventCountB.load() == 0; i++) { + std::this_thread::sleep_for(10ms); + } + std::this_thread::sleep_for(50ms); + Assert::AreEqual(1u, eventCountA.load() + eventCountB.load()); + Assert::IsTrue(eventCountA.load() != eventCountB.load()); + } + // A disposed session leaves both the session map and the connector map, so a send + // addressed to it is refused while the surviving session is still reachable. + TEST_METHOD(AddressedSendToDisposedSessionIsRefused) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCountA = 0; + std::atomic eventCountB = 0; + TestServer server{ pipeName, disposeCount }; + auto pClientA = ConnectClient_(pipeName, eventCountA); + const auto idsA = server.CopySessionIds(); + Assert::AreEqual((size_t)1, idsA.size()); + const auto idA = idsA.front(); + auto pClientB = ConnectClient_(pipeName, eventCountB); + const auto idsAB = server.CopySessionIds(); + Assert::AreEqual((size_t)2, idsAB.size()); + const auto idB = idsAB[0] == idA ? idsAB[1] : idsAB[0]; + + pClientA.reset(); + WaitForSessionCount_(server, 1); + Assert::AreEqual(1u, server.GetSessionCount()); + Assert::AreEqual(1u, disposeCount.load()); + Assert::ExpectException([&] { + server.DispatchToSession(idA, PushNotice::Params{ .value = 1 }); + }); + + server.DispatchToSession(idB, PushNotice::Params{ .value = 2 }); + for (int i = 0; i < 200 && eventCountB.load() == 0; i++) { + std::this_thread::sleep_for(10ms); + } + Assert::AreEqual(1u, eventCountB.load(), L"Surviving session did not receive its send"); + Assert::AreEqual(0u, eventCountA.load()); + } + // After the only peer leaves, an unaddressed send must reach the next peer. + TEST_METHOD(SinglePeerSendReachesReplacementPeer) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic firstEvents = 0; + std::atomic secondEvents = 0; + TestServer server{ pipeName, disposeCount }; + ConnectClient_(pipeName, firstEvents).reset(); + WaitForSessionCount_(server, 0); + Assert::AreEqual(0u, server.GetSessionCount()); + + auto pSecond = ConnectClient_(pipeName, secondEvents); + Assert::AreEqual(1u, server.GetSessionCount()); + server.DispatchDetached(PushNotice::Params{ .value = 3 }); + for (int i = 0; i < 200 && secondEvents.load() == 0; i++) { + std::this_thread::sleep_for(10ms); + } + Assert::AreEqual(1u, secondEvents.load(), L"Send did not reach the replacement peer"); + Assert::AreEqual(0u, firstEvents.load()); + } + // a connection that never opens its session is closed at the deadline, while an + // opened session may stay idle past it + TEST_METHOD(UnopenedSessionClosedAtHandshakeDeadline) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::mutex disposeMutex; + std::vector reasons; + std::atomic eventCount = 0; + constexpr auto deadline = 200ms; + TestServer server{ pipeName, disposeCount, &disposeMutex, &reasons, ServerTestHooks{ + .limits = { .handshakeTimeoutMs = (uint32_t)deadline.count() }, + } }; + auto pClient = ConnectClient_(pipeName, eventCount); + RawPipeClient raw{ pipeName }; + Assert::IsTrue(raw.IsConnected()); + const auto start = std::chrono::steady_clock::now(); + Assert::IsTrue(raw.WaitForServerClose(2s), L"An unopened session outlived the handshake deadline"); + Assert::IsTrue(std::chrono::steady_clock::now() - start >= deadline / 2, + L"An unopened session was closed long before the handshake deadline"); + for (int i = 0; i < 200 && disposeCount.load() == 0; i++) { + std::this_thread::sleep_for(10ms); + } + { + std::lock_guard lock{ disposeMutex }; + Assert::AreEqual((size_t)1, reasons.size()); + Assert::IsTrue(reasons[0] == SessionEndReason::PeerStalled); + } + const auto res = pClient->DispatchSync(EchoWithPush::Params{ .value = 10, .pushCount = 0 }); + Assert::AreEqual(10u, res.value, L"An opened session was closed by the handshake deadline"); + Assert::AreEqual(1u, server.GetSessionCount()); + } + // the cap refuses sessions beyond it, and admits again once one ends + TEST_METHOD(SessionCapRefusesUntilASessionEnds) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + constexpr uint32_t cap = 3; + TestServer server{ pipeName, disposeCount, nullptr, nullptr, ServerTestHooks{ + .limits = { .maxSessions = cap }, + } }; + std::vector> clients; + for (uint32_t i = 0; i < cap; i++) { + clients.push_back(ConnectClient_(pipeName, eventCount)); + } + Assert::IsTrue(ConnectionIsRefused_(server, pipeName), L"A session beyond the cap was admitted"); + clients.front().reset(); + WaitForSessionCount_(server, cap - 1); + auto pLate = ConnectClient_(pipeName, eventCount); + const auto res = pLate->DispatchSync(EchoWithPush::Params{ .value = 12, .pushCount = 0 }); + Assert::AreEqual(12u, res.value); + } + }; +} diff --git a/IntelPresentMon/PresentMonAPI2Tests/TransportTestKit.cpp b/IntelPresentMon/PresentMonAPI2Tests/TransportTestKit.cpp new file mode 100644 index 000000000..3e600371a --- /dev/null +++ b/IntelPresentMon/PresentMonAPI2Tests/TransportTestKit.cpp @@ -0,0 +1,25 @@ +// Copyright (C) 2022-2026 Intel Corporation +// SPDX-License-Identifier: MIT +#include "TransportTestKit.h" + +namespace TransportDemuxTests +{ + void RegisterActions_() + { + static std::once_flag once; + std::call_once(once, [] { + AsyncActionCollection::Get().AddAction(); + AsyncActionCollection::Get().AddAction(); + AsyncActionCollection::Get().AddAction(); + AsyncActionCollection::Get().AddAction(std::make_unique()); + AsyncActionCollection::Get().AddAction(); + AsyncActionCollection::Get().AddAction(std::make_unique()); + AsyncActionCollection::Get().AddAction(std::make_unique()); + AsyncActionCollection::Get().AddAction(std::make_unique()); + AsyncActionCollection::Get().AddAction(std::make_unique()); + AsyncActionCollection::Get().AddAction(); + AsyncActionCollection::Get().AddAction(); + AsyncActionCollection::Get().AddAction(std::make_unique()); + }); + } +} diff --git a/IntelPresentMon/PresentMonAPI2Tests/TransportTestKit.h b/IntelPresentMon/PresentMonAPI2Tests/TransportTestKit.h new file mode 100644 index 000000000..8f9adfee6 --- /dev/null +++ b/IntelPresentMon/PresentMonAPI2Tests/TransportTestKit.h @@ -0,0 +1,893 @@ +// Copyright (C) 2022-2026 Intel Corporation +// SPDX-License-Identifier: MIT +// Shared scaffolding for the action transport tests (TransportDemuxTests and its sibling +// Transport*Tests files): toy execution contexts and actions, test servers and clients, and +// helpers. The transport is exercised over private pipes, so the session, demultiplexer and +// disposal contract can be tested without the service or a CEF process. +#pragma once +#include "../CommonUtilities/win/WinAPI.h" +#include "CppUnitTest.h" +#include "../CommonUtilities/win/Handle.h" +#include "../CommonUtilities/str/String.h" +#include "../Interprocess/source/act/AsyncActionCollection.h" +#include "../Interprocess/source/act/SymmetricActionClient.h" +#include "../Interprocess/source/act/SymmetricActionServer.h" +#include "../Interprocess/source/act/ActionServerShutdown.h" +#include "RawPipeClient.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; +using namespace std::literals; + +namespace TransportDemuxTests +{ + using namespace pmon; + using namespace pmon::ipc::act; + + // --- server side execution context ------------------------------------------------- + + struct ServerExecutionContext; + + struct ServerSessionContext + { + uint32_t remotePid = 0; + }; + + struct ServerExecutionContext + { + using SessionContextType = ServerSessionContext; + + std::optional responseWriteTimeoutMs; + // set after the server is constructed, since an action needs the server to push + std::function* pPush = nullptr; + std::atomic* pDisposeCount = nullptr; + std::mutex* pDisposeMutex = nullptr; + std::vector* pDisposeReasons = nullptr; + std::vector* pDisposeModes = nullptr; + std::atomic* pUpdateTracking = nullptr; + std::atomic* pStartEtw = nullptr; + std::vector* pLifetimeEvents = nullptr; + std::optional bodyReadTimeoutMs; + struct ProtocolGate + { + std::atomic entered{ false }; + std::atomic release{ false }; + }; + ProtocolGate* pProtocolGate = nullptr; + struct LifetimeNote + { + std::vector* events = nullptr; + std::shared_ptr> borrowedAlive; + // Assign fields after make_shared. A temporary LifetimeNote would + // record destruction as soon as that temporary died. + static std::shared_ptr Make( + std::vector* events, + std::shared_ptr> borrowedAlive) + { + auto note = std::make_shared(); + note->events = events; + note->borrowedAlive = std::move(borrowedAlive); + return note; + } + ~LifetimeNote() + { + if (borrowedAlive && !borrowedAlive->load(std::memory_order_acquire)) { + if (events) { + events->push_back("ExecCtxTouchedDeadBorrow"); + } + } + if (events) { + events->push_back("ExecCtxDestroyed"); + } + } + }; + std::shared_ptr lifetime = std::make_shared(); + std::shared_ptr> cleanupMode = + std::make_shared>(SessionCleanupMode::NormalOperation); + + void EnterFinalTeardown() + { + cleanupMode->store(SessionCleanupMode::FinalTeardown, std::memory_order_release); + } + SessionCleanupMode GetSessionCleanupMode() const + { + return cleanupMode->load(std::memory_order_acquire); + } + void Dispose(SessionContextType&, SessionDisposition disposition) const + { + if (pDisposeCount) { + pDisposeCount->fetch_add(1); + } + if (pDisposeMutex) { + std::lock_guard lock{ *pDisposeMutex }; + RecordDispose_(disposition); + } + else { + RecordDispose_(disposition); + } + if (ShouldRecomputeGlobalState(disposition)) { + if (pUpdateTracking) { + pUpdateTracking->fetch_add(1, std::memory_order_relaxed); + } + if (pStartEtw) { + pStartEtw->fetch_add(1, std::memory_order_relaxed); + } + } + } + private: + void RecordDispose_(SessionDisposition disposition) const + { + if (pDisposeReasons) { + pDisposeReasons->push_back(disposition.reason); + } + if (pDisposeModes) { + pDisposeModes->push_back(disposition.cleanupMode); + } + if (pLifetimeEvents) { + pLifetimeEvents->push_back("Dispose"); + } + } + }; + + // --- client side execution context ------------------------------------------------- + + struct ClientExecutionContext; + + struct ClientSessionContext + { + uint32_t remotePid = 0; + }; + + struct ClientExecutionContext + { + using SessionContextType = ClientSessionContext; + + std::optional responseWriteTimeoutMs; + std::atomic* pEventCount = nullptr; + }; + + // The transport owns its connectors; a session context only has to carry remotePid. + static_assert(TransportSessionContext); + static_assert(TransportSessionContext); + struct SessionContextWithoutRemotePid_ {}; + static_assert(!TransportSessionContext); + struct SessionContextWithWideRemotePid_ { uint64_t remotePid = 0; }; + static_assert(!TransportSessionContext); + + // --- actions ----------------------------------------------------------------------- + + // named to match the transport's own "first action must be OpenSession" policy + class OpenSession : public AsyncActionBase_ + { + public: + static constexpr const char* Identifier = "OpenSession"; + struct Params + { + uint32_t clientPid; + template void serialize(A& ar) { ar(clientPid); } + }; + struct Response + { + uint32_t serverPid; + template void serialize(A& ar) { ar(serverPid); } + }; + private: + friend class AsyncActionBase_; + static Response Execute_(const ServerExecutionContext&, SessionContext& stx, Params&& in) + { + stx.remotePid = in.clientPid; + return Response{ .serverPid = GetCurrentProcessId() }; + } + }; + + // echoes its input and, on the way, pushes pushCount unsolicited events back at the + // caller; those events land on the same handle while this request is still outstanding + class EchoWithPush : public AsyncActionBase_ + { + public: + static constexpr const char* Identifier = "EchoWithPush"; + struct Params + { + uint32_t value; + uint32_t pushCount; + template void serialize(A& ar) { ar(value, pushCount); } + }; + struct Response + { + uint32_t value; + uint32_t remotePid; + template void serialize(A& ar) { ar(value, remotePid); } + }; + private: + friend class AsyncActionBase_; + static Response Execute_(const ServerExecutionContext& ctx, SessionContext& stx, Params&& in) + { + for (uint32_t i = 0; i < in.pushCount; i++) { + (*ctx.pPush)(in.value); + } + return Response{ .value = in.value, .remotePid = stx.remotePid }; + } + }; + + // carries a request larger than the pipe's buffer, so sending it needs the peer to read + class BulkRequest : public AsyncActionBase_ + { + public: + static constexpr const char* Identifier = "Bulk"; + struct Params + { + std::string bytes; + template void serialize(A& ar) { ar(bytes); } + }; + struct Response + { + uint32_t size; + template void serialize(A& ar) { ar(size); } + }; + private: + friend class AsyncActionBase_; + static Response Execute_(const ServerExecutionContext&, SessionContext&, Params&& in) + { + return Response{ .size = (uint32_t)in.bytes.size() }; + } + }; + + // Client-side traits holder; the server runs StallServer instead so a long stall + // does not block the session reader while other requests are in flight. + class StallForClient : public AsyncActionBase_ + { + public: + static constexpr const char* Identifier = "Stall"; + struct Params + { + uint32_t durationMs; + template void serialize(A& ar) { ar(durationMs); } + }; + struct Response + { + uint32_t durationMs; + template void serialize(A& ar) { ar(durationMs); } + }; + private: + friend class AsyncActionBase_; + static Response Execute_(const ServerExecutionContext&, SessionContext&, Params&&) + { + assert(false && "StallForClient is client-side only"); + return Response{}; + } + }; + + // Throws from the reader coroutine. AsyncActionBase_ would swallow this as a + // transport-failure response and the session would later end as LocalShutdown. + class ProtocolFault : public AsyncAction + { + public: + static constexpr const char* Identifier = "ProtocolFault"; + static constexpr uint16_t Version = 1; + struct Params + { + template void serialize(A& ar) {} + }; + struct Response + { + template void serialize(A& ar) {} + }; + pipe::as::awaitable Execute(ServerExecutionContext& ctx, SessionContext&, + const PacketHeader&, pipe::DuplexPipe& pipe) const override + { + (void)pipe.ConsumePacketPayload(); + if (ctx.pProtocolGate) { + ctx.pProtocolGate->entered.store(true, std::memory_order_release); + const auto deadline = std::chrono::steady_clock::now() + 2s; + while (!ctx.pProtocolGate->release.load(std::memory_order_acquire) + && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(1ms); + } + } + throw util::Except("test protocol fault"); + co_return; + } + const char* GetIdentifier() const override + { + return Identifier; + } + }; + + class StallServer : public AsyncAction + { + public: + static constexpr const char* Identifier = StallForClient::Identifier; + using Params = StallForClient::Params; + using Response = StallForClient::Response; + pipe::as::awaitable Execute(ServerExecutionContext& ctx, SessionContext&, + const PacketHeader& header, pipe::DuplexPipe& pipe) const override + { + auto params = pipe.ConsumePacketPayload(); + pipe::as::steady_timer timer{ co_await pipe::as::this_coro::executor }; + timer.expires_after(std::chrono::milliseconds(params.durationMs)); + co_await timer.async_wait(pipe::as::use_awaitable); + const auto resHeader = MakeResponseHeader(header, TransportStatus::Success, PM_STATUS_SUCCESS); + co_await pipe.WritePacket(resHeader, Response{ .durationMs = params.durationMs }, + ctx.responseWriteTimeoutMs); + } + const char* GetIdentifier() const override + { + return Identifier; + } + }; + + class PushNotice : public AsyncEventActionBase_ + { + public: + static constexpr const char* Identifier = "PushNotice"; + struct Params + { + uint32_t value; + template void serialize(A& ar) { ar(value); } + }; + private: + friend class AsyncEventActionBase_; + static void Execute_(const ClientExecutionContext& ctx, SessionContext&, Params&&) + { + ctx.pEventCount->fetch_add(1); + } + }; + + // a push larger than the pipe's buffer, so sending it holds the server's write lock until + // the client reads it + class BulkNotice : public AsyncEventActionBase_ + { + public: + static constexpr const char* Identifier = "BulkNotice"; + struct Params + { + std::string bytes; + template void serialize(A& ar) { ar(bytes); } + }; + private: + friend class AsyncEventActionBase_; + static void Execute_(const ClientExecutionContext& ctx, SessionContext&, Params&&) + { + ctx.pEventCount->fetch_add(1); + } + }; + + // Server-side traits holder; the client runs StallReaderClient, which waits on a timer, so + // its reader reads nothing for the duration while its io thread stays free to send. + class StallReaderNotice : public AsyncEventActionBase_ + { + public: + static constexpr const char* Identifier = "StallReader"; + struct Params + { + uint32_t durationMs; + template void serialize(A& ar) { ar(durationMs); } + }; + private: + friend class AsyncEventActionBase_; + static void Execute_(const ClientExecutionContext&, SessionContext&, Params&&) + { + assert(false && "StallReaderNotice is server-side only"); + } + }; + + class StallReaderClient : public AsyncAction + { + public: + pipe::as::awaitable Execute(ClientExecutionContext&, SessionContext&, + const PacketHeader&, pipe::DuplexPipe& pipe) const override + { + auto params = pipe.ConsumePacketPayload(); + pipe::as::steady_timer timer{ co_await pipe::as::this_coro::executor }; + timer.expires_after(std::chrono::milliseconds(params.durationMs)); + co_await timer.async_wait(pipe::as::use_awaitable); + } + const char* GetIdentifier() const override + { + return StallReaderNotice::Identifier; + } + }; + + class DropRequest : public AsyncActionBase_ + { + public: + static constexpr const char* Identifier = "Drop"; + struct Params + { + template void serialize(A&) {} + }; + struct Response + { + template void serialize(A&) {} + }; + private: + friend class AsyncActionBase_; + static Response Execute_(const ServerExecutionContext&, SessionContext&, Params&&) + { + return {}; + } + }; + + class DropServer : public AsyncAction + { + public: + pipe::as::awaitable Execute(ServerExecutionContext&, SessionContext&, + const PacketHeader&, pipe::DuplexPipe& pipe) const override + { + pipe.ConsumePacketPayload(); + co_return; + } + const char* GetIdentifier() const override + { + return DropRequest::Identifier; + } + }; + + class SpoofRequest : public AsyncActionBase_ + { + public: + static constexpr const char* Identifier = "Spoof"; + struct Params + { + uint32_t token; + template void serialize(A& ar) { ar(token); } + }; + struct Response + { + uint32_t token; + template void serialize(A& ar) { ar(token); } + }; + private: + friend class AsyncActionBase_; + static Response Execute_(const ServerExecutionContext&, SessionContext&, Params&& in) + { + return Response{ .token = in.token }; + } + }; + + class SpoofServer : public AsyncAction + { + public: + pipe::as::awaitable Execute(ServerExecutionContext& ctx, SessionContext&, + const PacketHeader& header, pipe::DuplexPipe& pipe) const override + { + auto params = pipe.ConsumePacketPayload(); + const PacketHeader fake{ + .identifier = SpoofRequest::Identifier, + .commandToken = params.token, + .packetType = PacketType::ActionResponse, + .headerVersion = kHeaderVersion, + }; + co_await pipe.WritePacket(fake, EmptyPayload{}, ctx.responseWriteTimeoutMs); + const auto resHeader = MakeResponseHeader(header, TransportStatus::Success, PM_STATUS_SUCCESS); + co_await pipe.WritePacket(resHeader, SpoofRequest::Response{ .token = params.token }, ctx.responseWriteTimeoutMs); + } + const char* GetIdentifier() const override + { + return SpoofRequest::Identifier; + } + }; + + class LateDouble : public AsyncActionBase_ + { + public: + static constexpr const char* Identifier = "LateDouble"; + struct Params + { + uint32_t value; + template void serialize(A& ar) { ar(value); } + }; + struct Response + { + uint32_t value; + template void serialize(A& ar) { ar(value); } + }; + private: + friend class AsyncActionBase_; + static Response Execute_(const ServerExecutionContext&, SessionContext&, Params&& in) + { + return Response{ .value = in.value }; + } + }; + + class LateDoubleServer : public AsyncAction + { + public: + pipe::as::awaitable Execute(ServerExecutionContext& ctx, SessionContext&, + const PacketHeader& header, pipe::DuplexPipe& pipe) const override + { + auto params = pipe.ConsumePacketPayload(); + pipe::as::steady_timer timer{ co_await pipe::as::this_coro::executor }; + timer.expires_after(250ms); + co_await timer.async_wait(pipe::as::use_awaitable); + const auto resHeader = MakeResponseHeader(header, TransportStatus::Success, PM_STATUS_SUCCESS); + const LateDouble::Response body{ .value = params.value }; + co_await pipe.WritePacket(resHeader, body, ctx.responseWriteTimeoutMs); + co_await pipe.WritePacket(resHeader, body, ctx.responseWriteTimeoutMs); + } + const char* GetIdentifier() const override + { + return LateDouble::Identifier; + } + }; + + class EchoToken : public AsyncActionBase_ + { + public: + static constexpr const char* Identifier = "EchoToken"; + struct Params + { + uint32_t value; + template void serialize(A& ar) { ar(value); } + }; + struct Response + { + uint32_t value; + uint32_t token; + template void serialize(A& ar) { ar(value, token); } + }; + private: + friend class AsyncActionBase_; + static Response Execute_(const ServerExecutionContext&, SessionContext&, Params&& in) + { + return Response{ .value = in.value, .token = 0 }; + } + }; + + class EchoTokenServer : public AsyncAction + { + public: + pipe::as::awaitable Execute(ServerExecutionContext& ctx, SessionContext&, + const PacketHeader& header, pipe::DuplexPipe& pipe) const override + { + auto params = pipe.ConsumePacketPayload(); + const auto resHeader = MakeResponseHeader(header, TransportStatus::Success, PM_STATUS_SUCCESS); + co_await pipe.WritePacket(resHeader, EchoToken::Response{ + .value = params.value, .token = header.commandToken }, ctx.responseWriteTimeoutMs); + } + const char* GetIdentifier() const override + { + return EchoToken::Identifier; + } + }; +} + +namespace pmon::ipc::act +{ + template<> struct ActionParamsTraits + { + using Action = TransportDemuxTests::OpenSession; + }; + template<> struct ActionParamsTraits + { + using Action = TransportDemuxTests::EchoWithPush; + }; + template<> struct ActionParamsTraits + { + using Action = TransportDemuxTests::BulkRequest; + }; + template<> struct ActionParamsTraits + { + using Action = TransportDemuxTests::StallForClient; + }; + template<> struct ActionParamsTraits + { + using Action = TransportDemuxTests::ProtocolFault; + }; + template<> struct ActionParamsTraits + { + using Action = TransportDemuxTests::PushNotice; + }; + template<> struct ActionParamsTraits + { + using Action = TransportDemuxTests::BulkNotice; + }; + template<> struct ActionParamsTraits + { + using Action = TransportDemuxTests::StallReaderNotice; + }; + template<> struct ActionParamsTraits + { + using Action = TransportDemuxTests::DropRequest; + }; + template<> struct ActionParamsTraits + { + using Action = TransportDemuxTests::SpoofRequest; + }; + template<> struct ActionParamsTraits + { + using Action = TransportDemuxTests::LateDouble; + }; + template<> struct ActionParamsTraits + { + using Action = TransportDemuxTests::EchoToken; + }; +} + +namespace TransportDemuxTests +{ + // Adds the toy actions to the action collections, once per process. Defined in + // TransportTestKit.cpp so their transport code is generated in one place. + void RegisterActions_(); + + struct ServerTestHooks + { + std::vector* disposeModes = nullptr; + std::atomic* updateTracking = nullptr; + std::atomic* startEtw = nullptr; + std::vector* events = nullptr; + std::optional bodyReadTimeoutMs; + std::optional responseWriteTimeoutMs; + ServerExecutionContext::ProtocolGate* protocolGate = nullptr; + std::shared_ptr> borrowedAlive; + SessionLimits limits; + }; + + // Policy selects single-peer push, request/response, or addressed send. + template + class TestServer + { + public: + static constexpr uint32_t reservedAcceptors = 2; + TestServer(const std::string& pipeName, std::atomic& disposeCount, + std::mutex* disposeMutex = nullptr, std::vector* disposeReasons = nullptr, + ServerTestHooks hooks = {}) + : + server_{ ServerExecutionContext{ + .responseWriteTimeoutMs = hooks.responseWriteTimeoutMs, + .pPush = &push_, + .pDisposeCount = &disposeCount, + .pDisposeMutex = disposeMutex, + .pDisposeReasons = disposeReasons, + .pDisposeModes = hooks.disposeModes, + .pUpdateTracking = hooks.updateTracking, + .pStartEtw = hooks.startEtw, + .pLifetimeEvents = hooks.events, + .bodyReadTimeoutMs = hooks.bodyReadTimeoutMs, + .pProtocolGate = hooks.protocolGate, + .lifetime = ServerExecutionContext::LifetimeNote::Make( + hooks.events, hooks.borrowedAlive), + }, + pipeName, reservedAcceptors, std::string{}, false, hooks.limits } + { + push_ = [this](uint32_t value) { + if constexpr (Policy::kUnaddressedSend) { + server_.DispatchDetached(PushNotice::Params{ .value = value }); + } + else { + (void)value; + } + }; + } + uint32_t GetSessionCount() const { return server_.GetSessionCount(); } + uint32_t GetAcceptorCount() const { return server_.GetAcceptorCount(); } + LifecyclePhase GetLifecyclePhase() const { return server_.GetLifecyclePhase(); } + void EnterFinalTeardown() { server_.EnterFinalTeardown(); } + void BeginShutdown() { server_.BeginShutdown(); } + bool WaitForShutdown() { return server_.WaitForShutdown(); } + bool IsRunning() const { return server_.IsRunning(); } + template + void DispatchDetached(Params&& params) + { + server_.DispatchDetached(std::forward(params)); + } + std::vector CopySessionIds() { return server_.CopySessionIds(); } + template + void DispatchToSession(uint32_t sessionId, Params&& params) + { + server_.DispatchDetached(sessionId, std::forward(params)); + } + private: + std::function push_; + SymmetricActionServer server_; + }; + + class TestClient : public SymmetricActionClient + { + public: + TestClient(const std::string& pipeName, std::atomic& eventCount, + uint32_t claimedPid = GetCurrentProcessId()) + : + SymmetricActionClient{ pipeName, ClientExecutionContext{ .pEventCount = &eventCount } } + { + const auto res = DispatchSync(OpenSession::Params{ .clientPid = claimedPid }); + EstablishSession_(res.serverPid); + } + }; + + // A server with no listeners: each session is a pipe pair it creates in OpenPairedSession. + class PairedTestServer + { + public: + PairedTestServer(std::atomic& disposeCount, std::mutex* disposeMutex = nullptr, + std::vector* disposeReasons = nullptr) + : + server_{ ServerExecutionContext{ + .pPush = &push_, + .pDisposeCount = &disposeCount, + .pDisposeMutex = disposeMutex, + .pDisposeReasons = disposeReasons, + }, + std::format("pm-demux-pair-test-{}", GetCurrentProcessId()), std::string{} } + { + push_ = [this](uint32_t value) { + server_.DispatchDetached(PushNotice::Params{ .value = value }); + }; + } + util::win::Handle OpenPairedSession() { return server_.OpenPairedSession(); } + uint32_t GetSessionCount() const { return server_.GetSessionCount(); } + void BeginShutdown() { server_.BeginShutdown(); } + bool WaitForShutdown() { return server_.WaitForShutdown(); } + bool IsRunning() const { return server_.IsRunning(); } + private: + std::function push_; + SymmetricActionServer server_; + }; + + class PairedTestClient : public SymmetricActionClient + { + public: + PairedTestClient(util::win::Handle connectedPipe, std::atomic& eventCount) + : + SymmetricActionClient{ std::move(connectedPipe), "pair-test", + ClientExecutionContext{ .pEventCount = &eventCount } } + { + const auto res = DispatchSync(OpenSession::Params{ .clientPid = GetCurrentProcessId() }); + EstablishSession_(res.serverPid); + } + }; + + inline std::string MakeUniquePipeName_() + { + static std::atomic nextId = 0; + return std::format(R"(\\.\pipe\pm-demux-test-{}-{})", GetCurrentProcessId(), nextId++); + } + + struct AdmissionHookState_ + { + TestClient* client = nullptr; + std::atomic sawRunning{ false }; + std::mutex mutex; + std::condition_variable cv; + bool entered = false; + bool release = false; + }; + + inline AdmissionHookState_* gAdmissionHook_ = nullptr; + + inline void AdmissionHookFn_() + { + auto* hook = gAdmissionHook_; + if (!hook) { + return; + } + hook->sawRunning.store(hook->client + && hook->client->GetLifecyclePhase() == LifecyclePhase::Running); + std::unique_lock lock{ hook->mutex }; + hook->entered = true; + hook->cv.notify_all(); + hook->cv.wait(lock, [&] { return hook->release; }); + } + + inline std::unique_ptr ConnectClient_(const std::string& pipeName, std::atomic& eventCount) + { + Assert::IsTrue(util::pipe::DuplexPipe::WaitForAvailability(pipeName, 2000), + L"Timed out waiting for the test transport pipe"); + return std::make_unique(pipeName, eventCount); + } + + // A refused connection is accepted at the pipe level and closed by the server without + // ever becoming a session. The connect itself must succeed, so a busy or missing pipe + // fails the test instead of passing for a refusal. Nothing is sent, so within the wait + // only refusal can close it; callers keep the handshake deadline well beyond it. + template + bool ConnectionIsRefused_(const Server& server, const std::string& pipeName) + { + const auto sessionsBefore = server.GetSessionCount(); + RawPipeClient raw{ pipeName }; + Assert::IsTrue(raw.IsConnected(), L"Could not connect to the test transport pipe"); + return raw.WaitForServerClose(1s) && server.GetSessionCount() == sessionsBefore; + } + + // Counts C++ exceptions raised anywhere in this process while it is alive, including + // ones that are caught. 0xE06D7363 is the SEH code MSVC uses for every C++ throw. + class CxxThrowCounter_ + { + public: + CxxThrowCounter_() + { + count_.store(0); + handle_ = AddVectoredExceptionHandler(1, &Handler_); + } + CxxThrowCounter_(const CxxThrowCounter_&) = delete; + CxxThrowCounter_& operator=(const CxxThrowCounter_&) = delete; + ~CxxThrowCounter_() + { + RemoveVectoredExceptionHandler(handle_); + } + uint32_t Count() const + { + return count_.load(); + } + private: + static LONG CALLBACK Handler_(EXCEPTION_POINTERS* pInfo) + { + if (pInfo->ExceptionRecord->ExceptionCode == 0xE06D7363) { + count_.fetch_add(1); + } + return EXCEPTION_CONTINUE_SEARCH; + } + static inline std::atomic count_{ 0 }; + PVOID handle_ = nullptr; + }; + + template + inline void WaitForSessionCount_(const Server& server, uint32_t expected) + { + for (int i = 0; i < 200 && server.GetSessionCount() != expected; i++) { + std::this_thread::sleep_for(10ms); + } + } + + inline void AssertTeardownIsPrompt_(std::unique_ptr& pClient, const wchar_t* situation) + { + const auto start = std::chrono::steady_clock::now(); + pClient.reset(); + const auto elapsed = std::chrono::steady_clock::now() - start; + Assert::IsTrue(elapsed < 500ms, (L"Client teardown stalled "s + situation).c_str()); + } + + template + inline void WaitForAcceptorCount_(const Server& server, uint32_t expected) + { + for (int i = 0; i < 200 && server.GetAcceptorCount() != expected; i++) { + std::this_thread::sleep_for(10ms); + } + } + + // connect and vanish, the way an abusive (or merely impatient) client does + inline void RawConnectAndClose_(const std::string& name) + { + for (int i = 0; i < 200; i++) { + const util::win::Handle handle{ CreateFileA(name.c_str(), GENERIC_READ | GENERIC_WRITE, + 0, nullptr, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, nullptr) }; + if (handle) { + // the handle closes here, before the server has a chance to serve it + return; + } + const auto error = GetLastError(); + if (error != ERROR_PIPE_BUSY && error != ERROR_FILE_NOT_FOUND) { + Assert::Fail(util::str::ToWide(std::format( + "Raw connect to the transport test pipe failed with error {}", error)).c_str()); + } + std::this_thread::sleep_for(5ms); + } + Assert::Fail(L"Raw connect to the transport test pipe never succeeded"); + } + + inline void AssertPipeAbsent_(const std::string& name) + { + const util::win::Handle handle{ CreateFileA(name.c_str(), GENERIC_READ | GENERIC_WRITE, + 0, nullptr, OPEN_EXISTING, 0, nullptr) }; + const auto error = GetLastError(); + Assert::IsFalse((bool)handle, L"A suffixed half-pipe name is still being created"); + Assert::AreEqual((DWORD)ERROR_FILE_NOT_FOUND, error); + } +} diff --git a/IntelPresentMon/PresentMonAPI2Tests/TransportWriteDeadlineTests.cpp b/IntelPresentMon/PresentMonAPI2Tests/TransportWriteDeadlineTests.cpp new file mode 100644 index 000000000..ebc3965ad --- /dev/null +++ b/IntelPresentMon/PresentMonAPI2Tests/TransportWriteDeadlineTests.cpp @@ -0,0 +1,166 @@ +// Copyright (C) 2022-2026 Intel Corporation +// SPDX-License-Identifier: MIT +// Deadlines on sending and on waiting for responses: a peer that stops reading or answering +// cannot hold a request past its timeout. +#include "TransportTestKit.h" + +namespace TransportDemuxTests +{ + TEST_CLASS(WriteDeadlineTests) + { + public: + TEST_METHOD_INITIALIZE(Setup) + { + RegisterActions_(); + } + // a responder that goes quiet must cost the requester a bounded wait, and only + // that request: the session itself stays usable + TEST_METHOD(ResponseTimeoutFailsOnlyTheRequest) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + TestServer server{ pipeName, disposeCount }; + auto pClient = ConnectClient_(pipeName, eventCount); + + Assert::ExpectException([&] { + pClient->DispatchSync(StallForClient::Params{ .durationMs = 1000 }, 100); + }, L"A request that was never answered did not time out"); + // the abandoned response arrives late and is dropped, leaving the stream usable + const auto res = pClient->DispatchSync(EchoWithPush::Params{ .value = 11, .pushCount = 0 }); + Assert::AreEqual(11u, res.value, L"A timed out request poisoned its session"); + Assert::AreEqual(1u, server.GetSessionCount(), L"A timed out request dropped its session"); + } + // A relaunched UI asks for a new pair, possibly before the old pipe's close was seen. + // A server that stops reading cannot hold a request past its timeout: sending shares the + // request's deadline, the session then ends, and a request queued behind the stalled + // write fails promptly instead of waiting for it. + TEST_METHOD(StalledPeerBoundsRequestSend) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + TestServer server{ pipeName, disposeCount }; + auto pClient = ConnectClient_(pipeName, eventCount); + // the server's reader awaits this stall, so it reads nothing for 2 s + std::jthread staller{ [&] { + try { + pClient->DispatchSync(StallForClient::Params{ .durationMs = 2000 }, 5000u); + } + catch (...) { + } + } }; + std::this_thread::sleep_for(100ms); + std::atomic finished = 0; + auto send = [&](auto params) { + try { + pClient->DispatchSync(std::move(params), 50u); + } + catch (...) { + } + finished.fetch_add(1); + }; + const auto start = std::chrono::steady_clock::now(); + { + std::jthread bulk{ [&] { send(BulkRequest::Params{ .bytes = std::string(512 * 1024, 'x') }); } }; + std::this_thread::sleep_for(10ms); + // waits for the write lock the bulk request holds + std::jthread queued{ [&] { send(EchoWithPush::Params{ .value = 1, .pushCount = 0 }); } }; + for (int i = 0; i < 300 && finished.load() < 2; i++) { + std::this_thread::sleep_for(10ms); + } + } + const auto elapsed = std::chrono::steady_clock::now() - start; + // without a send deadline the bulk write would last until the stall ends + Assert::IsTrue(elapsed < 1000ms, L"A request to a peer that stopped reading outlived its timeout"); + for (int i = 0; i < 200 && pClient->IsRunning(); i++) { + std::this_thread::sleep_for(10ms); + } + Assert::IsFalse(pClient->IsRunning(), L"The session survived a write that timed out"); + // the stall waits on a timer that closing the session cannot interrupt, so let it end + // before the server is destroyed; otherwise its shutdown would exceed its timeout + WaitForSessionCount_(server, 0); + } + // A request queued behind a stalled write keeps its own deadline: a 50 ms request behind + // a 1,000 ms one fails at about 50 ms, not when the other gives up. Nothing of it was + // sent, so it does not end the session; the stalled writer does, at its own deadline. + TEST_METHOD(QueuedRequestKeepsItsOwnDeadline) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + TestServer server{ pipeName, disposeCount }; + auto pClient = ConnectClient_(pipeName, eventCount); + // the server's reader awaits this stall, so it reads nothing for 2.5 s + std::jthread staller{ [&] { + try { + pClient->DispatchSync(StallForClient::Params{ .durationMs = 2500 }, 5000u); + } + catch (...) { + } + } }; + std::this_thread::sleep_for(100ms); + std::atomic bulkDone = false; + std::jthread bulk{ [&] { + try { + pClient->DispatchSync(BulkRequest::Params{ .bytes = std::string(512 * 1024, 'x') }, 1000u); + } + catch (...) { + } + bulkDone.store(true); + } }; + std::this_thread::sleep_for(20ms); + // waits for the write lock the bulk request holds + const auto start = std::chrono::steady_clock::now(); + try { + pClient->DispatchSync(EchoWithPush::Params{ .value = 1, .pushCount = 0 }, 50u); + } + catch (...) { + } + const auto queuedElapsed = std::chrono::steady_clock::now() - start; + const bool sessionUpAfterQueuedTimeout = pClient->IsRunning() && !bulkDone.load(); + bulk.join(); + for (int i = 0; i < 200 && pClient->IsRunning(); i++) { + std::this_thread::sleep_for(10ms); + } + const bool sessionEndedByStall = !pClient->IsRunning(); + // the stall waits on a timer that closing the session cannot interrupt, so let it end + // before the server is destroyed; otherwise its shutdown would exceed its timeout + WaitForSessionCount_(server, 0); + Assert::IsTrue(queuedElapsed < 300ms, L"A queued request waited past its own deadline for the stalled write"); + Assert::IsTrue(sessionUpAfterQueuedTimeout, L"A queued request's timeout ended the session"); + Assert::IsTrue(sessionEndedByStall, L"The stalled write did not end the session at its own deadline"); + } + // A response whose deadline passes while it waits for the write lock has sent nothing, + // so only that response is dropped: the requester times out on its own and the session + // stays up for the requests after it. + TEST_METHOD(QueuedResponseTimeoutKeepsSession) + { + const auto pipeName = MakeUniquePipeName_(); + std::atomic disposeCount = 0; + std::atomic eventCount = 0; + TestServer server{ pipeName, disposeCount, nullptr, nullptr, + ServerTestHooks{ .responseWriteTimeoutMs = 100u } }; + auto pClient = ConnectClient_(pipeName, eventCount); + // the client's reader awaits this stall, so it reads nothing for 1.5 s + server.DispatchDetached(StallReaderNotice::Params{ .durationMs = 1500 }); + // this push then holds the server's write lock until the stall ends + server.DispatchDetached(BulkNotice::Params{ .bytes = std::string(512 * 1024, 'x') }); + std::this_thread::sleep_for(100ms); + // the response waits for that lock past its 100 ms deadline + Assert::ExpectException([&] { + pClient->DispatchSync(EchoWithPush::Params{ .value = 1, .pushCount = 0 }, 500u); + }, L"A request whose response was dropped did not time out"); + // the server's view: the client cannot see a session end while its reader is stalled + Assert::AreEqual(1u, server.GetSessionCount(), L"A response that missed its deadline in the write queue ended the session"); + // once the client has read the push, the lock is free again + for (int i = 0; i < 500 && eventCount.load() == 0; i++) { + std::this_thread::sleep_for(10ms); + } + Assert::AreEqual(1u, eventCount.load(), L"The client never received the push"); + const auto res = pClient->DispatchSync(EchoWithPush::Params{ .value = 2, .pushCount = 0 }, 5000u); + Assert::AreEqual(2u, res.value, L"The session did not serve a request after a dropped response"); + Assert::AreEqual(1u, server.GetSessionCount(), L"A dropped response ended its session"); + } + }; +} diff --git a/IntelPresentMon/PresentMonAPI2Tests/UiLaunchTests.cpp b/IntelPresentMon/PresentMonAPI2Tests/UiLaunchTests.cpp index bac325b37..9b7bf6afe 100644 --- a/IntelPresentMon/PresentMonAPI2Tests/UiLaunchTests.cpp +++ b/IntelPresentMon/PresentMonAPI2Tests/UiLaunchTests.cpp @@ -8,8 +8,12 @@ #include "../CommonUtilities/win/com/ComPtr.h" #include #include +#include #include +#include #include +#include +#include #include #include #include @@ -60,6 +64,29 @@ namespace UiLaunchTests return predicate(); } + // names of the pipes that exist on this machine right now + static std::vector ListPipeNames_() + { + std::vector names; + WIN32_FIND_DATAW data{}; + const auto hFind = FindFirstFileW(LR"(\\.\pipe\*)", &data); + if (hFind == INVALID_HANDLE_VALUE) { + return names; + } + do { + names.push_back(data.cFileName); + } while (FindNextFileW(hFind, &data)); + FindClose(hFind); + return names; + } + + static bool FileContains_(const std::filesystem::path& path, const std::string& text) + { + std::ifstream file{ path }; + const std::string contents{ std::istreambuf_iterator{ file }, std::istreambuf_iterator{} }; + return contents.find(text) != std::string::npos; + } + static HWND WaitForUiWindow_(const std::string& mutexSuffix) { HWND hWnd = nullptr; @@ -150,6 +177,34 @@ namespace UiLaunchTests fixture_.Cleanup(); } + // The kernel serves its UI over a private pipe pair: the UI's session opens, and the + // kernel's old public pipe (ipm-cef-channel-), which any process could connect + // to, does not exist. + TEST_METHOD(UiSessionUsesPrivatePipePair) + { + const auto mutexSuffix = MakeMutexSuffix_("PrivatePipePair"); + // the kernel's file log is buffered; synchronous logging writes each entry through + auto kernelArgs = MakeKernelArgs_(mutexSuffix); + kernelArgs.push_back("--log-synchronous"s); + auto kernel = fixture_.LaunchKernel(kernelArgs); + WaitForUiWindow_(mutexSuffix); + const auto kernelPid = kernel.GetId(); + // on OpenSession the kernel logs "Kernel open action for cef= krn=" + const auto logPath = std::filesystem::path{ logFolder_ } / "pmlog.txt"; + Assert::IsTrue(WaitFor_(15s, [&] { + return FileContains_(logPath, std::format(" krn={}", kernelPid)); + }), L"The UI did not open its session with the kernel"); + const auto pipes = ListPipeNames_(); + const auto publicName = std::format(L"ipm-cef-channel-{}", kernelPid); + const auto pairPrefix = std::format(L"pm-kernel-ui-{}-", kernelPid); + Assert::IsFalse(std::ranges::any_of(pipes, [&](const std::wstring& name) { return name == publicName; }), + L"The kernel published a public pipe for its UI"); + Assert::IsTrue(std::ranges::any_of(pipes, [&](const std::wstring& name) { return name.starts_with(pairPrefix); }), + L"The kernel's private pipe pair was not found"); + + TerminateUiInstanceAndWait_(mutexSuffix, kernel); + } + TEST_METHOD(ApplicationLaunchBringsExistingUiToForeground) { const auto mutexSuffix = MakeMutexSuffix_("Foreground"); diff --git a/IntelPresentMon/PresentMonMiddleware/ActionClient.h b/IntelPresentMon/PresentMonMiddleware/ActionClient.h index f382a9f0a..35c4484b2 100644 --- a/IntelPresentMon/PresentMonMiddleware/ActionClient.h +++ b/IntelPresentMon/PresentMonMiddleware/ActionClient.h @@ -21,9 +21,9 @@ namespace pmon::mid public: ActionClient(const std::string& pipeName) : ClientBase{ pipeName } { - VerifySharedServiceControlPipeServerIfNeeded(GetControlPipeBaseName_(), GetActionConnector_()); + VerifySharedServiceControlPipeServerIfNeeded(GetControlPipeBaseName_(), SessionConnector_()); const uint32_t verifiedPipeServerPid = IsDefaultSharedServiceControlPipe(GetControlPipeBaseName_()) - ? GetActionConnector_().ResolveConnectedServerProcessId() + ? SessionConnector_().ResolveConnectedServerProcessId() : 0u; auto res = DispatchSync(OpenSession::Params{ .clientPid = GetCurrentProcessId(), diff --git a/IntelPresentMon/PresentMonMiddleware/MiddlewareExecutionContext.h b/IntelPresentMon/PresentMonMiddleware/MiddlewareExecutionContext.h index 1945266bb..c2a35846d 100644 --- a/IntelPresentMon/PresentMonMiddleware/MiddlewareExecutionContext.h +++ b/IntelPresentMon/PresentMonMiddleware/MiddlewareExecutionContext.h @@ -1,18 +1,14 @@ #pragma once -#include "../Interprocess/source/act/SymmetricActionConnector.h" #include -#include #include namespace pmon::mid { - struct MiddlewareExecutionContext; - + // define minimal context for client side connection struct MiddlewareSessionContext { - std::unique_ptr> pConn; + // required by the transport, see TransportSessionContext uint32_t remotePid = 0; - uint32_t nextCommandToken = 0; }; struct MiddlewareExecutionContext diff --git a/IntelPresentMon/PresentMonMiddleware/SharedServicePipeVerification.h b/IntelPresentMon/PresentMonMiddleware/SharedServicePipeVerification.h index c2bd2b635..977e2c47a 100644 --- a/IntelPresentMon/PresentMonMiddleware/SharedServicePipeVerification.h +++ b/IntelPresentMon/PresentMonMiddleware/SharedServicePipeVerification.h @@ -1,5 +1,6 @@ #pragma once #include "MiddlewareExecutionContext.h" +#include "../Interprocess/source/act/SymmetricActionConnector.h" #include "../CommonUtilities/win/ServiceProcess.h" #include diff --git a/IntelPresentMon/PresentMonService/ActionExecutionContext.cpp b/IntelPresentMon/PresentMonService/ActionExecutionContext.cpp index a04ff45fc..c53fd2028 100644 --- a/IntelPresentMon/PresentMonService/ActionExecutionContext.cpp +++ b/IntelPresentMon/PresentMonService/ActionExecutionContext.cpp @@ -8,36 +8,65 @@ namespace pmon::svc::acts { - void ActionExecutionContext::Dispose(SessionContextType& stx) + // Runs for every disposed session, including one that never sent a valid OpenSession. + // Each step is guarded on the session actually having requested something, because the + // updates are not free (UpdateTracking will start an ETW session even for an empty pid + // set) and any client can open and drop sessions as fast as it likes. + // + // SessionEndReason stays the transport cause. SessionCleanupMode decides whether + // global state may be recomputed. Final teardown is set before the action server + // is drained and before StopTraceSessions. LocalShutdown during normal operation + // still skips recompute. Release session-local backpressure and ETL either way. + void ActionExecutionContext::Dispose(SessionContextType& stx, ipc::act::SessionDisposition disposition) { - for (auto const& [pid, target] : stx.trackedPids) { - if (target.backpressureReadSerial) { - ReleaseBackpressure(pid); + const bool recomputeGlobal = ipc::act::ShouldRecomputeGlobalState(disposition); + // tracked pids cleanup + if (!stx.trackedPids.empty()) { + for (auto const& [pid, target] : stx.trackedPids) { + if (target.backpressureReadSerial) { + ReleaseBackpressure(pid); + } + } + stx.trackedPids.clear(); + if (recomputeGlobal) { + pPmon->UpdateTracking(GetTrackedPidSet()); + UpdatePeriodicLogFlushing(); } } - // etw log trace cleanup - auto& etw = pPmon->GetEtwLogger(); - for (auto id : stx.etwLogSessionIds) { - if (etw.HasActiveSession(id)) { - etw.CancelLogSession(id); + // etw log trace cleanup is session-local even during LocalShutdown + if (!stx.etwLogSessionIds.empty()) { + auto& etw = pPmon->GetEtwLogger(); + for (auto id : stx.etwLogSessionIds) { + if (etw.HasActiveSession(id)) { + etw.CancelLogSession(id); + } } + stx.etwLogSessionIds.clear(); } - // tracked pids cleanup - stx.trackedPids.clear(); - pPmon->UpdateTracking(GetTrackedPidSet()); - UpdatePeriodicLogFlushing(); // telemetry period cleanup - stx.requestedTelemetryPeriodMs.reset(); - UpdateTelemetryPeriod(); + if (stx.requestedTelemetryPeriodMs) { + stx.requestedTelemetryPeriodMs.reset(); + if (recomputeGlobal) { + UpdateTelemetryPeriod(); + } + } // etw flush cleanup - stx.requestedEtwFlushPeriodMs.reset(); - UpdateEtwFlushPeriod(); + if (stx.requestedEtwFlushPeriodMs) { + stx.requestedEtwFlushPeriodMs.reset(); + if (recomputeGlobal) { + UpdateEtwFlushPeriod(); + } + } // metric use cleanup - pmlog_verb(pmon::util::log::V::met_use)("Session closing, removing metric usage") - .pmwatch(stx.remotePid) - .serialize("sessionMetricUsage", stx.metricUsage); - stx.metricUsage.clear(); - UpdateMetricUsage(); + if (!stx.metricUsage.empty()) { + pmlog_verb(pmon::util::log::V::met_use)("Session closing, removing metric usage") + .pmwatch(stx.remotePid) + .serialize("sessionMetricUsage", stx.metricUsage); + stx.metricUsage.clear(); + if (recomputeGlobal) { + UpdateMetricUsage(); + } + } } void ActionExecutionContext::UpdateTelemetryPeriod() const { diff --git a/IntelPresentMon/PresentMonService/ActionExecutionContext.h b/IntelPresentMon/PresentMonService/ActionExecutionContext.h index 618395944..ada42e2eb 100644 --- a/IntelPresentMon/PresentMonService/ActionExecutionContext.h +++ b/IntelPresentMon/PresentMonService/ActionExecutionContext.h @@ -1,7 +1,8 @@ #pragma once -#include "../Interprocess/source/act/SymmetricActionConnector.h" +#include "../Interprocess/source/act/SessionEndReason.h" #include "../Interprocess/source/ShmNamer.h" #include "../CommonUtilities/win/Handle.h" +#include #include #include #include @@ -19,19 +20,12 @@ #include "FrameBroadcaster.h" #include "MetricUse.h" -namespace pmon::svc::acts -{ - struct ActionExecutionContext; -} - namespace pmon::svc::acts { struct ActionSessionContext { - // common session context items - std::unique_ptr> pConn; + // required by the transport, see TransportSessionContext uint32_t remotePid = 0; - uint32_t nextCommandToken = 0; // custom items struct TrackedTarget @@ -62,7 +56,18 @@ namespace pmon::svc::acts std::optional responseWriteTimeoutMs; // functions - void Dispose(SessionContextType& stx); + void EnterFinalTeardown() + { + cleanupMode_->store(ipc::act::SessionCleanupMode::FinalTeardown, std::memory_order_release); + } + ipc::act::SessionCleanupMode GetSessionCleanupMode() const + { + return cleanupMode_->load(std::memory_order_acquire); + } + void Dispose(SessionContextType& stx, ipc::act::SessionDisposition disposition); + + std::shared_ptr> cleanupMode_ = + std::make_shared>(ipc::act::SessionCleanupMode::NormalOperation); // TODO: refactor so that these functions need not be const void UpdateTelemetryPeriod() const; diff --git a/IntelPresentMon/PresentMonService/ActionServer.cpp b/IntelPresentMon/PresentMonService/ActionServer.cpp index 083dc158f..52c7ca1e9 100644 --- a/IntelPresentMon/PresentMonService/ActionServer.cpp +++ b/IntelPresentMon/PresentMonService/ActionServer.cpp @@ -14,6 +14,17 @@ namespace pmon::svc using namespace util; using namespace ipc; + using ServerImpl = act::SymmetricActionServer; + + namespace + { + // pImpl_ is type-erased so the header does not need the server template + ServerImpl& Impl(const std::shared_ptr& pImpl) + { + return *static_cast(pImpl.get()); + } + } + ActionServer::ActionServer(Service* pSvc, PresentMon* pPmon, std::optional pipeName, bool controlPipeAllowAuClients) { @@ -27,10 +38,30 @@ namespace pmon::svc sec = pipe::DuplexPipe::GetServiceControlPipeSecurityString(); } // construct (and start) the server - pImpl_ = std::make_shared>( + pImpl_ = std::make_shared( acts::ActionExecutionContext{ .pSvc = pSvc, .pPmon = pPmon }, pipeName.value_or(gid::defaultControlPipeName), reservedPipeInstanceCount, std::move(sec), false ); } + void ActionServer::EnterFinalTeardown() + { + Impl(pImpl_).EnterFinalTeardown(); + } + void ActionServer::BeginShutdown() + { + Impl(pImpl_).BeginShutdown(); + } + bool ActionServer::WaitForShutdown() + { + return Impl(pImpl_).WaitForShutdown(); + } + uint32_t ActionServer::GetSessionCount() const + { + return Impl(pImpl_).GetSessionCount(); + } + uint32_t ActionServer::GetAcceptorCount() const + { + return Impl(pImpl_).GetAcceptorCount(); + } } diff --git a/IntelPresentMon/PresentMonService/ActionServer.h b/IntelPresentMon/PresentMonService/ActionServer.h index 5c0dc4d1c..ca72a762f 100644 --- a/IntelPresentMon/PresentMonService/ActionServer.h +++ b/IntelPresentMon/PresentMonService/ActionServer.h @@ -16,10 +16,18 @@ namespace pmon::svc ActionServer(Service* pSvc, PresentMon* pPmon, std::optional pipeName, bool controlPipeAllowAuClients = false); ~ActionServer() = default; + // Marks disposal as final teardown. Does not stop the runner. + void EnterFinalTeardown(); + void BeginShutdown(); + // False if the runner has not stopped. Does not detach and does not destroy PresentMon. + bool WaitForShutdown(); ActionServer(const ActionServer&) = delete; ActionServer& operator=(const ActionServer&) = delete; ActionServer(ActionServer&&) = delete; ActionServer& operator=(ActionServer&&) = delete; + // transport health, exposed so tests can assert that abuse does not erode admission + uint32_t GetSessionCount() const; + uint32_t GetAcceptorCount() const; private: std::shared_ptr pImpl_; }; diff --git a/IntelPresentMon/PresentMonService/CliOptions.h b/IntelPresentMon/PresentMonService/CliOptions.h index 4b71f1fbd..0fdb0d7b9 100644 --- a/IntelPresentMon/PresentMonService/CliOptions.h +++ b/IntelPresentMon/PresentMonService/CliOptions.h @@ -36,11 +36,9 @@ namespace clio private: Group gl_{ this, "Logging", "Control logging behavior" }; public: Option logDir{ this, "--log-dir", "", "Enable logging to a file in the specified directory" }; - Option logPipeName{ this, "--log-pipe-name", pmon::gid::defaultLogPipeBaseName, "Name of the pipe to connect to for log IPC" }; Flag enableStdioLog{ this, "--enable-stdio-log", "Enable logging to stderr" }; Flag disableColorizedStdioLog{ this, "--disable-colorized-stdio-log", "Disable colorized stderr logging in console mode" }; Flag enableDebuggerLog{ this, "--enable-debugger-log", "Enable logging to system debugger" }; - Flag disableIpcLog{ this, "--disable-ipc-log", "Disable logging to named pipe connection" }; Option logLevel{ this, "--log-level", Level::Error, "Severity to log at", logLevelTf_ }; Flag logNamePid{ this, "--log-name-pid", "Append PID to log files instead of timestamp" }; Option> logVerboseModules{ this, "--log-verbose-modules", {}, "Verbose logging modules to enable", logVmodTf_ }; diff --git a/IntelPresentMon/PresentMonService/GlobalIdentifiers.h b/IntelPresentMon/PresentMonService/GlobalIdentifiers.h index a364919f9..706fac62e 100644 --- a/IntelPresentMon/PresentMonService/GlobalIdentifiers.h +++ b/IntelPresentMon/PresentMonService/GlobalIdentifiers.h @@ -4,7 +4,6 @@ namespace pmon::gid { inline constexpr const char* defaultControlPipeName = R"(\\.\pipe\sharedpresentmonsvcnamedpipe)"; inline constexpr const wchar_t* sharedWindowsServiceName = L"PresentMonSharedService"; - inline constexpr const char* defaultLogPipeBaseName = "shared-pm2-svc-log"; inline constexpr const wchar_t* registryPath = LR"(SOFTWARE\INTEL\PresentMon\Service)"; inline constexpr const char* middlewarePathKey = "sharedMiddlewarePath"; } \ No newline at end of file diff --git a/IntelPresentMon/PresentMonService/LogSetup.cpp b/IntelPresentMon/PresentMonService/LogSetup.cpp index 418c4723d..d21242c07 100644 --- a/IntelPresentMon/PresentMonService/LogSetup.cpp +++ b/IntelPresentMon/PresentMonService/LogSetup.cpp @@ -12,8 +12,6 @@ #include "../CommonUtilities/log/ErrorCodeResolvePolicy.h" #include "../CommonUtilities/log/ErrorCodeResolver.h" #include "../CommonUtilities/log/ChannelFlusher.h" -#include "../CommonUtilities/log/NamedPipeMarshallSender.h" -#include "../CommonUtilities/log/MarshallDriver.h" #include "../CommonUtilities/win/HrErrorCodeProvider.h" #include "../CommonUtilities/str/String.h" #include "../CommonUtilities/Exception.h" @@ -114,19 +112,6 @@ namespace logsetup pChannel->AttachComponent(std::make_shared( std::make_shared(), std::make_shared(fullPath)), "drv:file"); } - // setup ipc logging connection for clients - if (!opt.disableIpcLog) { - try { - auto pSender = std::make_shared(*opt.logPipeName, - asApp ? pipe::SecurityMode::Child : pipe::SecurityMode::Service); - log::IdentificationTable::RegisterSink(pSender); - auto pDriver = std::make_shared(pSender); - pChannel->AttachComponent(std::move(pDriver)); - } - catch (...) { - pmlog_panic_(ReportException()); - } - } } void SetPeriodicLogFlushingEnabled_(bool enabled) { diff --git a/IntelPresentMon/PresentMonService/PMMainThread.cpp b/IntelPresentMon/PresentMonService/PMMainThread.cpp index 53b74495f..60afa4f2b 100644 --- a/IntelPresentMon/PresentMonService/PMMainThread.cpp +++ b/IntelPresentMon/PresentMonService/PMMainThread.cpp @@ -19,6 +19,7 @@ #include "../CommonUtilities/log/GlogShim.h" #include "testing/TestControl.h" +#include "../Interprocess/source/act/ActionServerShutdown.h" using namespace std::literals; @@ -273,7 +274,7 @@ void PresentMonMainThread(Service* const pSvc) // communication controller for testing purposes std::unique_ptr pTcm; if (opt.enableTestControl) { - pTcm = std::make_unique(&pm, pSvc); + pTcm = std::make_unique(&pm, pSvc, pActionServer.get()); } // periodically check trace sessions while waiting for service stop event @@ -281,8 +282,14 @@ void PresentMonMainThread(Service* const pSvc) pm.CheckTraceSessions(); } - // Stop the PresentMon sessions - pm.StopTraceSessions(); + // Drain the action server while PresentMon, ETW, and the broadcaster are still alive. + // StopTraceSessions runs only after the runner has been joined. + pTcm.reset(); + pmon::ipc::act::JoinActionServerThenStopTraces(*pActionServer, [] {}, [&] { + pActionServer.reset(); + }, [&] { + pm.StopTraceSessions(); + }); // wait for the telemetry threads to exit if (telemetryThread.joinable()) { telemetryThread.join(); diff --git a/IntelPresentMon/PresentMonService/PresentMonService.args.json b/IntelPresentMon/PresentMonService/PresentMonService.args.json index 3a204374f..779f4d011 100644 --- a/IntelPresentMon/PresentMonService/PresentMonService.args.json +++ b/IntelPresentMon/PresentMonService/PresentMonService.args.json @@ -33,10 +33,6 @@ { "Id": "451e8835-6620-45f0-83cc-0cd19b29d21e", "Command": "--etl-test-file \"..\\..\\tests\\gold\\test_case_4.etl\"" - }, - { - "Id": "aafe8ea9-f424-4996-9b6d-109bd2a76f40", - "Command": "--disable-ipc-log" } ] } \ No newline at end of file diff --git a/IntelPresentMon/PresentMonService/acts/OpenSession.h b/IntelPresentMon/PresentMonService/acts/OpenSession.h index 56d8693c8..549d9e7fa 100644 --- a/IntelPresentMon/PresentMonService/acts/OpenSession.h +++ b/IntelPresentMon/PresentMonService/acts/OpenSession.h @@ -51,8 +51,8 @@ namespace pmon::svc::acts stx.remotePid = in.clientPid; stx.clientBuildId = in.clientBuildId; ctx.pSvc->SignalClientSessionOpened(); - pmlog_info(std::format("Open action for session #{} pid={}; [BID] cli={} svc={} [CFG] cli={} svc={}", - stx.pConn->GetId(), in.clientPid, in.clientBuildId, bid::BuildIdLongHash(), + pmlog_info(std::format("Open action for pid={}; [BID] cli={} svc={} [CFG] cli={} svc={}", + in.clientPid, in.clientBuildId, bid::BuildIdLongHash(), in.clientBuildConfig, bid::BuildIdConfig())); return Response{ .servicePid = GetCurrentProcessId(), diff --git a/IntelPresentMon/PresentMonService/testing/TestControl.cpp b/IntelPresentMon/PresentMonService/testing/TestControl.cpp index bd4078ff2..009075844 100644 --- a/IntelPresentMon/PresentMonService/testing/TestControl.cpp +++ b/IntelPresentMon/PresentMonService/testing/TestControl.cpp @@ -1,16 +1,18 @@ #include "TestControl.h" #include "../PresentMon.h" #include "../Service.h" +#include "../ActionServer.h" #include #include #include namespace pmon::svc::testing { - TestControlModule::TestControlModule(const PresentMon* pPmon, Service* pService) + TestControlModule::TestControlModule(const PresentMon* pPmon, Service* pService, const ActionServer* pActionServer) : pPresentMon_{ pPmon }, pService_{ pService }, + pActionServer_{ pActionServer }, worker_{ &TestControlModule::Run_, this } {} void TestControlModule::Run_() @@ -34,8 +36,13 @@ namespace pmon::svc::testing break; } else if (line == "%status") { + auto status = pPresentMon_->GetTestingStatus(); + if (pActionServer_) { + status.actionSessionCount = pActionServer_->GetSessionCount(); + status.actionAcceptorCount = pActionServer_->GetAcceptorCount(); + } std::ostringstream oss; - cereal::JSONOutputArchive{ oss }(pPresentMon_->GetTestingStatus()); + cereal::JSONOutputArchive{ oss }(status); WriteResponse_(oss.str()); } else { diff --git a/IntelPresentMon/PresentMonService/testing/TestControl.h b/IntelPresentMon/PresentMonService/testing/TestControl.h index fa4a2d0fa..366e45e27 100644 --- a/IntelPresentMon/PresentMonService/testing/TestControl.h +++ b/IntelPresentMon/PresentMonService/testing/TestControl.h @@ -5,18 +5,24 @@ class PresentMon; class Service; +namespace pmon::svc +{ + class ActionServer; +} + namespace pmon::svc::testing { class TestControlModule { public: - TestControlModule(const PresentMon* pPresentMon, Service* pService); + TestControlModule(const PresentMon* pPresentMon, Service* pService, const ActionServer* pActionServer); private: void Run_(); static void WriteResponse_(const std::string& payload); const PresentMon* pPresentMon_; Service* pService_; + const ActionServer* pActionServer_; std::jthread worker_; }; } \ No newline at end of file diff --git a/IntelPresentMon/UnitTests/CMakeLists.txt b/IntelPresentMon/UnitTests/CMakeLists.txt index 439b5707b..dad79187f 100644 --- a/IntelPresentMon/UnitTests/CMakeLists.txt +++ b/IntelPresentMon/UnitTests/CMakeLists.txt @@ -5,6 +5,7 @@ add_library( SHARED ExtremeQueue.cpp FixedVectorTests.cpp + HotkeyTests.cpp InterPresentActivityTests.cpp LoggingTests.cpp MetricCapabilitiesTests.cpp diff --git a/IntelPresentMon/UnitTests/HotkeyTests.cpp b/IntelPresentMon/UnitTests/HotkeyTests.cpp new file mode 100644 index 000000000..cc1d5d621 --- /dev/null +++ b/IntelPresentMon/UnitTests/HotkeyTests.cpp @@ -0,0 +1,41 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: MIT + +#include +#include + +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace HotkeyTests +{ + using p2c::win::Hotkeys; + using p2c::win::Key; + using p2c::win::ModSet; + + // Each test creates one Hotkeys at a time: it registers a fixed window class name, + // so two live instances in one process cannot both start. + TEST_CLASS(HotkeyBindingTests) + { + public: + // The kernel clears every binding when its UI session ends. + TEST_METHOD(ClearAllRemovesEveryBinding) + { + Hotkeys hotkeys; + hotkeys.BindAction(1, Key{ Key::Code::F23 }, ModSet::FromCodes({})); + hotkeys.BindAction(2, Key{ Key::Code::F24 }, ModSet::FromCodes({})); + Assert::AreEqual((size_t)2, hotkeys.ClearAll(), L"ClearAll did not remove both bindings"); + Assert::AreEqual((size_t)0, hotkeys.ClearAll(), L"Bindings survived ClearAll"); + } + // Binding an action again replaces its old combination rather than adding a second one, + // which a reloaded UI page relies on when it binds its hotkeys again. + TEST_METHOD(RebindingAnActionReplacesItsCombination) + { + Hotkeys hotkeys; + hotkeys.BindAction(1, Key{ Key::Code::F23 }, ModSet::FromCodes({})); + hotkeys.BindAction(1, Key{ Key::Code::F24 }, ModSet::FromCodes({})); + Assert::AreEqual((size_t)1, hotkeys.ClearAll(), L"Rebinding an action left its old combination bound"); + } + }; +} diff --git a/IntelPresentMon/UnitTests/UnitTests.vcxproj b/IntelPresentMon/UnitTests/UnitTests.vcxproj index d098ee85b..6aa18f044 100644 --- a/IntelPresentMon/UnitTests/UnitTests.vcxproj +++ b/IntelPresentMon/UnitTests/UnitTests.vcxproj @@ -106,6 +106,7 @@ + diff --git a/IntelPresentMon/UnitTests/UnitTests.vcxproj.filters b/IntelPresentMon/UnitTests/UnitTests.vcxproj.filters index 5b66b8b85..a8c3c376a 100644 --- a/IntelPresentMon/UnitTests/UnitTests.vcxproj.filters +++ b/IntelPresentMon/UnitTests/UnitTests.vcxproj.filters @@ -4,6 +4,7 @@ + diff --git a/PresentMonService.args.json b/PresentMonService.args.json index 3305bddd4..be7a31440 100644 --- a/PresentMonService.args.json +++ b/PresentMonService.args.json @@ -29,10 +29,6 @@ { "Id": "bc1dd7da-c628-48fe-b073-19c243473c15", "Command": "--shm-name-prefix pm_my_nsm" - }, - { - "Id": "aafe8ea9-f424-4996-9b6d-109bd2a76f40", - "Command": "--disable-ipc-log" } ] } diff --git a/Tests/ensure-aux.ps1 b/Tests/ensure-aux.ps1 index a28e194dc..076599ddc 100644 --- a/Tests/ensure-aux.ps1 +++ b/Tests/ensure-aux.ps1 @@ -6,6 +6,7 @@ param( ) $ErrorActionPreference = "Stop" +Import-Module Microsoft.PowerShell.Utility -ErrorAction Stop function Resolve-InputPath([string]$Path, [string]$BasePath) { if ([System.IO.Path]::IsPathRooted($Path)) { diff --git a/cmake/PresentMonCTest.cmake b/cmake/PresentMonCTest.cmake index dbef9b53e..20c2196a0 100644 --- a/cmake/PresentMonCTest.cmake +++ b/cmake/PresentMonCTest.cmake @@ -4,7 +4,7 @@ include_guard(GLOBAL) # (128 tests: 113 deterministic, 15 environment). set( PMON_API2_CTEST_ENVIRONMENT_NAME_REGEX - "RealtimeOpenSessionTest|RealtimeTrackProcessTest|RealtimeFrameMetricsTest|ApplicationLaunch|TrackUntrack|TrackPresenter|TargetTracked|UntrackOnClose|UntrackOnMurder|OpenServiceTest|DynamicQueryWithoutTracked|ServiceCrash|EtwFlush|TelemetryPeriod|FrameStoreRealtime|FrameStorePaced|FrameStoreBackpressured|FrameStorePlayback|LegacyBackpressured" + "RealtimeOpenSessionTest|RealtimeTrackProcessTest|RealtimeFrameMetricsTest|ApplicationLaunch|UiSessionUsesPrivatePipePair|TrackUntrack|TrackPresenter|TargetTracked|UntrackOnClose|UntrackOnMurder|OpenServiceTest|DynamicQueryWithoutTracked|ServiceCrash|EtwFlush|TelemetryPeriod|FrameStoreRealtime|FrameStorePaced|FrameStoreBackpressured|FrameStorePlayback|LegacyBackpressured" ) function(pmon_normalize_test_data_path input_path output_variable)