From f189e7ae640362cc410a6ce71c9154958b07cef6 Mon Sep 17 00:00:00 2001 From: "Galvan, Mark" Date: Tue, 20 Oct 2026 14:05:59 -0700 Subject: [PATCH] Rework action pipe transport and serve the kernel UI over a private pipe Each action session now runs over a single duplex pipe instead of separate in and out pipes. Requests, responses and pushed events share that pipe and are demultiplexed by packet type and command token, with response timeouts and bounded handling of late responses. Clients and servers have an explicit lifecycle (Running, Stopping, Stopped): dispatches are admitted through a gate, shutdown waits for in-flight work and joins its runner rather than detaching it, and every session ends with a recorded reason before it is disposed. Server send policies (request-response, single peer, addressed, paired) are declared in the type; the server enforces a session cap and a handshake deadline, ends any connection that acts before OpenSession, and logs the system-reported client pid. Every pipe write has one deadline covering the wait for the write lock and the write itself: a write that stalls part way ends the session as PeerStalled, while one that times out before sending anything fails only that request. The kernel no longer publishes a connectable pipe for its UI; for each UI launch it creates a single-instance pipe pair, connects both ends itself, and passes the client end to PresentMonUI by handle inheritance, and the UI browser process owns that connection and relays page calls and kernel events to the renderer. The service log pipe and its command line options are removed, --p2c-act-name is replaced by --p2c-act-handle, and the packet header version is now 2, so builds from before this change cannot connect to it. --- IntelPresentMon/AppCef/CMakeLists.txt | 2 + IntelPresentMon/AppCef/CefNano.vcxproj | 4 + .../AppCef/source/DataBindAccessor.cpp | 10 - .../AppCef/source/NanoCefBrowserClient.cpp | 9 + .../AppCef/source/NanoCefBrowserClient.h | 8 + .../AppCef/source/NanoCefProcessHandler.cpp | 49 +- .../AppCef/source/NanoCefProcessHandler.h | 1 + .../AppCef/source/util/ActionClientServer.cpp | 4 +- .../AppCef/source/util/ActionClientServer.h | 3 +- .../AppCef/source/util/CliOptions.h | 5 +- .../source/util/IpcInvocationManager.cpp | 41 +- .../AppCef/source/util/IpcInvocationManager.h | 64 +- .../source/util/KernelActionRegistration.cpp | 9 +- .../AppCef/source/util/KernelLink.cpp | 59 ++ .../AppCef/source/util/KernelLink.h | 32 + .../AppCef/source/util/KernelRelay.cpp | 88 ++ .../AppCef/source/util/KernelRelay.h | 117 +++ .../AppCef/source/util/KernelWrapper.h | 11 +- .../AppCef/source/util/LogSetup.cpp | 2 +- .../AppCef/source/util/SignalManager.cpp | 36 +- .../AppCef/source/util/SignalManager.h | 13 +- .../AppCef/source/util/async/GetAppInfo.h | 10 +- .../source/util/cact/CefExecutionContext.h | 24 +- .../source/util/cact/HotkeyFiredAction.h | 7 +- .../source/util/cact/OverlayDiedAction.h | 7 +- .../util/cact/PresentmonInitFailedAction.h | 7 +- .../AppCef/source/util/cact/StalePidAction.h | 7 +- .../source/util/cact/TargetLostAction.h | 7 +- IntelPresentMon/AppCef/source/winmain.cpp | 33 +- .../CommonUtilities/pipe/CoroMutex.cpp | 66 +- .../CommonUtilities/pipe/CoroMutex.h | 41 +- .../CommonUtilities/pipe/ManualAsyncEvent.h | 31 +- IntelPresentMon/CommonUtilities/pipe/Pipe.cpp | 174 +++- IntelPresentMon/CommonUtilities/pipe/Pipe.h | 153 ++- .../CommonUtilities/pipe/SecurityMode.h | 2 - .../CommonUtilities/win/Privileges.cpp | 24 + .../CommonUtilities/win/Privileges.h | 3 + IntelPresentMon/Core/source/cli/CliOptions.h | 3 - .../Core/source/infra/LogSetup.cpp | 2 +- .../Core/source/win/HotkeyListener.cpp | 22 +- .../Core/source/win/HotkeyListener.h | 5 + .../Interprocess/source/act/ActionContext.h | 17 +- .../Interprocess/source/act/ActionRunner.h | 79 ++ .../source/act/ActionServerShutdown.h | 31 + .../Interprocess/source/act/AdmissionGate.h | 60 ++ .../Interprocess/source/act/AsyncAction.h | 14 +- .../source/act/CommandTokenAllocator.h | 37 + .../Interprocess/source/act/Lifecycle.h | 65 ++ .../Interprocess/source/act/Packet.h | 38 +- .../Interprocess/source/act/ResponseRouter.h | 97 ++ .../source/act/ServerSendPolicy.h | 47 + .../source/act/SessionEndReason.h | 45 + .../Interprocess/source/act/SessionLimits.h | 15 + .../source/act/SymmetricActionClient.h | 359 +++++-- .../source/act/SymmetricActionConnector.h | 512 +++++++--- .../source/act/SymmetricActionServer.h | 838 +++++++++++++--- .../Interprocess/source/act/Transfer.h | 127 ++- .../KernelProcess/KernelProcess.args.json | 4 - .../kact/KernelExecutionContext.h | 27 +- IntelPresentMon/KernelProcess/winmain.cpp | 120 ++- .../PresentMonAPI2Tests/ActionRunnerTests.cpp | 65 ++ .../PresentMonAPI2Tests/CMakeLists.txt | 8 + .../PresentMonAPI2Tests/MultiClientTests.cpp | 210 +++- .../PipeAcceptReliabilityTests.cpp | 1 + .../PresentMonAPI2Tests/PipePairTests.cpp | 308 ++++++ .../PipeSecurityRegressionTests.cpp | 1 + .../PresentMonAPI2Tests.vcxproj | 10 + .../PresentMonAPI2Tests.vcxproj.filters | 30 + .../PresentMonAPI2Tests/RawPipeClient.h | 86 ++ .../PresentMonAPI2Tests/TestCommands.h | 6 +- .../PresentMonAPI2Tests/TestProcess.h | 7 + .../TransportDemuxTests.cpp | 303 ++++++ .../TransportLifecycleTests.cpp | 677 +++++++++++++ .../TransportPairedTests.cpp | 179 ++++ .../TransportSessionTests.cpp | 298 ++++++ .../PresentMonAPI2Tests/TransportTestKit.cpp | 25 + .../PresentMonAPI2Tests/TransportTestKit.h | 893 ++++++++++++++++++ .../TransportWriteDeadlineTests.cpp | 166 ++++ .../PresentMonAPI2Tests/UiLaunchTests.cpp | 55 ++ .../PresentMonMiddleware/ActionClient.h | 4 +- .../MiddlewareExecutionContext.h | 8 +- .../SharedServicePipeVerification.h | 1 + .../ActionExecutionContext.cpp | 73 +- .../ActionExecutionContext.h | 25 +- .../PresentMonService/ActionServer.cpp | 33 +- .../PresentMonService/ActionServer.h | 8 + .../PresentMonService/CliOptions.h | 2 - .../PresentMonService/GlobalIdentifiers.h | 1 - .../PresentMonService/LogSetup.cpp | 15 - .../PresentMonService/PMMainThread.cpp | 13 +- .../PresentMonService.args.json | 4 - .../PresentMonService/acts/OpenSession.h | 4 +- .../PresentMonService/testing/TestControl.cpp | 11 +- .../PresentMonService/testing/TestControl.h | 8 +- IntelPresentMon/UnitTests/CMakeLists.txt | 1 + IntelPresentMon/UnitTests/HotkeyTests.cpp | 41 + IntelPresentMon/UnitTests/UnitTests.vcxproj | 1 + .../UnitTests/UnitTests.vcxproj.filters | 1 + PresentMonService.args.json | 4 - Tests/ensure-aux.ps1 | 1 + cmake/PresentMonCTest.cmake | 2 +- 101 files changed, 6555 insertions(+), 761 deletions(-) create mode 100644 IntelPresentMon/AppCef/source/util/KernelLink.cpp create mode 100644 IntelPresentMon/AppCef/source/util/KernelLink.h create mode 100644 IntelPresentMon/AppCef/source/util/KernelRelay.cpp create mode 100644 IntelPresentMon/AppCef/source/util/KernelRelay.h create mode 100644 IntelPresentMon/Interprocess/source/act/ActionRunner.h create mode 100644 IntelPresentMon/Interprocess/source/act/ActionServerShutdown.h create mode 100644 IntelPresentMon/Interprocess/source/act/AdmissionGate.h create mode 100644 IntelPresentMon/Interprocess/source/act/CommandTokenAllocator.h create mode 100644 IntelPresentMon/Interprocess/source/act/Lifecycle.h create mode 100644 IntelPresentMon/Interprocess/source/act/ResponseRouter.h create mode 100644 IntelPresentMon/Interprocess/source/act/ServerSendPolicy.h create mode 100644 IntelPresentMon/Interprocess/source/act/SessionEndReason.h create mode 100644 IntelPresentMon/Interprocess/source/act/SessionLimits.h create mode 100644 IntelPresentMon/PresentMonAPI2Tests/ActionRunnerTests.cpp create mode 100644 IntelPresentMon/PresentMonAPI2Tests/PipePairTests.cpp create mode 100644 IntelPresentMon/PresentMonAPI2Tests/RawPipeClient.h create mode 100644 IntelPresentMon/PresentMonAPI2Tests/TransportDemuxTests.cpp create mode 100644 IntelPresentMon/PresentMonAPI2Tests/TransportLifecycleTests.cpp create mode 100644 IntelPresentMon/PresentMonAPI2Tests/TransportPairedTests.cpp create mode 100644 IntelPresentMon/PresentMonAPI2Tests/TransportSessionTests.cpp create mode 100644 IntelPresentMon/PresentMonAPI2Tests/TransportTestKit.cpp create mode 100644 IntelPresentMon/PresentMonAPI2Tests/TransportTestKit.h create mode 100644 IntelPresentMon/PresentMonAPI2Tests/TransportWriteDeadlineTests.cpp create mode 100644 IntelPresentMon/UnitTests/HotkeyTests.cpp 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)