phase 2 progress
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m26s
CI / Windows Release Package (push) Successful in 2m30s

This commit is contained in:
Aiden
2026-05-11 16:18:34 +10:00
parent 6e600be112
commit d4f6a4a268
16 changed files with 463 additions and 140 deletions

View File

@@ -166,17 +166,6 @@ void ControlServices::ConsumeCompletedOscCommits(std::vector<CompletedOscCommit>
completedCommits.swap(mCompletedOscCommits); completedCommits.swap(mCompletedOscCommits);
} }
void ControlServices::ConsumeRuntimeCoordinatorResults(std::vector<RuntimeCoordinatorServiceResult>& results)
{
results.clear();
std::lock_guard<std::mutex> lock(mRuntimeCoordinatorResultMutex);
if (mRuntimeCoordinatorResults.empty())
return;
results.swap(mRuntimeCoordinatorResults);
}
void ControlServices::StartPolling(RuntimeCoordinator& runtimeCoordinator) void ControlServices::StartPolling(RuntimeCoordinator& runtimeCoordinator)
{ {
if (mPollRunning.exchange(true)) if (mPollRunning.exchange(true))
@@ -218,7 +207,6 @@ void ControlServices::PollLoop(RuntimeCoordinator& runtimeCoordinator)
completedCommit.generation = entry.second.generation; completedCommit.generation = entry.second.generation;
std::lock_guard<std::mutex> lock(mCompletedOscCommitMutex); std::lock_guard<std::mutex> lock(mCompletedOscCommitMutex);
mCompletedOscCommits.push_back(std::move(completedCommit)); mCompletedOscCommits.push_back(std::move(completedCommit));
QueueRuntimeCoordinatorResult(result);
} }
else if (!result.errorMessage.empty()) else if (!result.errorMessage.empty())
{ {
@@ -228,8 +216,8 @@ void ControlServices::PollLoop(RuntimeCoordinator& runtimeCoordinator)
bool registryChanged = false; bool registryChanged = false;
const RuntimeCoordinatorResult pollResult = runtimeCoordinator.PollRuntimeStoreChanges(registryChanged); const RuntimeCoordinatorResult pollResult = runtimeCoordinator.PollRuntimeStoreChanges(registryChanged);
if (pollResult.runtimeStateBroadcastRequired || pollResult.shaderBuildRequested || pollResult.compileStatusChanged) if (pollResult.compileStatusChanged && !pollResult.compileStatusSucceeded && !pollResult.compileStatusMessage.empty())
QueueRuntimeCoordinatorResult(pollResult, pollResult.compileStatusChanged && !pollResult.compileStatusSucceeded && !pollResult.compileStatusMessage.empty()); OutputDebugStringA(("Runtime poll failed: " + pollResult.compileStatusMessage + "\n").c_str());
std::unique_lock<std::mutex> wakeLock(mPollWakeMutex); std::unique_lock<std::mutex> wakeLock(mPollWakeMutex);
mPollWakeCondition.wait_for(wakeLock, kCompatibilityPollFallbackInterval, [this]() { mPollWakeCondition.wait_for(wakeLock, kCompatibilityPollFallbackInterval, [this]() {
@@ -248,16 +236,6 @@ void ControlServices::WakePolling()
mPollWakeCondition.notify_one(); mPollWakeCondition.notify_one();
} }
void ControlServices::QueueRuntimeCoordinatorResult(const RuntimeCoordinatorResult& result, bool failed)
{
RuntimeCoordinatorServiceResult serviceResult;
serviceResult.result = result;
serviceResult.failed = failed;
std::lock_guard<std::mutex> lock(mRuntimeCoordinatorResultMutex);
mRuntimeCoordinatorResults.push_back(std::move(serviceResult));
}
void ControlServices::PublishRuntimeStateBroadcastRequested(const std::string& reason) void ControlServices::PublishRuntimeStateBroadcastRequested(const std::string& reason)
{ {
try try

View File

@@ -20,12 +20,6 @@ class OscServer;
class RuntimeEventDispatcher; class RuntimeEventDispatcher;
class RuntimeStore; class RuntimeStore;
struct RuntimeCoordinatorServiceResult
{
RuntimeCoordinatorResult result;
bool failed = false;
};
class ControlServices class ControlServices
{ {
public: 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); 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 ClearOscState();
void ConsumeCompletedOscCommits(std::vector<CompletedOscCommit>& completedCommits); void ConsumeCompletedOscCommits(std::vector<CompletedOscCommit>& completedCommits);
void ConsumeRuntimeCoordinatorResults(std::vector<RuntimeCoordinatorServiceResult>& results);
private: private:
struct PendingOscUpdate struct PendingOscUpdate
@@ -79,7 +72,6 @@ private:
void StopPolling(); void StopPolling();
void PollLoop(RuntimeCoordinator& runtimeCoordinator); void PollLoop(RuntimeCoordinator& runtimeCoordinator);
void WakePolling(); void WakePolling();
void QueueRuntimeCoordinatorResult(const RuntimeCoordinatorResult& result, bool failed = false);
void PublishRuntimeStateBroadcastRequested(const std::string& reason); void PublishRuntimeStateBroadcastRequested(const std::string& reason);
void PublishOscValueReceived(const PendingOscUpdate& update, const std::string& routeKey); void PublishOscValueReceived(const PendingOscUpdate& update, const std::string& routeKey);
void PublishOscCommitRequested(const PendingOscCommit& commit); void PublishOscCommitRequested(const PendingOscCommit& commit);
@@ -92,8 +84,6 @@ private:
std::mutex mPollWakeMutex; std::mutex mPollWakeMutex;
std::condition_variable mPollWakeCondition; std::condition_variable mPollWakeCondition;
bool mPollWakeRequested = false; bool mPollWakeRequested = false;
std::mutex mRuntimeCoordinatorResultMutex;
std::vector<RuntimeCoordinatorServiceResult> mRuntimeCoordinatorResults;
std::mutex mPendingOscMutex; std::mutex mPendingOscMutex;
std::map<std::string, PendingOscUpdate> mPendingOscUpdates; std::map<std::string, PendingOscUpdate> mPendingOscUpdates;
std::mutex mPendingOscCommitMutex; std::mutex mPendingOscCommitMutex;

View File

@@ -78,14 +78,3 @@ void RuntimeServices::ConsumeCompletedOscCommits(std::vector<CompletedOscCommit>
mControlServices->ConsumeCompletedOscCommits(completedCommits); mControlServices->ConsumeCompletedOscCommits(completedCommits);
} }
void RuntimeServices::ConsumeRuntimeCoordinatorResults(std::vector<RuntimeCoordinatorServiceResult>& results)
{
if (!mControlServices)
{
results.clear();
return;
}
mControlServices->ConsumeRuntimeCoordinatorResults(results);
}

View File

@@ -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); 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 ClearOscState();
void ConsumeCompletedOscCommits(std::vector<CompletedOscCommit>& completedCommits); void ConsumeCompletedOscCommits(std::vector<CompletedOscCommit>& completedCommits);
void ConsumeRuntimeCoordinatorResults(std::vector<RuntimeCoordinatorServiceResult>& results);
private: private:
std::unique_ptr<ControlServices> mControlServices; std::unique_ptr<ControlServices> mControlServices;

View File

@@ -300,7 +300,7 @@ bool OpenGLComposite::RequestScreenshot(std::string& error)
void OpenGLComposite::renderEffect() void OpenGLComposite::renderEffect()
{ {
if (mRuntimeUpdateController && ProcessRuntimeServiceResults()) if (mRuntimeUpdateController)
mRuntimeUpdateController->ProcessRuntimeWork(); mRuntimeUpdateController->ProcessRuntimeWork();
std::vector<RuntimeServices::AppliedOscUpdate> appliedOscUpdates; std::vector<RuntimeServices::AppliedOscUpdate> appliedOscUpdates;
std::vector<RuntimeServices::CompletedOscCommit> completedOscCommits; std::vector<RuntimeServices::CompletedOscCommit> completedOscCommits;
@@ -369,25 +369,6 @@ void OpenGLComposite::renderEffect()
historyCap); historyCap);
} }
bool OpenGLComposite::ProcessRuntimeServiceResults()
{
if (!mRuntimeServices || !mRuntimeUpdateController)
return true;
bool shaderBuildRequested = false;
std::vector<RuntimeCoordinatorServiceResult> 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() void OpenGLComposite::ProcessScreenshotRequest()
{ {
if (!mScreenshotRequested.exchange(false)) if (!mScreenshotRequested.exchange(false))

View File

@@ -87,7 +87,6 @@ private:
bool InitOpenGLState(); bool InitOpenGLState();
void renderEffect(); void renderEffect();
bool ProcessRuntimeServiceResults();
void ProcessScreenshotRequest(); void ProcessScreenshotRequest();
std::filesystem::path BuildScreenshotPath() const; std::filesystem::path BuildScreenshotPath() const;
}; };

View File

@@ -45,6 +45,9 @@ RuntimeUpdateController::RuntimeUpdateController(
mRuntimeEventDispatcher.Subscribe( mRuntimeEventDispatcher.Subscribe(
RuntimeEventType::RuntimeStateBroadcastRequested, RuntimeEventType::RuntimeStateBroadcastRequested,
[this](const RuntimeEvent& event) { HandleRuntimeStateBroadcastRequested(event); }); [this](const RuntimeEvent& event) { HandleRuntimeStateBroadcastRequested(event); });
mRuntimeEventDispatcher.Subscribe(
RuntimeEventType::RuntimeReloadRequested,
[this](const RuntimeEvent& event) { HandleRuntimeReloadRequested(event); });
mRuntimeEventDispatcher.Subscribe( mRuntimeEventDispatcher.Subscribe(
RuntimeEventType::ShaderBuildRequested, RuntimeEventType::ShaderBuildRequested,
[this](const RuntimeEvent& event) { HandleShaderBuildRequested(event); }); [this](const RuntimeEvent& event) { HandleShaderBuildRequested(event); });
@@ -137,6 +140,15 @@ void RuntimeUpdateController::HandleRuntimeStateBroadcastRequested(const Runtime
mRuntimeServices.BroadcastState(); mRuntimeServices.BroadcastState();
} }
void RuntimeUpdateController::HandleRuntimeReloadRequested(const RuntimeEvent& event)
{
const RuntimeReloadRequestedEvent* payload = std::get_if<RuntimeReloadRequestedEvent>(&event.payload);
if (!payload)
return;
mRuntimeStore.ClearReloadRequest();
}
void RuntimeUpdateController::HandleShaderBuildRequested(const RuntimeEvent& event) void RuntimeUpdateController::HandleShaderBuildRequested(const RuntimeEvent& event)
{ {
const ShaderBuildEvent* payload = std::get_if<ShaderBuildEvent>(&event.payload); const ShaderBuildEvent* payload = std::get_if<ShaderBuildEvent>(&event.payload);
@@ -317,16 +329,19 @@ void RuntimeUpdateController::PublishRuntimeEventHealthObservations(const Runtim
{ {
const RuntimeEventQueueMetrics queueMetrics = mRuntimeEventDispatcher.GetQueueMetrics(); const RuntimeEventQueueMetrics queueMetrics = mRuntimeEventDispatcher.GetQueueMetrics();
if (queueMetrics.depth != mLastReportedRuntimeEventQueueDepth || if (queueMetrics.depth != mLastReportedRuntimeEventQueueDepth ||
queueMetrics.droppedCount != mLastReportedRuntimeEventDroppedCount) queueMetrics.droppedCount != mLastReportedRuntimeEventDroppedCount ||
queueMetrics.coalescedCount != mLastReportedRuntimeEventCoalescedCount)
{ {
QueueDepthChangedEvent queueDepth; QueueDepthChangedEvent queueDepth;
queueDepth.queueName = "runtime-events"; queueDepth.queueName = "runtime-events";
queueDepth.depth = queueMetrics.depth; queueDepth.depth = queueMetrics.depth;
queueDepth.capacity = queueMetrics.capacity; queueDepth.capacity = queueMetrics.capacity;
queueDepth.droppedCount = queueMetrics.droppedCount; queueDepth.droppedCount = queueMetrics.droppedCount;
queueDepth.coalescedCount = queueMetrics.coalescedCount;
mRuntimeEventDispatcher.PublishPayload(queueDepth, "HealthTelemetry"); mRuntimeEventDispatcher.PublishPayload(queueDepth, "HealthTelemetry");
mLastReportedRuntimeEventQueueDepth = queueMetrics.depth; mLastReportedRuntimeEventQueueDepth = queueMetrics.depth;
mLastReportedRuntimeEventDroppedCount = queueMetrics.droppedCount; mLastReportedRuntimeEventDroppedCount = queueMetrics.droppedCount;
mLastReportedRuntimeEventCoalescedCount = queueMetrics.coalescedCount;
} }
if (result.handlerInvocations == 0 && result.handlerFailures == 0) if (result.handlerInvocations == 0 && result.handlerFailures == 0)

View File

@@ -35,6 +35,7 @@ public:
private: private:
void HandleRuntimeStateBroadcastRequested(const RuntimeEvent& event); void HandleRuntimeStateBroadcastRequested(const RuntimeEvent& event);
void HandleRuntimeReloadRequested(const RuntimeEvent& event);
void HandleShaderBuildRequested(const RuntimeEvent& event); void HandleShaderBuildRequested(const RuntimeEvent& event);
void HandleShaderBuildPrepared(const RuntimeEvent& event); void HandleShaderBuildPrepared(const RuntimeEvent& event);
void HandleShaderBuildFailed(const RuntimeEvent& event); void HandleShaderBuildFailed(const RuntimeEvent& event);
@@ -64,4 +65,5 @@ private:
std::size_t mPendingCoordinatorRenderResetEvents = 0; std::size_t mPendingCoordinatorRenderResetEvents = 0;
std::size_t mLastReportedRuntimeEventQueueDepth = static_cast<std::size_t>(-1); std::size_t mLastReportedRuntimeEventQueueDepth = static_cast<std::size_t>(-1);
std::size_t mLastReportedRuntimeEventDroppedCount = static_cast<std::size_t>(-1); std::size_t mLastReportedRuntimeEventDroppedCount = static_cast<std::size_t>(-1);
std::size_t mLastReportedRuntimeEventCoalescedCount = static_cast<std::size_t>(-1);
}; };

View File

@@ -246,6 +246,7 @@ RuntimeCoordinatorResult RuntimeCoordinator::LoadStackPreset(const std::string&
RuntimeCoordinatorResult RuntimeCoordinator::RequestShaderReload(bool preserveFeedbackState) RuntimeCoordinatorResult RuntimeCoordinator::RequestShaderReload(bool preserveFeedbackState)
{ {
std::lock_guard<std::mutex> lock(mMutex); std::lock_guard<std::mutex> lock(mMutex);
PublishManualReloadRequested(preserveFeedbackState, "RequestShaderReload");
RuntimeCoordinatorResult result = BuildQueuedReloadResult(preserveFeedbackState); RuntimeCoordinatorResult result = BuildQueuedReloadResult(preserveFeedbackState);
PublishCoordinatorFollowUpEvents("RequestShaderReload", result); PublishCoordinatorFollowUpEvents("RequestShaderReload", result);
return result; return result;
@@ -266,6 +267,7 @@ RuntimeCoordinatorResult RuntimeCoordinator::PollRuntimeStoreChanges(bool& regis
if (reloadRequested) if (reloadRequested)
{ {
PublishFileChangeDetected("PollRuntimeStoreChanges", registryChanged, reloadRequested);
RuntimeCoordinatorResult result = BuildQueuedReloadResult(false); RuntimeCoordinatorResult result = BuildQueuedReloadResult(false);
PublishCoordinatorFollowUpEvents("PollRuntimeStoreChanges", result); PublishCoordinatorFollowUpEvents("PollRuntimeStoreChanges", result);
return result; return result;
@@ -273,6 +275,7 @@ RuntimeCoordinatorResult RuntimeCoordinator::PollRuntimeStoreChanges(bool& regis
if (registryChanged) if (registryChanged)
{ {
PublishFileChangeDetected("PollRuntimeStoreChanges", registryChanged, reloadRequested);
RuntimeCoordinatorResult result = BuildAcceptedNoReloadResult(); RuntimeCoordinatorResult result = BuildAcceptedNoReloadResult();
PublishCoordinatorFollowUpEvents("PollRuntimeStoreChanges", result); PublishCoordinatorFollowUpEvents("PollRuntimeStoreChanges", result);
return result; return result;
@@ -332,6 +335,7 @@ RuntimeCoordinatorResult RuntimeCoordinator::HandlePreparedShaderBuildSuccess()
RuntimeCoordinatorResult RuntimeCoordinator::HandleRuntimeReloadRequest() RuntimeCoordinatorResult RuntimeCoordinator::HandleRuntimeReloadRequest()
{ {
std::lock_guard<std::mutex> lock(mMutex); std::lock_guard<std::mutex> lock(mMutex);
PublishManualReloadRequested(false, "HandleRuntimeReloadRequest");
RuntimeCoordinatorResult result = BuildQueuedReloadResult(false); RuntimeCoordinatorResult result = BuildQueuedReloadResult(false);
PublishCoordinatorFollowUpEvents("HandleRuntimeReloadRequest", result); PublishCoordinatorFollowUpEvents("HandleRuntimeReloadRequest", result);
return result; return result;
@@ -493,6 +497,36 @@ RuntimeCoordinatorResult RuntimeCoordinator::BuildAcceptedNoReloadResult() const
return result; 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 void RuntimeCoordinator::PublishCoordinatorResult(const std::string& action, const RuntimeCoordinatorResult& result) const
{ {
try try

View File

@@ -93,6 +93,8 @@ private:
RuntimeCoordinatorResult ApplyStoreMutation(bool succeeded, const std::string& errorMessage, bool reloadRequired, bool preserveFeedbackState, bool persistenceRequested); RuntimeCoordinatorResult ApplyStoreMutation(bool succeeded, const std::string& errorMessage, bool reloadRequired, bool preserveFeedbackState, bool persistenceRequested);
RuntimeCoordinatorResult BuildQueuedReloadResult(bool preserveFeedbackState); RuntimeCoordinatorResult BuildQueuedReloadResult(bool preserveFeedbackState);
RuntimeCoordinatorResult BuildAcceptedNoReloadResult() const; 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 PublishCoordinatorResult(const std::string& action, const RuntimeCoordinatorResult& result) const;
void PublishCoordinatorFollowUpEvents(const std::string& action, const RuntimeCoordinatorResult& result) const; void PublishCoordinatorFollowUpEvents(const std::string& action, const RuntimeCoordinatorResult& result) const;

View File

@@ -29,6 +29,17 @@ inline std::string RuntimeEventDefaultCoalescingKey(const RuntimeEvent& event)
return std::string(RuntimeEventTypeName(event.type)) + ":" + payload->routeKey; return std::string(RuntimeEventTypeName(event.type)) + ":" + payload->routeKey;
if (const auto* payload = std::get_if<FileChangeDetectedEvent>(&event.payload)) if (const auto* payload = std::get_if<FileChangeDetectedEvent>(&event.payload))
return std::string(RuntimeEventTypeName(event.type)) + ":" + payload->path; return std::string(RuntimeEventTypeName(event.type)) + ":" + payload->path;
if (const auto* payload = std::get_if<ShaderBuildEvent>(&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<RenderSnapshotPublishRequestedEvent>(&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<TimingSampleRecordedEvent>(&event.payload))
return std::string(RuntimeEventTypeName(event.type)) + ":" + payload->subsystem + ":" + payload->metric;
if (const auto* payload = std::get_if<QueueDepthChangedEvent>(&event.payload)) if (const auto* payload = std::get_if<QueueDepthChangedEvent>(&event.payload))
return std::string(RuntimeEventTypeName(event.type)) + ":" + payload->queueName; return std::string(RuntimeEventTypeName(event.type)) + ":" + payload->queueName;

View File

@@ -1,7 +1,9 @@
#pragma once #pragma once
#include "RuntimeEventCoalescingQueue.h"
#include "RuntimeEventQueue.h" #include "RuntimeEventQueue.h"
#include <algorithm>
#include <atomic> #include <atomic>
#include <functional> #include <functional>
#include <map> #include <map>
@@ -22,7 +24,8 @@ public:
using Handler = std::function<void(const RuntimeEvent&)>; using Handler = std::function<void(const RuntimeEvent&)>;
explicit RuntimeEventDispatcher(std::size_t queueCapacity = 1024) : explicit RuntimeEventDispatcher(std::size_t queueCapacity = 1024) :
mQueue(queueCapacity) mQueue(queueCapacity),
mCoalescingQueue(queueCapacity)
{ {
} }
@@ -34,6 +37,9 @@ public:
if (event.sequence == 0) if (event.sequence == 0)
event.sequence = mNextSequence.fetch_add(1); event.sequence = mNextSequence.fetch_add(1);
if (ShouldCoalesce(event))
return mCoalescingQueue.Push(std::move(event));
return mQueue.Push(std::move(event)); return mQueue.Push(std::move(event));
} }
@@ -59,6 +65,7 @@ public:
{ {
const auto startedAt = std::chrono::steady_clock::now(); const auto startedAt = std::chrono::steady_clock::now();
RuntimeEventDispatchResult result; RuntimeEventDispatchResult result;
FlushCoalescedToFifo(maxEvents);
std::vector<RuntimeEvent> events = mQueue.Drain(maxEvents); std::vector<RuntimeEvent> events = mQueue.Drain(maxEvents);
result.dispatchedEvents = events.size(); result.dispatchedEvents = events.size();
@@ -92,15 +99,56 @@ public:
RuntimeEventQueueMetrics GetQueueMetrics(std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now()) const 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 std::size_t QueueDepth() const
{ {
return mQueue.Depth(); return mQueue.Depth() + mCoalescingQueue.Depth();
} }
private: 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<RuntimeEvent> events = mCoalescingQueue.Drain(flushLimit);
for (RuntimeEvent& event : events)
mQueue.Push(std::move(event));
}
std::vector<Handler> HandlersFor(RuntimeEventType type) const std::vector<Handler> HandlersFor(RuntimeEventType type) const
{ {
std::lock_guard<std::mutex> lock(mHandlerMutex); std::lock_guard<std::mutex> lock(mHandlerMutex);
@@ -114,6 +162,7 @@ private:
} }
RuntimeEventQueue mQueue; RuntimeEventQueue mQueue;
RuntimeEventCoalescingQueue mCoalescingQueue;
std::atomic<uint64_t> mNextSequence{ 1 }; std::atomic<uint64_t> mNextSequence{ 1 };
mutable std::mutex mHandlerMutex; mutable std::mutex mHandlerMutex;
std::map<RuntimeEventType, std::vector<Handler>> mHandlers; std::map<RuntimeEventType, std::vector<Handler>> mHandlers;

View File

@@ -13,6 +13,7 @@ struct RuntimeEventQueueMetrics
std::size_t depth = 0; std::size_t depth = 0;
std::size_t capacity = 0; std::size_t capacity = 0;
std::size_t droppedCount = 0; std::size_t droppedCount = 0;
std::size_t coalescedCount = 0;
double oldestEventAgeMilliseconds = 0.0; double oldestEventAgeMilliseconds = 0.0;
}; };

View File

@@ -5,7 +5,7 @@ This note summarizes the main architectural improvements that would make the app
Phase checklist: Phase checklist:
- [x] Define subsystem boundaries and target architecture - [x] Define subsystem boundaries and target architecture
- [ ] Introduce an internal event model - [x] Introduce an internal event model
- [x] Split `RuntimeHost` - [x] Split `RuntimeHost`
- [ ] Make the render thread the sole GL owner - [ ] Make the render thread the sole GL owner
- [ ] Refactor live state layering into an explicit composition model - [ ] Refactor live state layering into an explicit composition model
@@ -16,7 +16,8 @@ Phase checklist:
Checklist note: Checklist note:
- The checked Phase 1 item means the subsystem vocabulary, dependency direction, state categories, design package, and runtime implementation foothold are in place. - 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 ## 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 - 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 - 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 - 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. 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 - 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 - 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: 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: 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 - isolate truly background work from latency-sensitive control reconciliation
- add separate metrics for queue age, not just queue depth - add separate metrics for queue age, not just queue depth

View File

@@ -6,25 +6,31 @@ Phase 1 established the subsystem vocabulary and moved the runtime path behind c
## Status ## Status
- Phase 2 design package: proposed. - Phase 2 design package: accepted.
- Phase 2 implementation: not started. - 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. - `RuntimeEventType`, typed payload structs, `RuntimeEvent`, `RuntimeEventQueue`, `RuntimeEventDispatcher`, and `RuntimeEventCoalescingQueue` define the event substrate.
- `RuntimeCoordinator` owns mutation validation, classification, and coordinator result policy. - `OpenGLComposite` owns one app-level `RuntimeEventDispatcher` and passes it into `RuntimeServices`, `RuntimeCoordinator`, `RuntimeUpdateController`, `RuntimeSnapshotProvider`, `ShaderBuildQueue`, and `VideoBackend`.
- `RuntimeUpdateController` applies coordinator outcomes and bridges toward render, shader builds, broadcasts, and backend state. - `ControlServices` publishes typed OSC and runtime-state broadcast events and uses condition-variable wakeups with a fallback compatibility timer.
- `RuntimeSnapshotProvider` publishes render-facing snapshots. - `RuntimeCoordinator` publishes accepted, rejected, state-changed, persistence, reload, shader-build, and compile-status follow-up events.
- `HealthTelemetry` owns status/timing snapshots. - `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 ## 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 - background service timing relied on coarse sleeps and polling
- control, reload, persistence, and render-update work still travel through mixed shared state and result queues - 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 - 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. 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 ## 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. - `ControlServices` publishes OSC value, OSC commit, and runtime-state broadcast events.
- `RuntimeUpdateController::ProcessRuntimeWork()` consumes queued coordinator results, applies them, and then checks whether a prepared shader build is ready. - `ControlServices::PollLoop(...)` is wakeup-driven for queued OSC commit work, with a bounded fallback timer for compatibility polling.
- `RuntimeCoordinatorResult` carries many downstream effects: shader build request, compile status update, transient OSC clear, runtime-state broadcast, committed-state mode, render reset scope. - `RuntimeCoordinator` still returns `RuntimeCoordinatorResult` for synchronous callers, but also publishes accepted/rejected/follow-up events.
- shader-build readiness is polled from the app update path. - `RuntimeUpdateController` subscribes to event families and applies many effects from events rather than only from drained result objects.
- runtime-state broadcasts are requested by direct calls rather than by an event publication contract. - 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 ## Event Model Principles
@@ -289,23 +304,24 @@ Phase 2 does not need a large framework. A small app-owned dispatcher is enough.
Suggested components: Suggested components:
- `RuntimeEventBus` - `RuntimeEventDispatcher`
- owns queues - owns queues
- assigns sequence numbers - assigns sequence numbers
- exposes `Publish(...)` - exposes `Publish(...)`
- exposes `Drain(...)` or `DispatchPending(...)` - exposes `DispatchPending(...)`
- `RuntimeEventHandler` - event handlers
- narrow handler interface or function callback - narrow handler interface or function callback
- registered by subsystem/composition root - registered by subsystem/composition root
- `RuntimeEventQueue` - `RuntimeEventQueue`
- bounded FIFO for ordinary events - bounded FIFO for ordinary events
- coalesced map for latest-value events such as high-rate OSC - `RuntimeEventCoalescingQueue`
- `RuntimeEventMetrics` - 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 - queue depth
- oldest event age - oldest event age
- dropped/coalesced counts - 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 ### Dispatcher Ownership Decision
@@ -320,6 +336,7 @@ References:
- `RuntimeServices` receives the dispatcher and passes it to `ControlServices`. - `RuntimeServices` receives the dispatcher and passes it to `ControlServices`.
- `RuntimeCoordinator` receives the dispatcher so coordinator outcomes can become explicit events. - `RuntimeCoordinator` receives the dispatcher so coordinator outcomes can become explicit events.
- `RuntimeUpdateController` receives the dispatcher so it can become the first effect/apply handler. - `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. 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 | | `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 | | `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 | | `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 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 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` | commit request `coalesced-latest` by route key; mutation result `fifo-fact` | | 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 | `sync-command-with-event` | | 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` | `coalesced-latest` by event type or reason family | | 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` | `fifo-fact` for manual request; reload execution may coalesce | | 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` | `coalesced-latest` by path, then coalesced reload request | | 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 | `ShaderPackagesChanged`, `RuntimeReloadRequested`, or warning event | `compatibility-poll` until file events fully replace polling | | 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` | `coalesced-latest` by input dimensions and preserve-feedback flag | | 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` | `fifo-fact` | | shader build ready/failure/apply | shader build lifecycle bridge | `ShaderBuildPrepared`, `ShaderBuildFailed`, `ShaderBuildApplied`, `CompileStatusChanged` | Implemented with generation matching. |
| render snapshot publication | snapshot bridge | `RenderSnapshotPublishRequested`, `RenderSnapshotPublished` | request may coalesce by output dimensions; published event is `fifo-fact` | | 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` | `fifo-fact` | | 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` | `coalesced-latest` by signal lane | | input signal changes | backend observation bridge | `InputSignalChanged` | Implemented as backend observation publication. |
| 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 | | 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` | `fifo-fact` for lifecycle transitions | | 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` | `coalesced-latest` by metric key | | 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 ### 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 ### Step 1. Add Event Types And A Minimal Dispatcher
Status: complete.
Introduce: Introduce:
- `RuntimeEvent` - `RuntimeEvent`
@@ -472,6 +491,8 @@ Start with events that do not change behavior:
### Step 2. Convert `RuntimeUpdateController` Into An Event Handler ### 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: `RuntimeUpdateController` is already close to an event effect applier. Phase 2 should narrow it into a handler for:
- coordinator outcome events - 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. 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 ### 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: Keep coalescing, but replace the fixed `25 x Sleep(10)` cadence with:
- a condition variable or waitable event - 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. 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 ### Step 4. Route Shader Build Lifecycle Through Events
Status: mostly complete.
Turn the current request/apply/failure/success path into explicit events: Turn the current request/apply/failure/success path into explicit events:
- `ShaderBuildRequested` - `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. 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 ### Step 5. Route Runtime Broadcasts Through Events
Status: partially complete.
Replace direct "broadcast now" decisions with: Replace direct "broadcast now" decisions with:
- `RuntimeStatePresentationChanged` - `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. 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 ### 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: Before using the event system for hotter paths, add metrics:
- event queue depth - event queue depth
@@ -528,6 +565,8 @@ Before using the event system for hotter paths, add metrics:
These should feed `HealthTelemetry`. 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 ## Dependency Rules
Allowed: 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. Phase 2 should add tests that do not require GL, DeckLink, or network sockets.
Recommended tests: Implemented tests:
- FIFO events dispatch in sequence order - FIFO events dispatch in sequence order
- coalesced events keep the latest payload and count collapsed updates - coalesced events keep the latest payload and count collapsed updates
- rejected mutations publish rejection events without downstream snapshot/build events - rejected mutations publish rejection events without downstream snapshot/build events
- accepted parameter mutations publish the expected follow-up event set - 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 - handler failures are reported as health/log events
- queue depth and oldest-event-age metrics update predictably - 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 Exit Criteria
Phase 2 can be considered complete once the project can say: Phase 2 can be considered complete once the project can say:
- there is a typed internal event envelope and dispatcher - [x] there is a typed internal event envelope and dispatcher
- `ControlServices` emits typed events for OSC commits, broadcast requests, and reload/file-change work - [x] `OpenGLComposite` owns the dispatcher as the current composition root
- `RuntimeCoordinator` publishes explicit accepted/rejected/follow-up events instead of callers interpreting broad result objects everywhere - [x] `ControlServices` emits typed events for OSC commits and broadcast requests
- `RuntimeUpdateController` handles events rather than polling all runtime work sources directly - [x] reload/file-change work publishes typed ingress and follow-up events
- shader build request/readiness/failure/application is represented as events - [x] `RuntimeCoordinator` publishes explicit accepted/rejected/follow-up events
- runtime-state broadcasts are event-driven and coalesced - [x] callers no longer need broad compatibility result queues for normal runtime side effects
- event queues expose depth, age, coalescing, and failure metrics - [x] `RuntimeUpdateController` handles event-driven broadcast, shader build, compile status, render reset, and health observation paths
- coarse sleep polling is no longer the default coordination model for service work - [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 ## 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? - Resolved: the first dispatcher is single-process, app-owned, and pumped through the current app/update path.
- Should high-rate OSC transient overlay events go through the same bus, or should only commit/settle events enter the bus initially? - Resolved: event payloads use typed structs carried by `std::variant`.
- Should event payloads use `std::variant`, type-erased handlers, or separate strongly typed queues per family? - Resolved: persistence requests are represented in Phase 2 even though background persistence lands later.
- How much of `RuntimeCoordinatorResult` should survive as an internal helper versus being replaced by explicit events? - Resolved: backend callback events are introduced now as observation-only events.
- Should persistence requests be represented in Phase 2 even though the background writer lands later? - 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?
- Should backend callback events be introduced now as observation-only events, or wait until the backend state-machine 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 ## Short Version

View File

@@ -67,6 +67,18 @@ void TestRuntimeEventPayloadTypes()
Expect(RuntimeEventPayloadType(persistence) == RuntimeEventType::RuntimePersistenceRequested, "runtime persistence payload maps to persistence event type"); Expect(RuntimeEventPayloadType(persistence) == RuntimeEventType::RuntimePersistenceRequested, "runtime persistence payload maps to persistence event type");
Expect(persistence.debounceAllowed, "runtime persistence payload carries debounce policy"); 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; ShaderBuildEvent preparedBuild;
preparedBuild.phase = RuntimeEventShaderBuildPhase::Prepared; preparedBuild.phase = RuntimeEventShaderBuildPhase::Prepared;
preparedBuild.inputWidth = 1920; preparedBuild.inputWidth = 1920;
@@ -234,12 +246,89 @@ void TestRuntimeEventDispatcher()
Expect(!dispatcher.Publish(mismatched), "dispatcher rejects mismatched event type and payload"); Expect(!dispatcher.Publish(mismatched), "dispatcher rejects mismatched event type and payload");
RuntimeEventDispatcher tinyDispatcher(1); RuntimeEventDispatcher tinyDispatcher(1);
Expect(tinyDispatcher.PublishPayload(broadcast, "test"), "tiny dispatcher accepts first event"); RuntimeMutationEvent acceptedMutation;
Expect(!tinyDispatcher.PublishPayload(broadcast, "test"), "tiny dispatcher rejects event when queue is full"); 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(); RuntimeEventQueueMetrics metrics = tinyDispatcher.GetQueueMetrics();
Expect(metrics.droppedCount == 1, "dispatcher exposes queue drop metrics"); 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<RuntimeStateBroadcastRequestedEvent>(&event.payload);
if (payload)
seenReason = payload->reason;
++broadcastHandlerCount;
});
dispatcher.Subscribe(RuntimeEventType::ShaderBuildRequested, [&](const RuntimeEvent& event) {
const auto* payload = std::get_if<ShaderBuildEvent>(&event.payload);
if (payload)
seenShaderMessage = payload->message;
++shaderHandlerCount;
});
dispatcher.Subscribe(RuntimeEventType::TimingSampleRecorded, [&](const RuntimeEvent& event) {
const auto* payload = std::get_if<TimingSampleRecordedEvent>(&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() void TestRuntimeEventCoalescingQueue()
{ {
RuntimeEventCoalescingQueue queue(2); RuntimeEventCoalescingQueue queue(2);
@@ -401,6 +490,124 @@ void TestAcceptedMutationFollowUps()
Expect(persistencePayload && persistencePayload->reason == "SetLayerShader", "persistence follow-up preserves mutation action reason"); 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<RuntimeStateBroadcastRequestedEvent>(&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<ShaderBuildEvent>(&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<RuntimeReloadRequestedEvent>(&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<FileChangeDetectedEvent>(&fileEvent->payload) : nullptr;
Expect(filePayload && filePayload->shaderPackageCandidate, "file reload bridge marks shader package candidate changes");
}
void TestRejectedMutationHasNoDownstreamFollowUps() void TestRejectedMutationHasNoDownstreamFollowUps()
{ {
RuntimeEventTestHarness harness; RuntimeEventTestHarness harness;
@@ -487,10 +694,14 @@ int main()
TestRuntimeEventEnvelope(); TestRuntimeEventEnvelope();
TestRuntimeEventQueue(); TestRuntimeEventQueue();
TestRuntimeEventDispatcher(); TestRuntimeEventDispatcher();
TestRuntimeEventDispatcherCoalescing();
TestRuntimeEventCoalescingQueue(); TestRuntimeEventCoalescingQueue();
TestRuntimeEventCoalescingCustomKey(); TestRuntimeEventCoalescingCustomKey();
TestRuntimeEventTestHarness(); TestRuntimeEventTestHarness();
TestAcceptedMutationFollowUps(); TestAcceptedMutationFollowUps();
TestAppLevelBroadcastAndBuildCoalescing();
TestManualReloadBridgeEvents();
TestFileReloadBridgeEvents();
TestRejectedMutationHasNoDownstreamFollowUps(); TestRejectedMutationHasNoDownstreamFollowUps();
TestShaderBuildGenerationEventMatching(); TestShaderBuildGenerationEventMatching();
TestHandlerFailureCanBecomeTelemetryEvent(); TestHandlerFailureCanBecomeTelemetryEvent();