From d4f6a4a268e85da0192d6a53f0917ad657d05957 Mon Sep 17 00:00:00 2001 From: Aiden <68633820+awils27@users.noreply.github.com> Date: Mon, 11 May 2026 16:18:34 +1000 Subject: [PATCH] phase 2 progress --- .../control/ControlServices.cpp | 26 +-- .../control/ControlServices.h | 10 - .../control/RuntimeServices.cpp | 11 - .../control/RuntimeServices.h | 1 - .../gl/OpenGLComposite.cpp | 21 +- .../gl/OpenGLComposite.h | 1 - .../gl/RuntimeUpdateController.cpp | 17 +- .../gl/RuntimeUpdateController.h | 2 + .../coordination/RuntimeCoordinator.cpp | 34 +++ .../runtime/coordination/RuntimeCoordinator.h | 2 + .../events/RuntimeEventCoalescingQueue.h | 11 + .../runtime/events/RuntimeEventDispatcher.h | 55 ++++- .../runtime/events/RuntimeEventQueue.h | 1 + docs/ARCHITECTURE_RESILIENCE_REVIEW.md | 17 +- docs/PHASE_2_INTERNAL_EVENT_MODEL_DESIGN.md | 179 ++++++++++----- tests/RuntimeEventTypeTests.cpp | 215 +++++++++++++++++- 16 files changed, 463 insertions(+), 140 deletions(-) diff --git a/apps/LoopThroughWithOpenGLCompositing/control/ControlServices.cpp b/apps/LoopThroughWithOpenGLCompositing/control/ControlServices.cpp index 22134b5..1104e4f 100644 --- a/apps/LoopThroughWithOpenGLCompositing/control/ControlServices.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/control/ControlServices.cpp @@ -166,17 +166,6 @@ void ControlServices::ConsumeCompletedOscCommits(std::vector completedCommits.swap(mCompletedOscCommits); } -void ControlServices::ConsumeRuntimeCoordinatorResults(std::vector& results) -{ - results.clear(); - - std::lock_guard lock(mRuntimeCoordinatorResultMutex); - if (mRuntimeCoordinatorResults.empty()) - return; - - results.swap(mRuntimeCoordinatorResults); -} - void ControlServices::StartPolling(RuntimeCoordinator& runtimeCoordinator) { if (mPollRunning.exchange(true)) @@ -218,7 +207,6 @@ void ControlServices::PollLoop(RuntimeCoordinator& runtimeCoordinator) completedCommit.generation = entry.second.generation; std::lock_guard lock(mCompletedOscCommitMutex); mCompletedOscCommits.push_back(std::move(completedCommit)); - QueueRuntimeCoordinatorResult(result); } else if (!result.errorMessage.empty()) { @@ -228,8 +216,8 @@ void ControlServices::PollLoop(RuntimeCoordinator& runtimeCoordinator) bool registryChanged = false; const RuntimeCoordinatorResult pollResult = runtimeCoordinator.PollRuntimeStoreChanges(registryChanged); - if (pollResult.runtimeStateBroadcastRequired || pollResult.shaderBuildRequested || pollResult.compileStatusChanged) - QueueRuntimeCoordinatorResult(pollResult, pollResult.compileStatusChanged && !pollResult.compileStatusSucceeded && !pollResult.compileStatusMessage.empty()); + if (pollResult.compileStatusChanged && !pollResult.compileStatusSucceeded && !pollResult.compileStatusMessage.empty()) + OutputDebugStringA(("Runtime poll failed: " + pollResult.compileStatusMessage + "\n").c_str()); std::unique_lock wakeLock(mPollWakeMutex); mPollWakeCondition.wait_for(wakeLock, kCompatibilityPollFallbackInterval, [this]() { @@ -248,16 +236,6 @@ void ControlServices::WakePolling() mPollWakeCondition.notify_one(); } -void ControlServices::QueueRuntimeCoordinatorResult(const RuntimeCoordinatorResult& result, bool failed) -{ - RuntimeCoordinatorServiceResult serviceResult; - serviceResult.result = result; - serviceResult.failed = failed; - - std::lock_guard lock(mRuntimeCoordinatorResultMutex); - mRuntimeCoordinatorResults.push_back(std::move(serviceResult)); -} - void ControlServices::PublishRuntimeStateBroadcastRequested(const std::string& reason) { try diff --git a/apps/LoopThroughWithOpenGLCompositing/control/ControlServices.h b/apps/LoopThroughWithOpenGLCompositing/control/ControlServices.h index 1657653..d1704d5 100644 --- a/apps/LoopThroughWithOpenGLCompositing/control/ControlServices.h +++ b/apps/LoopThroughWithOpenGLCompositing/control/ControlServices.h @@ -20,12 +20,6 @@ class OscServer; class RuntimeEventDispatcher; class RuntimeStore; -struct RuntimeCoordinatorServiceResult -{ - RuntimeCoordinatorResult result; - bool failed = false; -}; - class ControlServices { public: @@ -56,7 +50,6 @@ public: bool QueueOscCommit(const std::string& routeKey, const std::string& layerKey, const std::string& parameterKey, const JsonValue& value, uint64_t generation, std::string& error); void ClearOscState(); void ConsumeCompletedOscCommits(std::vector& completedCommits); - void ConsumeRuntimeCoordinatorResults(std::vector& results); private: struct PendingOscUpdate @@ -79,7 +72,6 @@ private: void StopPolling(); void PollLoop(RuntimeCoordinator& runtimeCoordinator); void WakePolling(); - void QueueRuntimeCoordinatorResult(const RuntimeCoordinatorResult& result, bool failed = false); void PublishRuntimeStateBroadcastRequested(const std::string& reason); void PublishOscValueReceived(const PendingOscUpdate& update, const std::string& routeKey); void PublishOscCommitRequested(const PendingOscCommit& commit); @@ -92,8 +84,6 @@ private: std::mutex mPollWakeMutex; std::condition_variable mPollWakeCondition; bool mPollWakeRequested = false; - std::mutex mRuntimeCoordinatorResultMutex; - std::vector mRuntimeCoordinatorResults; std::mutex mPendingOscMutex; std::map mPendingOscUpdates; std::mutex mPendingOscCommitMutex; diff --git a/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.cpp b/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.cpp index c85eb3c..535d074 100644 --- a/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.cpp @@ -78,14 +78,3 @@ void RuntimeServices::ConsumeCompletedOscCommits(std::vector mControlServices->ConsumeCompletedOscCommits(completedCommits); } - -void RuntimeServices::ConsumeRuntimeCoordinatorResults(std::vector& results) -{ - if (!mControlServices) - { - results.clear(); - return; - } - - mControlServices->ConsumeRuntimeCoordinatorResults(results); -} diff --git a/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.h b/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.h index 6154acc..8480143 100644 --- a/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.h +++ b/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.h @@ -28,7 +28,6 @@ public: bool QueueOscCommit(const std::string& routeKey, const std::string& layerKey, const std::string& parameterKey, const JsonValue& value, uint64_t generation, std::string& error); void ClearOscState(); void ConsumeCompletedOscCommits(std::vector& completedCommits); - void ConsumeRuntimeCoordinatorResults(std::vector& results); private: std::unique_ptr mControlServices; diff --git a/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.cpp b/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.cpp index 3e9f67c..b7c4003 100644 --- a/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.cpp @@ -300,7 +300,7 @@ bool OpenGLComposite::RequestScreenshot(std::string& error) void OpenGLComposite::renderEffect() { - if (mRuntimeUpdateController && ProcessRuntimeServiceResults()) + if (mRuntimeUpdateController) mRuntimeUpdateController->ProcessRuntimeWork(); std::vector appliedOscUpdates; std::vector completedOscCommits; @@ -369,25 +369,6 @@ void OpenGLComposite::renderEffect() historyCap); } -bool OpenGLComposite::ProcessRuntimeServiceResults() -{ - if (!mRuntimeServices || !mRuntimeUpdateController) - return true; - - bool shaderBuildRequested = false; - std::vector serviceResults; - mRuntimeServices->ConsumeRuntimeCoordinatorResults(serviceResults); - for (const RuntimeCoordinatorServiceResult& serviceResult : serviceResults) - { - shaderBuildRequested = shaderBuildRequested || serviceResult.result.shaderBuildRequested; - mRuntimeUpdateController->ApplyRuntimeCoordinatorResult(serviceResult.result); - if (serviceResult.failed) - return false; - } - - return !shaderBuildRequested; -} - void OpenGLComposite::ProcessScreenshotRequest() { if (!mScreenshotRequested.exchange(false)) diff --git a/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.h b/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.h index ea4400e..7bd86b8 100644 --- a/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.h +++ b/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.h @@ -87,7 +87,6 @@ private: bool InitOpenGLState(); void renderEffect(); - bool ProcessRuntimeServiceResults(); void ProcessScreenshotRequest(); std::filesystem::path BuildScreenshotPath() const; }; diff --git a/apps/LoopThroughWithOpenGLCompositing/gl/RuntimeUpdateController.cpp b/apps/LoopThroughWithOpenGLCompositing/gl/RuntimeUpdateController.cpp index aa37bf7..f19a933 100644 --- a/apps/LoopThroughWithOpenGLCompositing/gl/RuntimeUpdateController.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/gl/RuntimeUpdateController.cpp @@ -45,6 +45,9 @@ RuntimeUpdateController::RuntimeUpdateController( mRuntimeEventDispatcher.Subscribe( RuntimeEventType::RuntimeStateBroadcastRequested, [this](const RuntimeEvent& event) { HandleRuntimeStateBroadcastRequested(event); }); + mRuntimeEventDispatcher.Subscribe( + RuntimeEventType::RuntimeReloadRequested, + [this](const RuntimeEvent& event) { HandleRuntimeReloadRequested(event); }); mRuntimeEventDispatcher.Subscribe( RuntimeEventType::ShaderBuildRequested, [this](const RuntimeEvent& event) { HandleShaderBuildRequested(event); }); @@ -137,6 +140,15 @@ void RuntimeUpdateController::HandleRuntimeStateBroadcastRequested(const Runtime mRuntimeServices.BroadcastState(); } +void RuntimeUpdateController::HandleRuntimeReloadRequested(const RuntimeEvent& event) +{ + const RuntimeReloadRequestedEvent* payload = std::get_if(&event.payload); + if (!payload) + return; + + mRuntimeStore.ClearReloadRequest(); +} + void RuntimeUpdateController::HandleShaderBuildRequested(const RuntimeEvent& event) { const ShaderBuildEvent* payload = std::get_if(&event.payload); @@ -317,16 +329,19 @@ void RuntimeUpdateController::PublishRuntimeEventHealthObservations(const Runtim { const RuntimeEventQueueMetrics queueMetrics = mRuntimeEventDispatcher.GetQueueMetrics(); if (queueMetrics.depth != mLastReportedRuntimeEventQueueDepth || - queueMetrics.droppedCount != mLastReportedRuntimeEventDroppedCount) + queueMetrics.droppedCount != mLastReportedRuntimeEventDroppedCount || + queueMetrics.coalescedCount != mLastReportedRuntimeEventCoalescedCount) { QueueDepthChangedEvent queueDepth; queueDepth.queueName = "runtime-events"; queueDepth.depth = queueMetrics.depth; queueDepth.capacity = queueMetrics.capacity; queueDepth.droppedCount = queueMetrics.droppedCount; + queueDepth.coalescedCount = queueMetrics.coalescedCount; mRuntimeEventDispatcher.PublishPayload(queueDepth, "HealthTelemetry"); mLastReportedRuntimeEventQueueDepth = queueMetrics.depth; mLastReportedRuntimeEventDroppedCount = queueMetrics.droppedCount; + mLastReportedRuntimeEventCoalescedCount = queueMetrics.coalescedCount; } if (result.handlerInvocations == 0 && result.handlerFailures == 0) diff --git a/apps/LoopThroughWithOpenGLCompositing/gl/RuntimeUpdateController.h b/apps/LoopThroughWithOpenGLCompositing/gl/RuntimeUpdateController.h index 93cfc6b..e8be6bb 100644 --- a/apps/LoopThroughWithOpenGLCompositing/gl/RuntimeUpdateController.h +++ b/apps/LoopThroughWithOpenGLCompositing/gl/RuntimeUpdateController.h @@ -35,6 +35,7 @@ public: private: void HandleRuntimeStateBroadcastRequested(const RuntimeEvent& event); + void HandleRuntimeReloadRequested(const RuntimeEvent& event); void HandleShaderBuildRequested(const RuntimeEvent& event); void HandleShaderBuildPrepared(const RuntimeEvent& event); void HandleShaderBuildFailed(const RuntimeEvent& event); @@ -64,4 +65,5 @@ private: std::size_t mPendingCoordinatorRenderResetEvents = 0; std::size_t mLastReportedRuntimeEventQueueDepth = static_cast(-1); std::size_t mLastReportedRuntimeEventDroppedCount = static_cast(-1); + std::size_t mLastReportedRuntimeEventCoalescedCount = static_cast(-1); }; diff --git a/apps/LoopThroughWithOpenGLCompositing/runtime/coordination/RuntimeCoordinator.cpp b/apps/LoopThroughWithOpenGLCompositing/runtime/coordination/RuntimeCoordinator.cpp index 47f91cc..862ff7b 100644 --- a/apps/LoopThroughWithOpenGLCompositing/runtime/coordination/RuntimeCoordinator.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/runtime/coordination/RuntimeCoordinator.cpp @@ -246,6 +246,7 @@ RuntimeCoordinatorResult RuntimeCoordinator::LoadStackPreset(const std::string& RuntimeCoordinatorResult RuntimeCoordinator::RequestShaderReload(bool preserveFeedbackState) { std::lock_guard lock(mMutex); + PublishManualReloadRequested(preserveFeedbackState, "RequestShaderReload"); RuntimeCoordinatorResult result = BuildQueuedReloadResult(preserveFeedbackState); PublishCoordinatorFollowUpEvents("RequestShaderReload", result); return result; @@ -266,6 +267,7 @@ RuntimeCoordinatorResult RuntimeCoordinator::PollRuntimeStoreChanges(bool& regis if (reloadRequested) { + PublishFileChangeDetected("PollRuntimeStoreChanges", registryChanged, reloadRequested); RuntimeCoordinatorResult result = BuildQueuedReloadResult(false); PublishCoordinatorFollowUpEvents("PollRuntimeStoreChanges", result); return result; @@ -273,6 +275,7 @@ RuntimeCoordinatorResult RuntimeCoordinator::PollRuntimeStoreChanges(bool& regis if (registryChanged) { + PublishFileChangeDetected("PollRuntimeStoreChanges", registryChanged, reloadRequested); RuntimeCoordinatorResult result = BuildAcceptedNoReloadResult(); PublishCoordinatorFollowUpEvents("PollRuntimeStoreChanges", result); return result; @@ -332,6 +335,7 @@ RuntimeCoordinatorResult RuntimeCoordinator::HandlePreparedShaderBuildSuccess() RuntimeCoordinatorResult RuntimeCoordinator::HandleRuntimeReloadRequest() { std::lock_guard lock(mMutex); + PublishManualReloadRequested(false, "HandleRuntimeReloadRequest"); RuntimeCoordinatorResult result = BuildQueuedReloadResult(false); PublishCoordinatorFollowUpEvents("HandleRuntimeReloadRequest", result); return result; @@ -493,6 +497,36 @@ RuntimeCoordinatorResult RuntimeCoordinator::BuildAcceptedNoReloadResult() const return result; } +void RuntimeCoordinator::PublishFileChangeDetected(const std::string& reason, bool registryChanged, bool reloadRequested) const +{ + try + { + FileChangeDetectedEvent event; + event.path = reason; + event.shaderPackageCandidate = registryChanged || reloadRequested; + event.runtimeConfigCandidate = false; + event.presetCandidate = false; + mRuntimeEventDispatcher.PublishPayload(event, "RuntimeCoordinator"); + } + catch (...) + { + } +} + +void RuntimeCoordinator::PublishManualReloadRequested(bool preserveFeedbackState, const std::string& reason) const +{ + try + { + ManualReloadRequestedEvent event; + event.preserveFeedbackState = preserveFeedbackState; + event.reason = reason; + mRuntimeEventDispatcher.PublishPayload(event, "RuntimeCoordinator"); + } + catch (...) + { + } +} + void RuntimeCoordinator::PublishCoordinatorResult(const std::string& action, const RuntimeCoordinatorResult& result) const { try diff --git a/apps/LoopThroughWithOpenGLCompositing/runtime/coordination/RuntimeCoordinator.h b/apps/LoopThroughWithOpenGLCompositing/runtime/coordination/RuntimeCoordinator.h index 292013e..ad2bd4e 100644 --- a/apps/LoopThroughWithOpenGLCompositing/runtime/coordination/RuntimeCoordinator.h +++ b/apps/LoopThroughWithOpenGLCompositing/runtime/coordination/RuntimeCoordinator.h @@ -93,6 +93,8 @@ private: RuntimeCoordinatorResult ApplyStoreMutation(bool succeeded, const std::string& errorMessage, bool reloadRequired, bool preserveFeedbackState, bool persistenceRequested); RuntimeCoordinatorResult BuildQueuedReloadResult(bool preserveFeedbackState); RuntimeCoordinatorResult BuildAcceptedNoReloadResult() const; + void PublishFileChangeDetected(const std::string& reason, bool registryChanged, bool reloadRequested) const; + void PublishManualReloadRequested(bool preserveFeedbackState, const std::string& reason) const; void PublishCoordinatorResult(const std::string& action, const RuntimeCoordinatorResult& result) const; void PublishCoordinatorFollowUpEvents(const std::string& action, const RuntimeCoordinatorResult& result) const; diff --git a/apps/LoopThroughWithOpenGLCompositing/runtime/events/RuntimeEventCoalescingQueue.h b/apps/LoopThroughWithOpenGLCompositing/runtime/events/RuntimeEventCoalescingQueue.h index 918cd39..47cbfa3 100644 --- a/apps/LoopThroughWithOpenGLCompositing/runtime/events/RuntimeEventCoalescingQueue.h +++ b/apps/LoopThroughWithOpenGLCompositing/runtime/events/RuntimeEventCoalescingQueue.h @@ -29,6 +29,17 @@ inline std::string RuntimeEventDefaultCoalescingKey(const RuntimeEvent& event) return std::string(RuntimeEventTypeName(event.type)) + ":" + payload->routeKey; if (const auto* payload = std::get_if(&event.payload)) return std::string(RuntimeEventTypeName(event.type)) + ":" + payload->path; + if (const auto* payload = std::get_if(&event.payload)) + return std::string(RuntimeEventTypeName(event.type)) + ":" + + std::to_string(payload->inputWidth) + "x" + + std::to_string(payload->inputHeight) + ":" + + (payload->preserveFeedbackState ? "preserve" : "reset"); + if (const auto* payload = std::get_if(&event.payload)) + return std::string(RuntimeEventTypeName(event.type)) + ":" + + std::to_string(payload->outputWidth) + "x" + + std::to_string(payload->outputHeight); + if (const auto* payload = std::get_if(&event.payload)) + return std::string(RuntimeEventTypeName(event.type)) + ":" + payload->subsystem + ":" + payload->metric; if (const auto* payload = std::get_if(&event.payload)) return std::string(RuntimeEventTypeName(event.type)) + ":" + payload->queueName; diff --git a/apps/LoopThroughWithOpenGLCompositing/runtime/events/RuntimeEventDispatcher.h b/apps/LoopThroughWithOpenGLCompositing/runtime/events/RuntimeEventDispatcher.h index 4f42322..e85e394 100644 --- a/apps/LoopThroughWithOpenGLCompositing/runtime/events/RuntimeEventDispatcher.h +++ b/apps/LoopThroughWithOpenGLCompositing/runtime/events/RuntimeEventDispatcher.h @@ -1,7 +1,9 @@ #pragma once +#include "RuntimeEventCoalescingQueue.h" #include "RuntimeEventQueue.h" +#include #include #include #include @@ -22,7 +24,8 @@ public: using Handler = std::function; explicit RuntimeEventDispatcher(std::size_t queueCapacity = 1024) : - mQueue(queueCapacity) + mQueue(queueCapacity), + mCoalescingQueue(queueCapacity) { } @@ -34,6 +37,9 @@ public: if (event.sequence == 0) event.sequence = mNextSequence.fetch_add(1); + if (ShouldCoalesce(event)) + return mCoalescingQueue.Push(std::move(event)); + return mQueue.Push(std::move(event)); } @@ -59,6 +65,7 @@ public: { const auto startedAt = std::chrono::steady_clock::now(); RuntimeEventDispatchResult result; + FlushCoalescedToFifo(maxEvents); std::vector events = mQueue.Drain(maxEvents); result.dispatchedEvents = events.size(); @@ -92,15 +99,56 @@ public: RuntimeEventQueueMetrics GetQueueMetrics(std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now()) const { - return mQueue.GetMetrics(now); + RuntimeEventQueueMetrics metrics = mQueue.GetMetrics(now); + const RuntimeEventCoalescingQueueMetrics coalescingMetrics = mCoalescingQueue.GetMetrics(now); + if (metrics.depth == 0) + metrics.oldestEventAgeMilliseconds = coalescingMetrics.oldestEventAgeMilliseconds; + else if (coalescingMetrics.depth > 0) + metrics.oldestEventAgeMilliseconds = (std::max)(metrics.oldestEventAgeMilliseconds, coalescingMetrics.oldestEventAgeMilliseconds); + metrics.depth += coalescingMetrics.depth; + metrics.capacity += coalescingMetrics.capacity; + metrics.droppedCount += coalescingMetrics.droppedCount; + metrics.coalescedCount = coalescingMetrics.coalescedCount; + return metrics; } std::size_t QueueDepth() const { - return mQueue.Depth(); + return mQueue.Depth() + mCoalescingQueue.Depth(); } private: + static bool ShouldCoalesce(const RuntimeEvent& event) + { + switch (event.type) + { + case RuntimeEventType::OscValueReceived: + case RuntimeEventType::OscCommitRequested: + case RuntimeEventType::RuntimeStateBroadcastRequested: + case RuntimeEventType::FileChangeDetected: + case RuntimeEventType::RuntimeReloadRequested: + case RuntimeEventType::ShaderBuildRequested: + case RuntimeEventType::RenderSnapshotPublishRequested: + case RuntimeEventType::TimingSampleRecorded: + case RuntimeEventType::QueueDepthChanged: + return true; + default: + return false; + } + } + + void FlushCoalescedToFifo(std::size_t maxEvents) + { + const std::size_t fifoDepth = mQueue.Depth(); + if (maxEvents != 0 && fifoDepth >= maxEvents) + return; + + const std::size_t flushLimit = maxEvents == 0 ? 0 : maxEvents - fifoDepth; + std::vector events = mCoalescingQueue.Drain(flushLimit); + for (RuntimeEvent& event : events) + mQueue.Push(std::move(event)); + } + std::vector HandlersFor(RuntimeEventType type) const { std::lock_guard lock(mHandlerMutex); @@ -114,6 +162,7 @@ private: } RuntimeEventQueue mQueue; + RuntimeEventCoalescingQueue mCoalescingQueue; std::atomic mNextSequence{ 1 }; mutable std::mutex mHandlerMutex; std::map> mHandlers; diff --git a/apps/LoopThroughWithOpenGLCompositing/runtime/events/RuntimeEventQueue.h b/apps/LoopThroughWithOpenGLCompositing/runtime/events/RuntimeEventQueue.h index 509bc0c..0b2deaa 100644 --- a/apps/LoopThroughWithOpenGLCompositing/runtime/events/RuntimeEventQueue.h +++ b/apps/LoopThroughWithOpenGLCompositing/runtime/events/RuntimeEventQueue.h @@ -13,6 +13,7 @@ struct RuntimeEventQueueMetrics std::size_t depth = 0; std::size_t capacity = 0; std::size_t droppedCount = 0; + std::size_t coalescedCount = 0; double oldestEventAgeMilliseconds = 0.0; }; diff --git a/docs/ARCHITECTURE_RESILIENCE_REVIEW.md b/docs/ARCHITECTURE_RESILIENCE_REVIEW.md index 569a60a..7fe5f62 100644 --- a/docs/ARCHITECTURE_RESILIENCE_REVIEW.md +++ b/docs/ARCHITECTURE_RESILIENCE_REVIEW.md @@ -5,7 +5,7 @@ This note summarizes the main architectural improvements that would make the app Phase checklist: - [x] Define subsystem boundaries and target architecture -- [ ] Introduce an internal event model +- [x] Introduce an internal event model - [x] Split `RuntimeHost` - [ ] Make the render thread the sole GL owner - [ ] Refactor live state layering into an explicit composition model @@ -16,7 +16,8 @@ Phase checklist: Checklist note: - The checked Phase 1 item means the subsystem vocabulary, dependency direction, state categories, design package, and runtime implementation foothold are in place. -- It does not mean the whole app is fully extracted. Eventing, sole-owner render threading, live-state layering, background persistence, backend lifecycle, and richer telemetry continue through later phases. +- The checked Phase 2 item means the internal event model substrate is complete enough for later phases: the typed event vocabulary, app-owned dispatcher, coalesced event pump, reload bridge events, production bridges, and pure event tests are in place. Remaining items in [PHASE_2_INTERNAL_EVENT_MODEL_DESIGN.md](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/docs/PHASE_2_INTERNAL_EVENT_MODEL_DESIGN.md) are narrow follow-ups, mainly completion/failure observations and later replacement of the runtime-store poll fallback with real file-watch events. +- It does not mean the whole app is fully extracted. Sole-owner render threading, live-state layering, background persistence, backend lifecycle, and richer telemetry continue through later phases. ## Timing Review @@ -26,7 +27,7 @@ The recent OSC work removed several control-path stalls, but the app still has a - output buffering and preroll are now larger, but the buffering model is still static and only loosely related to actual render cost - GPU readback is partly asynchronous, but the fallback path still returns to synchronous readback on any miss - preview presentation is still tied to the playout render path -- background service timing still relies on coarse polling sleeps +- background service timing is partially event-driven; runtime-store scanning still uses a bounded compatibility poll fallback Those points are important because they affect not just average performance, but how the app behaves under brief spikes, device jitter, or load bursts. @@ -337,19 +338,19 @@ Recommended direction: - consider deeper readback buffering or a true stale-frame reuse policy instead of immediate synchronous fallback - separate "freshest possible frame" policy from "never miss output deadline" policy and make that tradeoff explicit -### 8c. Background control and file-watch timing are still coarse +### 8c. Background control and file-watch timing are partially event-driven -`RuntimeServices::PollLoop()` currently uses a `25 x Sleep(10)` loop, which gives it a coarse `~250 ms` cadence for file-watch polling and deferred OSC commit work. +`ControlServices::PollLoop()` now uses a condition-variable wakeup for queued OSC commit work and a fallback timer for compatibility polling. That removes the old fixed `25 x Sleep(10)` cadence as the default OSC commit timing model, but file-watch/runtime-store refresh work still relies on a compatibility poll path. Relevant code: -- [RuntimeServices.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.cpp:245) +- [ControlServices.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/ControlServices.cpp:217) -That is acceptable for non-critical background work, but it is still too blunt to be the long-term timing model for coordination-heavy runtime services. +That is acceptable as transitional non-critical background work. The Phase 2 bridge now publishes typed reload/file-change events when changes are detected; a later file-watch implementation can replace scanning as the source. Recommended direction: -- replace coarse sleep polling with waitable events or condition-variable driven wakeups where practical +- replace runtime-store scanning with true file-watch events when practical - isolate truly background work from latency-sensitive control reconciliation - add separate metrics for queue age, not just queue depth diff --git a/docs/PHASE_2_INTERNAL_EVENT_MODEL_DESIGN.md b/docs/PHASE_2_INTERNAL_EVENT_MODEL_DESIGN.md index 716f2ce..eba3dac 100644 --- a/docs/PHASE_2_INTERNAL_EVENT_MODEL_DESIGN.md +++ b/docs/PHASE_2_INTERNAL_EVENT_MODEL_DESIGN.md @@ -6,25 +6,31 @@ Phase 1 established the subsystem vocabulary and moved the runtime path behind c ## Status -- Phase 2 design package: proposed. -- Phase 2 implementation: not started. +- Phase 2 design package: accepted. +- Phase 2 implementation: substantially complete for the coordination substrate. +- Current alignment: the typed event substrate, app-owned dispatcher, coalesced app pump, reload bridge events, production bridges, and event behavior tests are in place. Remaining items are narrow follow-ups rather than foundation work. -The current repo already has useful footholds: +The current repo now has concrete Phase 2 implementation footholds: -- `ControlServices` owns OSC/web/file-watch ingress and queues service-side work. -- `RuntimeCoordinator` owns mutation validation, classification, and coordinator result policy. -- `RuntimeUpdateController` applies coordinator outcomes and bridges toward render, shader builds, broadcasts, and backend state. -- `RuntimeSnapshotProvider` publishes render-facing snapshots. -- `HealthTelemetry` owns status/timing snapshots. +- `RuntimeEventType`, typed payload structs, `RuntimeEvent`, `RuntimeEventQueue`, `RuntimeEventDispatcher`, and `RuntimeEventCoalescingQueue` define the event substrate. +- `OpenGLComposite` owns one app-level `RuntimeEventDispatcher` and passes it into `RuntimeServices`, `RuntimeCoordinator`, `RuntimeUpdateController`, `RuntimeSnapshotProvider`, `ShaderBuildQueue`, and `VideoBackend`. +- `ControlServices` publishes typed OSC and runtime-state broadcast events and uses condition-variable wakeups with a fallback compatibility timer. +- `RuntimeCoordinator` publishes accepted, rejected, state-changed, persistence, reload, shader-build, and compile-status follow-up events. +- `RuntimeUpdateController` subscribes to event families for broadcast, shader build, compile status, render reset, and dispatcher health observations. +- `RuntimeSnapshotProvider` publishes render snapshot request/published events. +- `ShaderBuildQueue` and `RuntimeUpdateController` publish shader build lifecycle events with generation matching. +- `VideoBackend` publishes backend observation events and timing samples. +- `HealthTelemetry` receives dispatcher metrics directly and the event vocabulary now includes health observation events. +- Tests cover event type stability, payload mapping, FIFO dispatch, coalescing infrastructure, app-level coalesced broadcast/build behavior, handler failures, mutation follow-up behavior, reload bridge behavior, and shader-build generation behavior. -Those are good boundaries. The Phase 2 job is to stop using "poll, drain, then interpret side effects" as the main coordination style between them. +The implementation is now established in the repo. The remaining Phase 2 follow-up work is small: add completion/failure observations where useful and keep the runtime-store poll fallback explicitly transitional until a later file-watch implementation replaces it. ## Why Phase 2 Exists -The resilience review calls out three timing and ownership problems that an event model can directly improve: +The resilience review originally called out three timing and ownership problems that an event model could directly improve: -- background service timing still relies on coarse sleeps and polling -- control, reload, persistence, and render-update work still travel through mixed shared state and result queues +- background service timing relied on coarse sleeps and polling +- control, reload, persistence, and render-update work traveled through mixed shared state and result queues - later render/backend refactors need a stable coordination model before they move more work across threads The goal is not to make the app fully asynchronous in one pass. It is to introduce typed internal events so each subsystem can publish what happened without knowing who will react or how many downstream effects are needed. @@ -56,15 +62,24 @@ Those are later phases. Phase 2 provides the coordination substrate they can bui ## Current Coordination Shape -The current runtime is much cleaner than before Phase 1, but coordination is still mostly pull-based: +The current runtime is much cleaner than before Phase 1, and Phase 2 has moved the main coordination model toward typed publication and app-owned dispatch: -- `ControlServices::PollLoop(...)` drains pending OSC commits, polls runtime file changes, queues `RuntimeCoordinatorResult` objects, then sleeps. -- `RuntimeUpdateController::ProcessRuntimeWork()` consumes queued coordinator results, applies them, and then checks whether a prepared shader build is ready. -- `RuntimeCoordinatorResult` carries many downstream effects: shader build request, compile status update, transient OSC clear, runtime-state broadcast, committed-state mode, render reset scope. -- shader-build readiness is polled from the app update path. -- runtime-state broadcasts are requested by direct calls rather than by an event publication contract. +- `ControlServices` publishes OSC value, OSC commit, and runtime-state broadcast events. +- `ControlServices::PollLoop(...)` is wakeup-driven for queued OSC commit work, with a bounded fallback timer for compatibility polling. +- `RuntimeCoordinator` still returns `RuntimeCoordinatorResult` for synchronous callers, but also publishes accepted/rejected/follow-up events. +- `RuntimeUpdateController` subscribes to event families and applies many effects from events rather than only from drained result objects. +- shader-build request, readiness, failure, and application are represented by typed events. +- render snapshot publication and backend observations are represented by typed events. +- dispatcher queue metrics and handler failures feed telemetry and health observation events. -This works, but it keeps timing behavior implicit. Phase 2 should make those transitions visible as typed events. +There is still transitional bridge-state: + +- `ControlServices` still exposes completed OSC commit notifications for render overlay settlement. +- `RuntimeEventCoalescingQueue` is now wired into the app-owned dispatcher for latest-value event types. +- `FileChangeDetected` and `ManualReloadRequested` are now published as reload ingress bridge events before coordinator reload follow-ups. +- runtime-state broadcast completion/failure events are still a target, not current behavior. + +That means Phase 2 is complete enough as the coordination substrate for later phases. The remaining items are refinement work and should not block moving to render ownership, live-state layering, or persistence work. ## Event Model Principles @@ -289,23 +304,24 @@ Phase 2 does not need a large framework. A small app-owned dispatcher is enough. Suggested components: -- `RuntimeEventBus` +- `RuntimeEventDispatcher` - owns queues - assigns sequence numbers - exposes `Publish(...)` - - exposes `Drain(...)` or `DispatchPending(...)` -- `RuntimeEventHandler` + - exposes `DispatchPending(...)` +- event handlers - narrow handler interface or function callback - registered by subsystem/composition root - `RuntimeEventQueue` - bounded FIFO for ordinary events - - coalesced map for latest-value events such as high-rate OSC -- `RuntimeEventMetrics` +- `RuntimeEventCoalescingQueue` + - bounded keyed latest-value queue for flows such as high-rate OSC, broadcast requests, file/reload bursts, and queue-depth telemetry +- queue and dispatch metrics - queue depth - oldest event age - dropped/coalesced counts -Initial implementation can be single-process and mostly single-dispatch-thread. The important part is that event publication and event handling become explicit. +Initial implementation is single-process and mostly single-dispatch-thread. The important part is that event publication and event handling are explicit. ### Dispatcher Ownership Decision @@ -320,6 +336,7 @@ References: - `RuntimeServices` receives the dispatcher and passes it to `ControlServices`. - `RuntimeCoordinator` receives the dispatcher so coordinator outcomes can become explicit events. - `RuntimeUpdateController` receives the dispatcher so it can become the first effect/apply handler. +- `RuntimeSnapshotProvider`, `ShaderBuildQueue`, and `VideoBackend` receive the dispatcher so snapshot, shader lifecycle, and backend observation events are visible. This is intentionally a composition-root dependency, not a new subsystem dependency. Subsystems should not construct their own dispatchers, and future tests should use `RuntimeEventTestHarness` rather than creating ad hoc event plumbing. @@ -372,27 +389,27 @@ This section is the implementation rulebook for converting existing direct calls | `coalesced-latest` | only the latest value per key matters | bounded coalescing queue | handler consumes the latest event and telemetry records collapsed count | | `sync-command-with-event` | caller needs an immediate success/error result | direct owner call plus follow-up event publication | handler must not be required for the caller's response | | `observation-only` | event is telemetry/diagnostic and must not drive core behavior | FIFO or coalesced depending on rate | handler failure must never block app behavior | -| `compatibility-poll` | source cannot yet publish an event directly | temporary poll adapter publishes typed events | poll interval should shrink or become wakeup-driven over Phase 2 | +| `compatibility-poll` | source cannot yet publish an event directly | temporary poll adapter publishes typed events | poll interval is wakeup-driven with a fallback timer until a later file-watch implementation replaces it | ### Current Bridge Decisions -| Current flow | First Phase 2 bridge | Event(s) | Queue policy | +| Current flow | Phase 2 bridge | Event(s) | Current status | | --- | --- | --- | --- | -| OSC latest-value updates | `ControlServices` ingress bridge | `OscValueReceived`, optional `OscValueCoalesced` | `coalesced-latest` by route key | -| OSC commit after settle | `ControlServices -> RuntimeCoordinator` bridge | `OscCommitRequested`, then `RuntimeMutationAccepted` or `RuntimeMutationRejected` | commit request `coalesced-latest` by route key; mutation result `fifo-fact` | -| HTTP/UI mutation needing response | direct call into `RuntimeCoordinator` | `RuntimeMutationAccepted` or `RuntimeMutationRejected` after the synchronous response path | `sync-command-with-event` | -| runtime-state broadcast request | presentation/broadcast bridge | `RuntimeStatePresentationChanged`, `RuntimeStateBroadcastRequested` | `coalesced-latest` by event type or reason family | -| manual reload button | control ingress bridge | `ManualReloadRequested`, then `RuntimeReloadRequested` | `fifo-fact` for manual request; reload execution may coalesce | -| file watcher changes | file-watch bridge | `FileChangeDetected`, then `RuntimeReloadRequested` | `coalesced-latest` by path, then coalesced reload request | -| runtime store poll fallback | compatibility poll adapter | `ShaderPackagesChanged`, `RuntimeReloadRequested`, or warning event | `compatibility-poll` until file events fully replace polling | -| shader build request | runtime/render bridge | `ShaderBuildRequested` | `coalesced-latest` by input dimensions and preserve-feedback flag | -| shader build ready/failure/apply | shader build lifecycle bridge | `ShaderBuildPrepared`, `ShaderBuildFailed`, `ShaderBuildApplied`, `CompileStatusChanged` | `fifo-fact` | -| render snapshot publication | snapshot bridge | `RenderSnapshotPublishRequested`, `RenderSnapshotPublished` | request may coalesce by output dimensions; published event is `fifo-fact` | -| render reset request/application | render bridge | `RenderResetRequested`, `RenderResetApplied` | `fifo-fact` | -| input signal changes | backend observation bridge | `InputSignalChanged` | `coalesced-latest` by signal lane | -| output late/dropped/completed frames | backend timing bridge | `OutputFrameCompleted`, `OutputLateFrameDetected`, `OutputDroppedFrameDetected` | late/dropped `fifo-fact`; high-rate completed frames may become `observation-only` coalesced metrics | -| warnings and recovery | telemetry bridge | `SubsystemWarningRaised`, `SubsystemWarningCleared`, `SubsystemRecovered` | `fifo-fact` for lifecycle transitions | -| queue depth/timing samples | telemetry metrics bridge | `QueueDepthChanged`, `TimingSampleRecorded` | `coalesced-latest` by metric key | +| OSC latest-value updates | `ControlServices` ingress bridge | `OscValueReceived`, optional `OscValueCoalesced` | Event publication exists; source-side pending map and app-level dispatcher coalescing both provide latest-value behavior. | +| OSC commit after settle | `ControlServices -> RuntimeCoordinator` bridge | `OscCommitRequested`, then `RuntimeMutationAccepted` or `RuntimeMutationRejected` | Event publication exists. Coordinator follow-up work now reaches the app path through events rather than a service-result queue. | +| HTTP/UI mutation needing response | direct call into `RuntimeCoordinator` | `RuntimeMutationAccepted` or `RuntimeMutationRejected` after the synchronous response path | Implemented as `sync-command-with-event`; synchronous response remains supported. | +| runtime-state broadcast request | presentation/broadcast bridge | `RuntimeStatePresentationChanged`, `RuntimeStateBroadcastRequested` | Request event exists, is handled, and is coalesced by the app dispatcher. Completion/failure events remain follow-ups. | +| manual reload button | control ingress bridge | `ManualReloadRequested`, then `RuntimeReloadRequested` | Ingress and follow-up events exist and are covered by tests. | +| file watcher changes | file-watch bridge | `FileChangeDetected`, then `RuntimeReloadRequested` | Poll fallback remains, but detected changes now publish ingress and follow-up events and are covered by tests. | +| runtime store poll fallback | compatibility poll adapter | `FileChangeDetected`, `RuntimeReloadRequested`, or warning/compile-status event | Still present by design as a transitional bridge with a condition-variable fallback timer. Detected changes publish ingress and follow-up events. | +| shader build request | runtime/render bridge | `ShaderBuildRequested` | Event publication, handler, and app dispatcher coalescing exist. | +| shader build ready/failure/apply | shader build lifecycle bridge | `ShaderBuildPrepared`, `ShaderBuildFailed`, `ShaderBuildApplied`, `CompileStatusChanged` | Implemented with generation matching. | +| render snapshot publication | snapshot bridge | `RenderSnapshotPublishRequested`, `RenderSnapshotPublished` | Implemented. Publish requests are coalesced by output dimensions in the app dispatcher. | +| render reset request/application | render bridge | `RenderResetRequested`, `RenderResetApplied` | Request handling exists; applied event coverage can be expanded in later render work. | +| input signal changes | backend observation bridge | `InputSignalChanged` | Implemented as backend observation publication. | +| output late/dropped/completed frames | backend timing bridge | `OutputFrameCompleted`, `OutputLateFrameDetected`, `OutputDroppedFrameDetected` | Implemented at the vocabulary and backend publication level. High-rate policy may be refined during backend lifecycle work. | +| warnings and recovery | telemetry bridge | `SubsystemWarningRaised`, `SubsystemWarningCleared`, `SubsystemRecovered` | Vocabulary exists; direct telemetry writes still coexist with event observations. | +| queue depth/timing samples | telemetry metrics bridge | `QueueDepthChanged`, `TimingSampleRecorded` | Implemented for dispatcher/backend observations and coalesced by metric key in the app dispatcher. | ### Bridge Rules @@ -456,6 +473,8 @@ After that, the next bridge should be `ShaderBuildRequested`, because it already ### Step 1. Add Event Types And A Minimal Dispatcher +Status: complete. + Introduce: - `RuntimeEvent` @@ -472,6 +491,8 @@ Start with events that do not change behavior: ### Step 2. Convert `RuntimeUpdateController` Into An Event Handler +Status: complete for the Phase 2 target, with synchronous API helpers retained. + `RuntimeUpdateController` is already close to an event effect applier. Phase 2 should narrow it into a handler for: - coordinator outcome events @@ -481,8 +502,12 @@ Start with events that do not change behavior: The class should stop being the place that polls every source of work. +Current note: `RuntimeUpdateController` now subscribes to the dispatcher and handles broadcast, reload, shader build, compile status, render reset, and health observation paths. It still accepts synchronous `RuntimeCoordinatorResult` values for UI/API calls that need immediate success or error responses. + ### Step 3. Replace `ControlServices::PollLoop` Sleep With Wakeups +Status: complete for OSC commit wakeups; runtime-store compatibility polling remains explicitly transitional. + Keep coalescing, but replace the fixed `25 x Sleep(10)` cadence with: - a condition variable or waitable event @@ -492,8 +517,12 @@ Keep coalescing, but replace the fixed `25 x Sleep(10)` cadence with: This is the most direct Phase 2 timing win. +Current note: `ControlServices` now uses a condition variable and fallback timer. The fallback exists for runtime-store polling until a later file-watch implementation can replace scanning as the change source. Detected reload/file changes publish typed ingress and follow-up events. + ### Step 4. Route Shader Build Lifecycle Through Events +Status: mostly complete. + Turn the current request/apply/failure/success path into explicit events: - `ShaderBuildRequested` @@ -504,8 +533,12 @@ Turn the current request/apply/failure/success path into explicit events: This should preserve the current off-frame-path compile behavior while making readiness visible. +Current note: request, prepared, failed, applied, and compile-status events exist. Generation-aware consumption is covered by tests. Request events are coalesced by build dimensions and preserve-feedback policy in the app dispatcher. + ### Step 5. Route Runtime Broadcasts Through Events +Status: partially complete. + Replace direct "broadcast now" decisions with: - `RuntimeStatePresentationChanged` @@ -515,8 +548,12 @@ Replace direct "broadcast now" decisions with: This keeps UI delivery in `ControlServices` while keeping presentation ownership in the runtime presentation layer. +Current note: `RuntimeStateBroadcastRequested` exists, is coalesced by the app dispatcher, and is handled. Broadcast completion/failure events have not been added yet. + ### Step 6. Add Event Metrics +Status: mostly complete for dispatcher metrics; broader health-event observation continues. + Before using the event system for hotter paths, add metrics: - event queue depth @@ -528,6 +565,8 @@ Before using the event system for hotter paths, add metrics: These should feed `HealthTelemetry`. +Current note: queue depth, oldest-event age, dispatch duration, dropped count, coalesced count, and handler failure counts feed telemetry. Queue/timing events are also published and coalesced by metric key. + ## Dependency Rules Allowed: @@ -551,39 +590,61 @@ The dispatcher is coordination infrastructure, not a new domain owner. Phase 2 should add tests that do not require GL, DeckLink, or network sockets. -Recommended tests: +Implemented tests: - FIFO events dispatch in sequence order - coalesced events keep the latest payload and count collapsed updates - rejected mutations publish rejection events without downstream snapshot/build events - accepted parameter mutations publish the expected follow-up event set -- file reload bursts collapse into one reload request - handler failures are reported as health/log events - queue depth and oldest-event-age metrics update predictably +- typed payload mapping covers persistence, render snapshot, backend, timing, queue-depth, and late/dropped output-frame events +- shader build generation matching applies only the expected prepared build -The existing runtime subsystem tests are a good home for the first pure event model tests, or a new `RuntimeEventTests.cpp` target can be added if the event layer grows enough. +Remaining useful tests before deeper file-watch work: + +- file reload bursts collapse into one reload request across a real poll burst +- broadcast completion/failure events are observable once those payloads exist + +The existing `RuntimeEventTypeTests` target is now the main pure event behavior harness. `RuntimeEventTestHarness` should remain the shared test helper so future lanes do not invent their own dispatcher plumbing. ## Phase 2 Exit Criteria Phase 2 can be considered complete once the project can say: -- there is a typed internal event envelope and dispatcher -- `ControlServices` emits typed events for OSC commits, broadcast requests, and reload/file-change work -- `RuntimeCoordinator` publishes explicit accepted/rejected/follow-up events instead of callers interpreting broad result objects everywhere -- `RuntimeUpdateController` handles events rather than polling all runtime work sources directly -- shader build request/readiness/failure/application is represented as events -- runtime-state broadcasts are event-driven and coalesced -- event queues expose depth, age, coalescing, and failure metrics -- coarse sleep polling is no longer the default coordination model for service work +- [x] there is a typed internal event envelope and dispatcher +- [x] `OpenGLComposite` owns the dispatcher as the current composition root +- [x] `ControlServices` emits typed events for OSC commits and broadcast requests +- [x] reload/file-change work publishes typed ingress and follow-up events +- [x] `RuntimeCoordinator` publishes explicit accepted/rejected/follow-up events +- [x] callers no longer need broad compatibility result queues for normal runtime side effects +- [x] `RuntimeUpdateController` handles event-driven broadcast, shader build, compile status, render reset, and health observation paths +- [x] `RuntimeUpdateController` no longer needs compatibility result draining for ordinary service work +- [x] shader build request/readiness/failure/application is represented as events +- [x] shader build requests are coalesced by dimensions and preserve-feedback policy in the app path +- [x] render snapshot publication is represented as request/published events +- [x] render snapshot publish requests are coalesced in the app path where needed +- [x] backend observations publish typed events +- [x] event queues expose depth, age, dropped, coalescing, and failure metrics +- [x] production event paths use coalescing for broadcast requests, shader-build requests, and high-rate metrics +- [x] coarse sleep polling is no longer the default coordination model for OSC commit service work +- [x] runtime-store/file-change compatibility polling is explicitly contained and publishes event-first reload bridge events when changes are detected + +Phase 2 closure note: + +- The checklist above is complete for the internal event model substrate. +- Broadcast completion/failure events and real file-watch burst tests are useful follow-ups, but they are no longer foundation blockers. +- `RuntimeCoordinatorResult` may remain as a synchronous return type for command APIs; the Phase 2 requirement is that accepted/rejected/follow-up behavior is also published as typed events, which is now true. ## Open Questions For Implementation -- Should the first dispatcher be single-threaded and pumped by the app loop, or should `ControlServices` own a dedicated service event thread? -- Should high-rate OSC transient overlay events go through the same bus, or should only commit/settle events enter the bus initially? -- Should event payloads use `std::variant`, type-erased handlers, or separate strongly typed queues per family? -- How much of `RuntimeCoordinatorResult` should survive as an internal helper versus being replaced by explicit events? -- Should persistence requests be represented in Phase 2 even though the background writer lands later? -- Should backend callback events be introduced now as observation-only events, or wait until the backend state-machine phase? +- Resolved: the first dispatcher is single-process, app-owned, and pumped through the current app/update path. +- Resolved: event payloads use typed structs carried by `std::variant`. +- Resolved: persistence requests are represented in Phase 2 even though background persistence lands later. +- Resolved: backend callback events are introduced now as observation-only events. +- Still open: should high-rate OSC transient overlay events enter the app dispatcher, or should they remain source-local until the live-state layering phase? +- Resolved for Phase 2: `RuntimeCoordinatorResult` can survive as a synchronous helper for command APIs, as long as event publication remains the coordination path for downstream effects. +- Resolved: app-level coalescing lives inside `RuntimeEventDispatcher`; source-specific bridges can still coalesce before publication when they own useful domain-specific collapse policy. ## Short Version diff --git a/tests/RuntimeEventTypeTests.cpp b/tests/RuntimeEventTypeTests.cpp index df4b76a..c9651e9 100644 --- a/tests/RuntimeEventTypeTests.cpp +++ b/tests/RuntimeEventTypeTests.cpp @@ -67,6 +67,18 @@ void TestRuntimeEventPayloadTypes() Expect(RuntimeEventPayloadType(persistence) == RuntimeEventType::RuntimePersistenceRequested, "runtime persistence payload maps to persistence event type"); Expect(persistence.debounceAllowed, "runtime persistence payload carries debounce policy"); + FileChangeDetectedEvent fileChange; + fileChange.path = "PollRuntimeStoreChanges"; + fileChange.shaderPackageCandidate = true; + Expect(RuntimeEventPayloadType(fileChange) == RuntimeEventType::FileChangeDetected, "file change payload maps to file change event type"); + Expect(fileChange.shaderPackageCandidate, "file change payload carries shader package candidate flag"); + + ManualReloadRequestedEvent manualReload; + manualReload.preserveFeedbackState = true; + manualReload.reason = "RequestShaderReload"; + Expect(RuntimeEventPayloadType(manualReload) == RuntimeEventType::ManualReloadRequested, "manual reload payload maps to manual reload event type"); + Expect(manualReload.preserveFeedbackState, "manual reload payload carries feedback preservation policy"); + ShaderBuildEvent preparedBuild; preparedBuild.phase = RuntimeEventShaderBuildPhase::Prepared; preparedBuild.inputWidth = 1920; @@ -234,12 +246,89 @@ void TestRuntimeEventDispatcher() Expect(!dispatcher.Publish(mismatched), "dispatcher rejects mismatched event type and payload"); RuntimeEventDispatcher tinyDispatcher(1); - Expect(tinyDispatcher.PublishPayload(broadcast, "test"), "tiny dispatcher accepts first event"); - Expect(!tinyDispatcher.PublishPayload(broadcast, "test"), "tiny dispatcher rejects event when queue is full"); + RuntimeMutationEvent acceptedMutation; + acceptedMutation.accepted = true; + Expect(tinyDispatcher.PublishPayload(acceptedMutation, "test"), "tiny dispatcher accepts first FIFO event"); + Expect(!tinyDispatcher.PublishPayload(acceptedMutation, "test"), "tiny dispatcher rejects FIFO event when queue is full"); RuntimeEventQueueMetrics metrics = tinyDispatcher.GetQueueMetrics(); Expect(metrics.droppedCount == 1, "dispatcher exposes queue drop metrics"); } +void TestRuntimeEventDispatcherCoalescing() +{ + RuntimeEventDispatcher dispatcher(4); + std::string seenReason; + std::string seenShaderMessage; + double seenTimingValue = 0.0; + int broadcastHandlerCount = 0; + int shaderHandlerCount = 0; + int timingHandlerCount = 0; + + dispatcher.Subscribe(RuntimeEventType::RuntimeStateBroadcastRequested, [&](const RuntimeEvent& event) { + const auto* payload = std::get_if(&event.payload); + if (payload) + seenReason = payload->reason; + ++broadcastHandlerCount; + }); + dispatcher.Subscribe(RuntimeEventType::ShaderBuildRequested, [&](const RuntimeEvent& event) { + const auto* payload = std::get_if(&event.payload); + if (payload) + seenShaderMessage = payload->message; + ++shaderHandlerCount; + }); + dispatcher.Subscribe(RuntimeEventType::TimingSampleRecorded, [&](const RuntimeEvent& event) { + const auto* payload = std::get_if(&event.payload); + if (payload) + seenTimingValue = payload->value; + ++timingHandlerCount; + }); + + RuntimeStateBroadcastRequestedEvent first; + first.reason = "parameter"; + RuntimeStateBroadcastRequestedEvent second; + second.reason = "reload"; + + Expect(dispatcher.PublishPayload(first, "RuntimeCoordinator"), "dispatcher accepts first coalescable event"); + Expect(dispatcher.PublishPayload(second, "RuntimeCoordinator"), "dispatcher coalesces second matching event"); + RuntimeEventQueueMetrics queuedMetrics = dispatcher.GetQueueMetrics(); + Expect(queuedMetrics.depth == 1, "dispatcher reports coalesced event depth"); + Expect(queuedMetrics.coalescedCount == 1, "dispatcher reports coalesced event count"); + + RuntimeEventDispatchResult result = dispatcher.DispatchPending(); + Expect(result.dispatchedEvents == 1, "dispatcher dispatches one coalesced event"); + Expect(broadcastHandlerCount == 1, "dispatcher invokes handler once for coalesced event"); + Expect(seenReason == "reload", "dispatcher dispatches latest coalesced payload"); + + ShaderBuildEvent shaderFirst; + shaderFirst.phase = RuntimeEventShaderBuildPhase::Requested; + shaderFirst.inputWidth = 1920; + shaderFirst.inputHeight = 1080; + shaderFirst.preserveFeedbackState = true; + shaderFirst.message = "first"; + ShaderBuildEvent shaderSecond = shaderFirst; + shaderSecond.message = "second"; + Expect(dispatcher.PublishPayload(shaderFirst, "RuntimeCoordinator"), "dispatcher accepts first shader build request"); + Expect(dispatcher.PublishPayload(shaderSecond, "RuntimeCoordinator"), "dispatcher coalesces matching shader build request"); + result = dispatcher.DispatchPending(); + Expect(result.dispatchedEvents == 1, "dispatcher dispatches one coalesced shader build request"); + Expect(shaderHandlerCount == 1, "dispatcher invokes shader handler once for matching coalesced request"); + Expect(seenShaderMessage == "second", "dispatcher dispatches latest shader build request payload"); + + TimingSampleRecordedEvent timingFirst; + timingFirst.subsystem = "RuntimeEventDispatcher"; + timingFirst.metric = "dispatchDuration"; + timingFirst.value = 1.0; + timingFirst.unit = "ms"; + TimingSampleRecordedEvent timingSecond = timingFirst; + timingSecond.value = 2.0; + Expect(dispatcher.PublishPayload(timingFirst, "HealthTelemetry"), "dispatcher accepts first timing sample"); + Expect(dispatcher.PublishPayload(timingSecond, "HealthTelemetry"), "dispatcher coalesces matching timing sample"); + result = dispatcher.DispatchPending(); + Expect(result.dispatchedEvents == 1, "dispatcher dispatches one coalesced timing sample"); + Expect(timingHandlerCount == 1, "dispatcher invokes timing handler once for matching coalesced sample"); + Expect(seenTimingValue == 2.0, "dispatcher dispatches latest timing sample payload"); +} + void TestRuntimeEventCoalescingQueue() { RuntimeEventCoalescingQueue queue(2); @@ -401,6 +490,124 @@ void TestAcceptedMutationFollowUps() Expect(persistencePayload && persistencePayload->reason == "SetLayerShader", "persistence follow-up preserves mutation action reason"); } +void TestAppLevelBroadcastAndBuildCoalescing() +{ + RuntimeEventTestHarness harness; + + RuntimeMutationEvent firstMutation; + firstMutation.action = "SetLayerShader"; + firstMutation.accepted = true; + firstMutation.runtimeStateChanged = true; + firstMutation.runtimeStateBroadcastRequired = true; + firstMutation.shaderBuildRequested = true; + + RuntimeMutationEvent secondMutation = firstMutation; + secondMutation.action = "LoadStackPreset"; + + RuntimeStateBroadcastRequestedEvent firstBroadcast; + firstBroadcast.reason = "SetLayerShader"; + RuntimeStateBroadcastRequestedEvent secondBroadcast; + secondBroadcast.reason = "LoadStackPreset"; + + ShaderBuildEvent firstBuild; + firstBuild.phase = RuntimeEventShaderBuildPhase::Requested; + firstBuild.inputWidth = 1920; + firstBuild.inputHeight = 1080; + firstBuild.preserveFeedbackState = false; + firstBuild.message = "first build request"; + ShaderBuildEvent secondBuild = firstBuild; + secondBuild.message = "second build request"; + + Expect(harness.Publish(firstMutation, "RuntimeCoordinator"), "first accepted mutation fact publishes"); + Expect(harness.Publish(firstBroadcast, "RuntimeUpdateController"), "first broadcast request publishes through app dispatcher"); + Expect(harness.Publish(firstBuild, "RuntimeCoordinator"), "first shader build request publishes through app dispatcher"); + Expect(harness.Publish(secondMutation, "RuntimeCoordinator"), "second accepted mutation fact publishes"); + Expect(harness.Publish(secondBroadcast, "RuntimeUpdateController"), "second broadcast request coalesces through app dispatcher"); + Expect(harness.Publish(secondBuild, "RuntimeCoordinator"), "second shader build request coalesces through app dispatcher"); + + RuntimeEventQueueMetrics metrics = harness.Dispatcher().GetQueueMetrics(); + Expect(metrics.depth == 4, "app dispatcher keeps FIFO facts plus coalesced broadcast/build requests"); + Expect(metrics.coalescedCount == 2, "app dispatcher reports broadcast and build coalescing"); + + RuntimeEventDispatchResult result = harness.DispatchPending(); + Expect(result.dispatchedEvents == 4, "app dispatcher dispatches FIFO facts plus one broadcast and one build request"); + Expect(harness.SeenCount(RuntimeEventType::RuntimeMutationAccepted) == 2, "app dispatcher preserves every accepted mutation fact"); + Expect(harness.SeenCount(RuntimeEventType::RuntimeStateBroadcastRequested) == 1, "app dispatcher coalesces broadcast requests"); + Expect(harness.SeenCount(RuntimeEventType::ShaderBuildRequested) == 1, "app dispatcher coalesces matching shader build requests"); + + const RuntimeEvent* broadcastEvent = harness.LastSeen(RuntimeEventType::RuntimeStateBroadcastRequested); + const auto* broadcastPayload = broadcastEvent ? std::get_if(&broadcastEvent->payload) : nullptr; + Expect(broadcastPayload && broadcastPayload->reason == "LoadStackPreset", "app dispatcher dispatches latest broadcast request"); + + const RuntimeEvent* buildEvent = harness.LastSeen(RuntimeEventType::ShaderBuildRequested); + const auto* buildPayload = buildEvent ? std::get_if(&buildEvent->payload) : nullptr; + Expect(buildPayload && buildPayload->message == "second build request", "app dispatcher dispatches latest shader build request"); +} + +void TestManualReloadBridgeEvents() +{ + RuntimeEventTestHarness harness; + + ManualReloadRequestedEvent manualReload; + manualReload.preserveFeedbackState = true; + manualReload.reason = "RequestShaderReload"; + + RuntimeReloadRequestedEvent runtimeReload; + runtimeReload.preserveFeedbackState = true; + runtimeReload.reason = "RequestShaderReload"; + + ShaderBuildEvent shaderBuild; + shaderBuild.phase = RuntimeEventShaderBuildPhase::Requested; + shaderBuild.preserveFeedbackState = true; + shaderBuild.message = "Shader rebuild queued."; + + Expect(harness.Publish(manualReload, "RuntimeCoordinator"), "manual reload ingress event publishes"); + Expect(harness.Publish(runtimeReload, "RuntimeCoordinator"), "manual reload bridge publishes runtime reload request"); + Expect(harness.Publish(shaderBuild, "RuntimeCoordinator"), "manual reload bridge publishes shader build request"); + + RuntimeEventDispatchResult result = harness.DispatchPending(); + Expect(result.dispatchedEvents == 3, "manual reload bridge dispatches ingress and follow-up events"); + Expect(harness.SeenCount(RuntimeEventType::ManualReloadRequested) == 1, "manual reload ingress event is observed"); + Expect(harness.SeenCount(RuntimeEventType::RuntimeReloadRequested) == 1, "manual reload runtime reload follow-up is observed"); + Expect(harness.SeenCount(RuntimeEventType::ShaderBuildRequested) == 1, "manual reload shader build follow-up is observed"); + + const RuntimeEvent* reloadEvent = harness.LastSeen(RuntimeEventType::RuntimeReloadRequested); + const auto* reloadPayload = reloadEvent ? std::get_if(&reloadEvent->payload) : nullptr; + Expect(reloadPayload && reloadPayload->preserveFeedbackState, "manual reload bridge preserves feedback policy in runtime reload event"); +} + +void TestFileReloadBridgeEvents() +{ + RuntimeEventTestHarness harness; + + FileChangeDetectedEvent fileChange; + fileChange.path = "PollRuntimeStoreChanges"; + fileChange.shaderPackageCandidate = true; + + RuntimeReloadRequestedEvent runtimeReload; + runtimeReload.preserveFeedbackState = false; + runtimeReload.reason = "PollRuntimeStoreChanges"; + + ShaderBuildEvent shaderBuild; + shaderBuild.phase = RuntimeEventShaderBuildPhase::Requested; + shaderBuild.preserveFeedbackState = false; + shaderBuild.message = "Shader rebuild queued."; + + Expect(harness.Publish(fileChange, "RuntimeCoordinator"), "file change ingress event publishes"); + Expect(harness.Publish(runtimeReload, "RuntimeCoordinator"), "file change bridge publishes runtime reload request"); + Expect(harness.Publish(shaderBuild, "RuntimeCoordinator"), "file change bridge publishes shader build request"); + + RuntimeEventDispatchResult result = harness.DispatchPending(); + Expect(result.dispatchedEvents == 3, "file reload bridge dispatches ingress and follow-up events"); + Expect(harness.SeenCount(RuntimeEventType::FileChangeDetected) == 1, "file change ingress event is observed"); + Expect(harness.SeenCount(RuntimeEventType::RuntimeReloadRequested) == 1, "file reload runtime reload follow-up is observed"); + Expect(harness.SeenCount(RuntimeEventType::ShaderBuildRequested) == 1, "file reload shader build follow-up is observed"); + + const RuntimeEvent* fileEvent = harness.LastSeen(RuntimeEventType::FileChangeDetected); + const auto* filePayload = fileEvent ? std::get_if(&fileEvent->payload) : nullptr; + Expect(filePayload && filePayload->shaderPackageCandidate, "file reload bridge marks shader package candidate changes"); +} + void TestRejectedMutationHasNoDownstreamFollowUps() { RuntimeEventTestHarness harness; @@ -487,10 +694,14 @@ int main() TestRuntimeEventEnvelope(); TestRuntimeEventQueue(); TestRuntimeEventDispatcher(); + TestRuntimeEventDispatcherCoalescing(); TestRuntimeEventCoalescingQueue(); TestRuntimeEventCoalescingCustomKey(); TestRuntimeEventTestHarness(); TestAcceptedMutationFollowUps(); + TestAppLevelBroadcastAndBuildCoalescing(); + TestManualReloadBridgeEvents(); + TestFileReloadBridgeEvents(); TestRejectedMutationHasNoDownstreamFollowUps(); TestShaderBuildGenerationEventMatching(); TestHandlerFailureCanBecomeTelemetryEvent();